Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-01-11 15:51:22 +07:00
parent 6f8a8de9be
commit 309606565b
46 changed files with 28649 additions and 28522 deletions

View File

@@ -149,4 +149,4 @@ TypeScript 文档https://www.typescriptlang.org/
Capacitor 文档https://capacitorjs.com/docs/getting-started Capacitor 文档https://capacitorjs.com/docs/getting-started
TailwindCSS 文档https://tailwindcss.com/docs/installation/using-vite TailwindCSS 文档https://tailwindcss.com/docs/installation/using-vite
TradingView Charting Library 文档https://www.tradingview.com/widget-docs/getting-started/#getting-started TradingView Charting Library 文档https://www.tradingview.com/widget-docs/getting-started/#getting-started
PWA 文档https://vite-pwa-org.netlify.app/ PWA 文档https://vite-pwa-org.netlify.app/

View File

@@ -20,16 +20,16 @@
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted } from "vue";
const { checkAndPromptUpdate } = useAppUpdate() const { checkAndPromptUpdate } = useAppUpdate();
onMounted(async () => { onMounted(async () => {
// 应用启动 3 秒后检查更新 // 应用启动 3 秒后检查更新
setTimeout(() => { setTimeout(() => {
checkAndPromptUpdate() checkAndPromptUpdate();
}, 3000) }, 3000);
}) });
</script> </script>
``` ```
@@ -38,6 +38,18 @@ onMounted(async () => {
在设置页面添加"检查更新"按钮: 在设置页面添加"检查更新"按钮:
```vue ```vue
<script setup lang="ts">
const {
isChecking,
currentVersion,
checkAndPromptUpdate,
} = useAppUpdate();
async function handleCheckUpdate() {
await checkAndPromptUpdate();
}
</script>
<template> <template>
<ion-page> <ion-page>
<ion-header> <ion-header>
@@ -59,18 +71,6 @@ onMounted(async () => {
</ion-content> </ion-content>
</ion-page> </ion-page>
</template> </template>
<script setup lang="ts">
const {
isChecking,
currentVersion,
checkAndPromptUpdate,
} = useAppUpdate()
async function handleCheckUpdate() {
await checkAndPromptUpdate()
}
</script>
``` ```
### 3. 自定义更新提示 ### 3. 自定义更新提示
@@ -86,19 +86,20 @@ const {
updateMessage, updateMessage,
checkForUpdate, checkForUpdate,
openStoreUpdate, openStoreUpdate,
} = useAppUpdate() } = useAppUpdate();
async function checkUpdate() { async function checkUpdate() {
const result = await checkForUpdate() const result = await checkForUpdate();
if (result.hasUpdate) { if (result.hasUpdate) {
// 自定义提示逻辑 // 自定义提示逻辑
if (result.forceUpdate) { if (result.forceUpdate) {
// 强制更新 - 阻止用户继续使用 // 强制更新 - 阻止用户继续使用
showForceUpdateModal() showForceUpdateModal();
} else { }
else {
// 可选更新 - 显示提示但允许跳过 // 可选更新 - 显示提示但允许跳过
showOptionalUpdateToast() showOptionalUpdateToast();
} }
} }
} }
@@ -108,26 +109,28 @@ async function checkUpdate() {
### 4. 显示版本信息 ### 4. 显示版本信息
```vue ```vue
<script setup lang="ts">
import { onMounted } from "vue";
const { currentVersion, hasUpdate, getCurrentVersion, checkForUpdate } = useAppUpdate();
onMounted(async () => {
await getCurrentVersion();
await checkForUpdate();
});
</script>
<template> <template>
<ion-item> <ion-item>
<ion-label> <ion-label>
<h2>应用版本</h2> <h2>应用版本</h2>
<p>{{ currentVersion || '获取中...' }}</p> <p>{{ currentVersion || '获取中...' }}</p>
</ion-label> </ion-label>
<ion-badge v-if="hasUpdate" color="danger">有更新</ion-badge> <ion-badge v-if="hasUpdate" color="danger">
有更新
</ion-badge>
</ion-item> </ion-item>
</template> </template>
<script setup lang="ts">
import { onMounted } from 'vue'
const { currentVersion, hasUpdate, getCurrentVersion, checkForUpdate } = useAppUpdate()
onMounted(async () => {
await getCurrentVersion()
await checkForUpdate()
})
</script>
``` ```
## API 参考 ## API 参考

View File

@@ -51,23 +51,23 @@ curl https://your-domain.com/version.json
```typescript ```typescript
// Node.js / Elysia 示例 // Node.js / Elysia 示例
app.get('/api/app/version', async (ctx) => { app.get("/api/app/version", async (ctx) => {
// 从前端静态资源读取版本 // 从前端静态资源读取版本
const response = await fetch('https://your-frontend-domain.com/version.json') const response = await fetch("https://your-frontend-domain.com/version.json");
const frontendVersion = await response.json() const frontendVersion = await response.json();
const { platform, currentVersion } = ctx.query const { platform, currentVersion } = ctx.query;
return { return {
version: frontendVersion.version, version: frontendVersion.version,
forceUpdate: compareVersion(currentVersion, frontendVersion.version) < 0, forceUpdate: compareVersion(currentVersion, frontendVersion.version) < 0,
updateMessage: '修复了一些问题', updateMessage: "修复了一些问题",
updateUrl: platform === 'ios' updateUrl: platform === "ios"
? 'https://apps.apple.com/app/xxx' ? "https://apps.apple.com/app/xxx"
: 'https://play.google.com/store/apps/details?id=xxx', : "https://play.google.com/store/apps/details?id=xxx",
minSupportVersion: '0.0.1', minSupportVersion: "0.0.1",
} };
}) });
``` ```
**方式 2部署时同步到后端** **方式 2部署时同步到后端**
@@ -88,18 +88,18 @@ app.get('/api/app/version', async (ctx) => {
后端直接读取本地文件: 后端直接读取本地文件:
```typescript ```typescript
import fs from 'fs' import fs from "node:fs";
app.get("/api/app/version", async (ctx) => {
const versionFile = fs.readFileSync("/app/frontend-version.json", "utf-8");
const { version } = JSON.parse(versionFile);
app.get('/api/app/version', async (ctx) => {
const versionFile = fs.readFileSync('/app/frontend-version.json', 'utf-8')
const { version } = JSON.parse(versionFile)
return { return {
version, version,
forceUpdate: false, forceUpdate: false,
// ... // ...
} };
}) });
``` ```
--- ---
@@ -112,7 +112,7 @@ app.get('/api/app/version', async (ctx) => {
```json ```json
{ {
"version": "1.2.3" // 只改这里 "version": "1.2.3" // 只改这里
} }
``` ```
@@ -162,27 +162,27 @@ versionName "1.2.3" // 与 package.json 保持一致
```javascript ```javascript
#!/usr/bin/env node #!/usr/bin/env node
import fs from 'fs' import { execSync } from "node:child_process";
import { execSync } from 'child_process' import fs from "node:fs";
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8')) const packageJson = JSON.parse(fs.readFileSync("./package.json", "utf-8"));
const version = packageJson.version const version = packageJson.version;
console.log(`Syncing version ${version} to native projects...`) console.log(`Syncing version ${version} to native projects...`);
// iOS // iOS
const infoPlist = './ios/App/App/Info.plist' const infoPlist = "./ios/App/App/Info.plist";
let plistContent = fs.readFileSync(infoPlist, 'utf-8') let plistContent = fs.readFileSync(infoPlist, "utf-8");
plistContent = plistContent.replace( plistContent = plistContent.replace(
/<key>CFBundleShortVersionString<\/key>\s*<string>.*?<\/string>/, /<key>CFBundleShortVersionString<\/key>\s*<string>.*?<\/string>/,
`<key>CFBundleShortVersionString</key>\n\t<string>${version}</string>` `<key>CFBundleShortVersionString</key>\n\t<string>${version}</string>`
) );
fs.writeFileSync(infoPlist, plistContent) fs.writeFileSync(infoPlist, plistContent);
// Android (需要安装 gradle 解析库或手动更新) // Android (需要安装 gradle 解析库或手动更新)
console.log('Please manually update android/app/build.gradle versionName') console.log("Please manually update android/app/build.gradle versionName");
console.log('✓ Version synced!') console.log("✓ Version synced!");
``` ```
`package.json` 中添加脚本: `package.json` 中添加脚本:
@@ -237,24 +237,24 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: '18' node-version: "18"
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install
- name: Build - name: Build
run: pnpm run build run: pnpm run build
env: env:
NODE_ENV: production NODE_ENV: production
- name: Deploy to server - name: Deploy to server
run: | run: |
rsync -avz dist/ user@server:/var/www/html/ rsync -avz dist/ user@server:/var/www/html/
- name: Notify backend - name: Notify backend
run: | run: |
curl -X POST https://your-backend.com/api/webhook/frontend-deployed \ curl -X POST https://your-backend.com/api/webhook/frontend-deployed \
@@ -284,107 +284,108 @@ jobs:
**一次配置,永久受益!** 🎉 **一次配置,永久受益!** 🎉
## 后端 API 实现示例 - 版本检查接口 ## 后端 API 实现示例 - 版本检查接口
```ts ```ts
import { Elysia, t } from 'elysia' // 方案二:从本地文件读取(适用于前后端部署在同一服务器)
import fs from "node:fs";
import { Elysia, t } from "elysia";
// 版本比较工具函数 // 版本比较工具函数
function compareVersion(version1: string, version2: string): number { function compareVersion(version1: string, version2: string): number {
const v1Parts = version1.split('.').map(Number) const v1Parts = version1.split(".").map(Number);
const v2Parts = version2.split('.').map(Number) const v2Parts = version2.split(".").map(Number);
const maxLength = Math.max(v1Parts.length, v2Parts.length) const maxLength = Math.max(v1Parts.length, v2Parts.length);
for (let i = 0; i < maxLength; i++) { for (let i = 0; i < maxLength; i++) {
const v1Part = v1Parts[i] || 0 const v1Part = v1Parts[i] || 0;
const v2Part = v2Parts[i] || 0 const v2Part = v2Parts[i] || 0;
if (v1Part < v2Part) return -1 if (v1Part < v2Part)
if (v1Part > v2Part) return 1 return -1;
if (v1Part > v2Part)
return 1;
} }
return 0 return 0;
} }
// 方案一:从前端静态资源读取版本(推荐) // 方案一:从前端静态资源读取版本(推荐)
async function getFrontendVersionFromURL(): Promise<{ async function getFrontendVersionFromURL(): Promise<{
version: string version: string;
buildTime: string buildTime: string;
gitCommit: string gitCommit: string;
environment: string environment: string;
}> { }> {
const response = await fetch('https://your-frontend-domain.com/version.json') const response = await fetch("https://your-frontend-domain.com/version.json");
return await response.json() return await response.json();
} }
// 方案二:从本地文件读取(适用于前后端部署在同一服务器)
import fs from 'fs'
function getFrontendVersionFromFile(): { function getFrontendVersionFromFile(): {
version: string version: string;
buildTime: string buildTime: string;
gitCommit: string gitCommit: string;
environment: string environment: string;
} { } {
const content = fs.readFileSync('/app/frontend-version.json', 'utf-8') const content = fs.readFileSync("/app/frontend-version.json", "utf-8");
return JSON.parse(content) return JSON.parse(content);
} }
// 应用商店链接配置 // 应用商店链接配置
const APP_STORE_URLS = { const APP_STORE_URLS = {
ios: 'https://apps.apple.com/app/id123456789', ios: "https://apps.apple.com/app/id123456789",
android: 'https://play.google.com/store/apps/details?id=riwa.ionic.app', android: "https://play.google.com/store/apps/details?id=riwa.ionic.app",
} };
// 版本策略配置(可存储在数据库) // 版本策略配置(可存储在数据库)
const VERSION_POLICIES = { const VERSION_POLICIES = {
minSupportVersion: '0.0.1', // 最低支持版本 minSupportVersion: "0.0.1", // 最低支持版本
forceUpdateVersions: ['0.0.1'], // 需要强制更新的版本列表 forceUpdateVersions: ["0.0.1"], // 需要强制更新的版本列表
updateMessages: { updateMessages: {
'zh-CN': '修复了一些问题并优化了性能', "zh-CN": "修复了一些问题并优化了性能",
'en-US': 'Bug fixes and performance improvements', "en-US": "Bug fixes and performance improvements",
}, },
} };
// Elysia 路由定义 // Elysia 路由定义
const app = new Elysia() const app = new Elysia()
.get( .get(
'/api/app/version', "/api/app/version",
async ({ query, headers }) => { async ({ query, headers }) => {
const { platform, currentVersion } = query const { platform, currentVersion } = query;
const lang = headers['accept-language']?.split(',')[0] || 'en-US' const lang = headers["accept-language"]?.split(",")[0] || "en-US";
try { try {
// 获取前端版本信息 // 获取前端版本信息
const frontendVersion = await getFrontendVersionFromURL() const frontendVersion = await getFrontendVersionFromURL();
// 或使用本地文件: const frontendVersion = getFrontendVersionFromFile() // 或使用本地文件: const frontendVersion = getFrontendVersionFromFile()
// 判断是否需要更新 // 判断是否需要更新
const hasUpdate = compareVersion(currentVersion, frontendVersion.version) < 0 const hasUpdate = compareVersion(currentVersion, frontendVersion.version) < 0;
// 判断是否强制更新 // 判断是否强制更新
let forceUpdate = VERSION_POLICIES.forceUpdateVersions.includes(currentVersion) let forceUpdate = VERSION_POLICIES.forceUpdateVersions.includes(currentVersion);
// 检查是否低于最低支持版本 // 检查是否低于最低支持版本
if (compareVersion(currentVersion, VERSION_POLICIES.minSupportVersion) < 0) { if (compareVersion(currentVersion, VERSION_POLICIES.minSupportVersion) < 0) {
forceUpdate = true forceUpdate = true;
} }
// 获取更新链接 // 获取更新链接
const updateUrl = platform === 'ios' const updateUrl = platform === "ios"
? APP_STORE_URLS.ios ? APP_STORE_URLS.ios
: platform === 'android' : platform === "android"
? APP_STORE_URLS.android ? APP_STORE_URLS.android
: '' : "";
// 获取更新说明(多语言) // 获取更新说明(多语言)
const updateMessage = VERSION_POLICIES.updateMessages[lang] const updateMessage = VERSION_POLICIES.updateMessages[lang]
|| VERSION_POLICIES.updateMessages['en-US'] || VERSION_POLICIES.updateMessages["en-US"];
return { return {
version: frontendVersion.version, version: frontendVersion.version,
buildNumber: parseInt(frontendVersion.version.replace(/\./g, '')), buildNumber: Number.parseInt(frontendVersion.version.replace(/\./g, "")),
buildTime: frontendVersion.buildTime, buildTime: frontendVersion.buildTime,
gitCommit: frontendVersion.gitCommit, gitCommit: frontendVersion.gitCommit,
forceUpdate, forceUpdate,
@@ -392,26 +393,27 @@ const app = new Elysia()
updateUrl, updateUrl,
minSupportVersion: VERSION_POLICIES.minSupportVersion, minSupportVersion: VERSION_POLICIES.minSupportVersion,
releaseNotes: [ releaseNotes: [
'修复了已知问题', "修复了已知问题",
'优化了应用性能', "优化了应用性能",
'改进了用户界面', "改进了用户界面",
], ],
} };
} catch (error) { }
console.error('Failed to get frontend version:', error) catch (error) {
console.error("Failed to get frontend version:", error);
// 降级处理:返回当前版本,不强制更新 // 降级处理:返回当前版本,不强制更新
return { return {
version: currentVersion, version: currentVersion,
forceUpdate: false, forceUpdate: false,
updateMessage: '', updateMessage: "",
updateUrl: '', updateUrl: "",
} };
} }
}, },
{ {
query: t.Object({ query: t.Object({
platform: t.Union([t.Literal('ios'), t.Literal('android'), t.Literal('web')]), platform: t.Union([t.Literal("ios"), t.Literal("android"), t.Literal("web")]),
currentVersion: t.String(), currentVersion: t.String(),
}), }),
response: t.Object({ response: t.Object({
@@ -426,13 +428,13 @@ const app = new Elysia()
releaseNotes: t.Optional(t.Array(t.String())), releaseNotes: t.Optional(t.Array(t.String())),
}), }),
} }
) );
export default app export default app;
/** /**
* 使用示例: * 使用示例:
* *
* 1. 启动后端服务 * 1. 启动后端服务
* 2. 前端请求GET /api/app/version?platform=ios&currentVersion=0.0.1 * 2. 前端请求GET /api/app/version?platform=ios&currentVersion=0.0.1
* 3. 后端响应: * 3. 后端响应:
@@ -448,9 +450,9 @@ export default app
/** /**
* 数据库存储方案(可选) * 数据库存储方案(可选)
* *
* 如果需要更灵活的版本策略管理,可以将配置存储在数据库: * 如果需要更灵活的版本策略管理,可以将配置存储在数据库:
* *
* CREATE TABLE app_versions ( * CREATE TABLE app_versions (
* id SERIAL PRIMARY KEY, * id SERIAL PRIMARY KEY,
* platform VARCHAR(20) NOT NULL, * platform VARCHAR(20) NOT NULL,
@@ -463,12 +465,11 @@ export default app
* release_notes JSONB, * release_notes JSONB,
* created_at TIMESTAMP DEFAULT NOW() * created_at TIMESTAMP DEFAULT NOW()
* ); * );
* *
* 然后从数据库查询版本策略: * 然后从数据库查询版本策略:
* const policy = await db.query( * const policy = await db.query(
* 'SELECT * FROM app_versions WHERE platform = $1 ORDER BY created_at DESC LIMIT 1', * 'SELECT * FROM app_versions WHERE platform = $1 ORDER BY created_at DESC LIMIT 1',
* [platform] * [platform]
* ) * )
*/ */
```
```

View File

@@ -36,7 +36,7 @@
"@ionic/vue-router": "^8.7.11", "@ionic/vue-router": "^8.7.11",
"@riwa/api-types": "http://192.168.1.7:9527/api/riwa-eden-0.0.118.tgz", "@riwa/api-types": "http://192.168.1.7:9527/api/riwa-eden-0.0.118.tgz",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@vee-validate/yup": "^4.15.1", "@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
"@vueuse/integrations": "^14.1.0", "@vueuse/integrations": "^14.1.0",
"@vueuse/router": "^14.1.0", "@vueuse/router": "^14.1.0",
@@ -55,7 +55,7 @@
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-i18n": "^11.2.2", "vue-i18n": "^11.2.2",
"vue-router": "^4.6.3", "vue-router": "^4.6.3",
"yup": "^1.7.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^6.6.1", "@antfu/eslint-config": "^6.6.1",

View File

@@ -50,19 +50,19 @@ pnpm generate
```typescript ```typescript
export const currentVersion: AppVersion = { export const currentVersion: AppVersion = {
version: '1.0.0', version: "1.0.0",
buildNumber: '100', buildNumber: "100",
releaseDate: '2025-12-30', releaseDate: "2025-12-30",
releaseNotes: { releaseNotes: {
'zh-CN': ['更新内容...'], "zh-CN": ["更新内容..."],
'en-US': ['What\'s new...'], "en-US": ["What's new..."],
}, },
downloads: { downloads: {
ios: 'https://example.com/app.ipa', ios: "https://example.com/app.ipa",
android: 'https://example.com/app.apk', android: "https://example.com/app.apk",
h5: 'https://app.example.com', h5: "https://app.example.com",
}, },
} };
``` ```
### 接入真实 API ### 接入真实 API
@@ -81,9 +81,9 @@ Nuxt UI 使用 TailwindCSS 4可在 `nuxt.config.ts` 中配置:
```typescript ```typescript
export default defineNuxtConfig({ export default defineNuxtConfig({
colorMode: { colorMode: {
preference: 'system', // 'light' | 'dark' | 'system' preference: "system", // 'light' | 'dark' | 'system'
}, },
}) });
``` ```
## 目录结构 ## 目录结构

View File

@@ -1,7 +1,7 @@
export default defineAppConfig({ export default defineAppConfig({
ui: { ui: {
colors: { colors: {
primary: 'blue', primary: "blue",
}, },
}, },
}) });

View File

@@ -6,4 +6,3 @@
<NuxtPage /> <NuxtPage />
</UApp> </UApp>
</template> </template>

View File

@@ -64,7 +64,8 @@
/* 发光脉冲效果 */ /* 发光脉冲效果 */
@keyframes glow-pulse { @keyframes glow-pulse {
0%, 100% { 0%,
100% {
box-shadow: 0 0 20px rgba(99, 102, 241, 0.3); box-shadow: 0 0 20px rgba(99, 102, 241, 0.3);
} }
50% { 50% {
@@ -96,7 +97,8 @@
/* 悬浮动画 */ /* 悬浮动画 */
@keyframes float { @keyframes float {
0%, 100% { 0%,
100% {
transform: translateY(0); transform: translateY(0);
} }
50% { 50% {
@@ -208,7 +210,8 @@
/* 脉冲缩放动画 */ /* 脉冲缩放动画 */
@keyframes pulse-scale { @keyframes pulse-scale {
0%, 100% { 0%,
100% {
transform: scale(1); transform: scale(1);
} }
50% { 50% {
@@ -250,7 +253,8 @@
/* 微妙的悬浮动画 */ /* 微妙的悬浮动画 */
@keyframes float-subtle { @keyframes float-subtle {
0%, 100% { 0%,
100% {
transform: translateY(0) translateZ(0); transform: translateY(0) translateZ(0);
} }
50% { 50% {
@@ -284,7 +288,8 @@
/* 发光脉冲动画 */ /* 发光脉冲动画 */
@keyframes pulse-glow { @keyframes pulse-glow {
0%, 100% { 0%,
100% {
opacity: 0.5; opacity: 0.5;
} }
50% { 50% {
@@ -298,7 +303,8 @@
/* 微妙的脉冲动画 */ /* 微妙的脉冲动画 */
@keyframes pulse-subtle { @keyframes pulse-subtle {
0%, 100% { 0%,
100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
@@ -362,7 +368,8 @@
/* 震动效果 */ /* 震动效果 */
@keyframes shake { @keyframes shake {
0%, 100% { 0%,
100% {
transform: translateX(0); transform: translateX(0);
} }
25% { 25% {
@@ -401,7 +408,8 @@
/* 呼吸灯效果 */ /* 呼吸灯效果 */
@keyframes breathe { @keyframes breathe {
0%, 100% { 0%,
100% {
opacity: 0.6; opacity: 0.6;
transform: scale(1); transform: scale(1);
} }

View File

@@ -1,59 +1,59 @@
<script setup lang="ts"> <script setup lang="ts">
const { t } = useI18n() const { t } = useI18n();
const { canInstall, isInstalled, install } = usePWAInstall() const { canInstall, isInstalled, install } = usePWAInstall();
const installing = ref(false) const installing = ref(false);
const dismissed = ref(false) const dismissed = ref(false);
// 从 localStorage 读取是否已关闭,但只在未安装状态下有效 // 从 localStorage 读取是否已关闭,但只在未安装状态下有效
onMounted(() => { onMounted(() => {
// 如果应用未安装,检查用户是否之前关闭过横幅 // 如果应用未安装,检查用户是否之前关闭过横幅
if (!isInstalled.value) { if (!isInstalled.value) {
dismissed.value = localStorage.getItem('pwa-banner-dismissed') === 'true' dismissed.value = localStorage.getItem("pwa-banner-dismissed") === "true";
} }
else { else {
// 如果应用已安装,清除关闭记录(为了卸载后能再次提示) // 如果应用已安装,清除关闭记录(为了卸载后能再次提示)
localStorage.removeItem('pwa-banner-dismissed') localStorage.removeItem("pwa-banner-dismissed");
} }
}) });
// 监听安装状态变化 // 监听安装状态变化
watch(isInstalled, (newValue) => { watch(isInstalled, (newValue) => {
if (newValue) { if (newValue) {
// 应用安装后,清除关闭记录 // 应用安装后,清除关闭记录
localStorage.removeItem('pwa-banner-dismissed') localStorage.removeItem("pwa-banner-dismissed");
dismissed.value = false dismissed.value = false;
} }
}) });
// 监听 canInstall 变化(卸载后会重新触发 beforeinstallprompt // 监听 canInstall 变化(卸载后会重新触发 beforeinstallprompt
watch(canInstall, (newValue) => { watch(canInstall, (newValue) => {
if (newValue && !isInstalled.value) { if (newValue && !isInstalled.value) {
// 如果可以安装且未安装,清除之前的关闭记录 // 如果可以安装且未安装,清除之前的关闭记录
// 这样卸载后再次访问就会重新显示横幅 // 这样卸载后再次访问就会重新显示横幅
localStorage.removeItem('pwa-banner-dismissed') localStorage.removeItem("pwa-banner-dismissed");
dismissed.value = false dismissed.value = false;
} }
}) });
async function handleInstall() { async function handleInstall() {
installing.value = true installing.value = true;
try { try {
const success = await install() const success = await install();
if (success) { if (success) {
console.log('PWA 安装成功') console.log("PWA 安装成功");
} }
} }
finally { finally {
installing.value = false installing.value = false;
} }
} }
function dismissBanner() { function dismissBanner() {
dismissed.value = true dismissed.value = true;
localStorage.setItem('pwa-banner-dismissed', 'true') localStorage.setItem("pwa-banner-dismissed", "true");
} }
const showBanner = computed(() => canInstall.value && !isInstalled.value && !dismissed.value) const showBanner = computed(() => canInstall.value && !isInstalled.value && !dismissed.value);
</script> </script>
<template> <template>
@@ -73,7 +73,7 @@ const showBanner = computed(() => canInstall.value && !isInstalled.value && !dis
<div class="absolute inset-0 bg-linear-to-br from-primary-50/80 via-blue-50/50 to-purple-50/80 dark:from-primary-950/50 dark:via-blue-950/30 dark:to-purple-950/50" /> <div class="absolute inset-0 bg-linear-to-br from-primary-50/80 via-blue-50/50 to-purple-50/80 dark:from-primary-950/50 dark:via-blue-950/30 dark:to-purple-950/50" />
<div class="absolute top-0 right-0 w-64 h-64 bg-primary-500/10 rounded-full blur-3xl" /> <div class="absolute top-0 right-0 w-64 h-64 bg-primary-500/10 rounded-full blur-3xl" />
<div class="absolute bottom-0 left-0 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl" /> <div class="absolute bottom-0 left-0 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl" />
<!-- 内容 --> <!-- 内容 -->
<div class="relative z-10 flex items-center gap-4"> <div class="relative z-10 flex items-center gap-4">
<!-- 图标 --> <!-- 图标 -->

View File

@@ -1,19 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
const { t } = useI18n() const { t } = useI18n();
const { canInstall, isInstalled, install } = usePWAInstall() const { canInstall, isInstalled, install } = usePWAInstall();
const installing = ref(false) const installing = ref(false);
async function handleInstall() { async function handleInstall() {
installing.value = true installing.value = true;
try { try {
const success = await install() const success = await install();
if (success) { if (success) {
// 可以显示成功提示 // 可以显示成功提示
console.log('PWA 安装成功') console.log("PWA 安装成功");
} }
} }
finally { finally {
installing.value = false installing.value = false;
} }
} }
</script> </script>

View File

@@ -1,52 +1,52 @@
import { ref, onMounted } from 'vue' import { onMounted, ref } from "vue";
export function usePWAInstall() { export function usePWAInstall() {
const deferredPrompt = ref<any>(null) const deferredPrompt = ref<any>(null);
const canInstall = ref(false) const canInstall = ref(false);
const isInstalled = ref(false) const isInstalled = ref(false);
onMounted(() => { onMounted(() => {
// 检查是否已安装 // 检查是否已安装
if (window.matchMedia('(display-mode: standalone)').matches) { if (window.matchMedia("(display-mode: standalone)").matches) {
isInstalled.value = true isInstalled.value = true;
return return;
} }
// 监听安装提示事件 // 监听安装提示事件
window.addEventListener('beforeinstallprompt', (e) => { window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault() e.preventDefault();
deferredPrompt.value = e deferredPrompt.value = e;
canInstall.value = true canInstall.value = true;
}) });
// 监听安装成功事件 // 监听安装成功事件
window.addEventListener('appinstalled', () => { window.addEventListener("appinstalled", () => {
deferredPrompt.value = null deferredPrompt.value = null;
canInstall.value = false canInstall.value = false;
isInstalled.value = true isInstalled.value = true;
}) });
}) });
async function install() { async function install() {
if (!deferredPrompt.value) { if (!deferredPrompt.value) {
return false return false;
} }
try { try {
await deferredPrompt.value.prompt() await deferredPrompt.value.prompt();
const { outcome } = await deferredPrompt.value.userChoice const { outcome } = await deferredPrompt.value.userChoice;
if (outcome === 'accepted') { if (outcome === "accepted") {
deferredPrompt.value = null deferredPrompt.value = null;
canInstall.value = false canInstall.value = false;
return true return true;
} }
return false return false;
} }
catch (error) { catch (error) {
console.error('安装失败:', error) console.error("安装失败:", error);
return false return false;
} }
} }
@@ -54,5 +54,5 @@ export function usePWAInstall() {
canInstall, canInstall,
isInstalled, isInstalled,
install, install,
} };
} }

View File

@@ -1,32 +1,32 @@
import type { Platform } from '~/types' import type { Platform } from "~/types";
export function usePlatformDetection() { export function usePlatformDetection() {
const platform = useState<Platform>('platform', () => 'unknown') const platform = useState<Platform>("platform", () => "unknown");
function detectPlatform(): Platform { function detectPlatform(): Platform {
if (import.meta.server) if (import.meta.server)
return 'unknown' return "unknown";
const ua = navigator.userAgent.toLowerCase() const ua = navigator.userAgent.toLowerCase();
if (/iphone|ipad|ipod/.test(ua)) if (/iphone|ipad|ipod/.test(ua))
return 'ios' return "ios";
else if (/android/.test(ua)) else if (/android/.test(ua))
return 'android' return "android";
else if (/windows|macintosh|linux/.test(ua)) else if (/windows|macintosh|linux/.test(ua))
return 'desktop' return "desktop";
return 'unknown' return "unknown";
} }
onMounted(() => { onMounted(() => {
platform.value = detectPlatform() platform.value = detectPlatform();
}) });
return { return {
platform: readonly(platform), platform: readonly(platform),
isIOS: computed(() => platform.value === 'ios'), isIOS: computed(() => platform.value === "ios"),
isAndroid: computed(() => platform.value === 'android'), isAndroid: computed(() => platform.value === "android"),
isDesktop: computed(() => platform.value === 'desktop'), isDesktop: computed(() => platform.value === "desktop"),
} };
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,34 @@
import type { AppVersion, DownloadStats } from '~/types' import type { AppVersion, DownloadStats } from "~/types";
// 当前版本信息 // 当前版本信息
export const currentVersion: AppVersion = { export const currentVersion: AppVersion = {
version: '1.0.0', version: "1.0.0",
buildNumber: '100', buildNumber: "100",
releaseDate: '2025-12-30', releaseDate: "2025-12-30",
releaseNotes: { releaseNotes: {
'zh-CN': [ "zh-CN": [
'🎉 首次发布', "🎉 首次发布",
'✨ 全新的用户界面设计', "✨ 全新的用户界面设计",
'🔐 增强的安全特性', "🔐 增强的安全特性",
'⚡ 性能优化,响应速度提升 30%', "⚡ 性能优化,响应速度提升 30%",
'🌍 支持多语言切换', "🌍 支持多语言切换",
'🌙 深色模式支持', "🌙 深色模式支持",
], ],
'en-US': [ "en-US": [
'🎉 Initial Release', "🎉 Initial Release",
'✨ Brand new user interface', "✨ Brand new user interface",
'🔐 Enhanced security features', "🔐 Enhanced security features",
'⚡ Performance optimization, 30% faster response', "⚡ Performance optimization, 30% faster response",
'🌍 Multi-language support', "🌍 Multi-language support",
'🌙 Dark mode support', "🌙 Dark mode support",
], ],
}, },
downloads: { downloads: {
ios: 'https://example.com/riwa-ios-1.0.0.ipa', ios: "https://example.com/riwa-ios-1.0.0.ipa",
android: 'https://example.com/riwa-android-1.0.0.apk', android: "https://example.com/riwa-android-1.0.0.apk",
h5: 'http://localhost:5173', h5: "http://localhost:5173",
}, },
} };
// 模拟下载统计数据 // 模拟下载统计数据
export const mockDownloadStats: DownloadStats = { export const mockDownloadStats: DownloadStats = {
@@ -36,4 +36,4 @@ export const mockDownloadStats: DownloadStats = {
today: 156, today: 156,
ios: 7234, ios: 7234,
android: 5346, android: 5346,
} };

View File

@@ -14,7 +14,6 @@ services:
# nginx conf # nginx conf
- ./nginx.conf:/etc/nginx/nginx.conf - ./nginx.conf:/etc/nginx/nginx.conf
# version: '3.8' # version: '3.8'
# services: # services:

View File

@@ -1,8 +1,8 @@
import antfu from '@antfu/eslint-config' import antfu from "@antfu/eslint-config";
import withNuxt from './.nuxt/eslint.config.mjs' import withNuxt from "./.nuxt/eslint.config.mjs";
export default withNuxt( export default withNuxt(
antfu({ antfu({
formatters: true, formatters: true,
}), }),
) );

View File

@@ -1,49 +1,49 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
modules: [ modules: [
'@nuxt/ui', "@nuxt/ui",
'@nuxtjs/i18n', "@nuxtjs/i18n",
'@nuxt/eslint', "@nuxt/eslint",
'@vite-pwa/nuxt', "@vite-pwa/nuxt",
], ],
devtools: { enabled: true }, devtools: { enabled: true },
pwa: { pwa: {
registerType: 'autoUpdate', registerType: "autoUpdate",
manifest: { manifest: {
name: 'Riwa应用商店', name: "Riwa应用商店",
short_name: 'Riwa应用商店', short_name: "Riwa应用商店",
description: 'Riwa App 下载 - iOS, Android, H5', description: "Riwa App 下载 - iOS, Android, H5",
theme_color: '#3b82f6', theme_color: "#3b82f6",
background_color: '#ffffff', background_color: "#ffffff",
display: 'standalone', display: "standalone",
scope: '/', scope: "/",
start_url: '/', start_url: "/",
icons: [ icons: [
{ {
src: '/favicon.svg', src: "/favicon.svg",
sizes: '512x512', sizes: "512x512",
type: 'image/svg+xml', type: "image/svg+xml",
purpose: 'any', purpose: "any",
}, },
{ {
src: '/favicon.svg', src: "/favicon.svg",
sizes: '192x192', sizes: "192x192",
type: 'image/svg+xml', type: "image/svg+xml",
}, },
], ],
}, },
workbox: { workbox: {
navigateFallback: '/', navigateFallback: "/",
globPatterns: ['**/*.{js,css,html,png,svg,ico}'], globPatterns: ["**/*.{js,css,html,png,svg,ico}"],
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst', handler: "CacheFirst",
options: { options: {
cacheName: 'google-fonts-cache', cacheName: "google-fonts-cache",
expiration: { expiration: {
maxEntries: 10, maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
@@ -55,9 +55,9 @@ export default defineNuxtConfig({
}, },
{ {
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst', handler: "CacheFirst",
options: { options: {
cacheName: 'gstatic-fonts-cache', cacheName: "gstatic-fonts-cache",
expiration: { expiration: {
maxEntries: 10, maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
@@ -75,81 +75,81 @@ export default defineNuxtConfig({
}, },
devOptions: { devOptions: {
enabled: true, enabled: true,
type: 'module', type: "module",
}, },
injectManifest: { injectManifest: {
globPatterns: ['**/*.{js,css,html,png,svg,ico}'], globPatterns: ["**/*.{js,css,html,png,svg,ico}"],
}, },
}, },
css: [ css: [
'~/assets/css/main.css', "~/assets/css/main.css",
'~/assets/css/animations.css', "~/assets/css/animations.css",
], ],
colorMode: { colorMode: {
preference: 'light', preference: "light",
}, },
i18n: { i18n: {
defaultLocale: 'zh-CN', defaultLocale: "zh-CN",
locales: [ locales: [
{ {
code: 'zh-CN', code: "zh-CN",
name: '简体中文', name: "简体中文",
file: 'zh-CN.json', file: "zh-CN.json",
}, },
{ {
code: 'en-US', code: "en-US",
name: 'English', name: "English",
file: 'en-US.json', file: "en-US.json",
}, },
], ],
strategy: 'no_prefix', strategy: "no_prefix",
detectBrowserLanguage: { detectBrowserLanguage: {
useCookie: true, useCookie: true,
cookieKey: 'i18n_locale', cookieKey: "i18n_locale",
redirectOn: 'root', redirectOn: "root",
}, },
}, },
app: { app: {
head: { head: {
charset: 'utf-8', charset: "utf-8",
viewport: 'width=device-width, initial-scale=1', viewport: "width=device-width, initial-scale=1",
title: 'Riwa App 下载', title: "Riwa App 下载",
meta: [ meta: [
{ name: 'description', content: 'Riwa App 下载 - iOS, Android, H5' }, { name: "description", content: "Riwa App 下载 - iOS, Android, H5" },
], ],
link: [ link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }, { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
], ],
}, },
}, },
devServer:{ devServer: {
host: '0.0.0.0' host: "0.0.0.0",
}, },
nitro: { nitro: {
prerender: { prerender: {
routes: ['/'], routes: ["/"],
crawlLinks: true, crawlLinks: true,
}, },
}, },
hooks: { hooks: {
async 'nitro:config'(nitroConfig) { "nitro:config": async function (nitroConfig) {
// 预渲染所有应用详情页 // 预渲染所有应用详情页
const { apps } = await import('./data/apps') const { apps } = await import("./data/apps");
const routes = apps.map(app => `/apps/${app.id}`) const routes = apps.map(app => `/apps/${app.id}`);
nitroConfig.prerender = nitroConfig.prerender || {} nitroConfig.prerender = nitroConfig.prerender || {};
nitroConfig.prerender.routes = [ nitroConfig.prerender.routes = [
...(nitroConfig.prerender.routes || []), ...(nitroConfig.prerender.routes || []),
...routes, ...routes,
] ];
}, },
}, },
compatibilityDate: '2025-12-30', compatibilityDate: "2025-12-30",
}) });

View File

@@ -1,7 +1,7 @@
{ {
"name": "@riwa/distribute", "name": "@riwa/distribute",
"version": "1.0.0",
"type": "module", "type": "module",
"version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "nuxt dev", "dev": "nuxt dev",

View File

@@ -1,72 +1,72 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AppInfo } from '~/types' import type { AppInfo } from "~/types";
import { apps } from '~/data/apps' import { apps } from "~/data/apps";
const route = useRoute() const route = useRoute();
const router = useRouter() const router = useRouter();
const { t, locale } = useI18n() const { t, locale } = useI18n();
// 直接从数据文件获取应用详情 // 直接从数据文件获取应用详情
const app = computed(() => apps.find(a => a.id === route.params.id)) const app = computed(() => apps.find(a => a.id === route.params.id));
// 如果应用不存在,跳转回首页 // 如果应用不存在,跳转回首页
if (!app.value) { if (!app.value) {
navigateTo('/') navigateTo("/");
} }
// 下载处理 // 下载处理
async function handleDownload(type: 'ios' | 'android' | 'h5') { async function handleDownload(type: "ios" | "android" | "h5") {
if (!app.value) { if (!app.value) {
return return;
} }
const url = app.value.downloads[type] const url = app.value.downloads[type];
if (!url) { if (!url) {
return return;
} }
if (type === 'h5') { if (type === "h5") {
navigateTo(url, { external: true, open: { target: '_blank' } }) navigateTo(url, { external: true, open: { target: "_blank" } });
} }
else { else {
navigateTo(url, { external: true }) navigateTo(url, { external: true });
} }
await $fetch(`/api/track/${type}`, { await $fetch(`/api/track/${type}`, {
method: 'POST', method: "POST",
body: { appId: app.value.id }, body: { appId: app.value.id },
}) });
} }
// 返回首页 // 返回首页
function goBack() { function goBack() {
router.back() router.back();
} }
// SEO // SEO
useHead({ useHead({
title: app.value ? `${app.value.name} - Riwa App Store` : 'Riwa App Store', title: app.value ? `${app.value.name} - Riwa App Store` : "Riwa App Store",
meta: [ meta: [
{ {
name: 'description', name: "description",
content: app.value?.shortDescription[locale.value as 'zh-CN' | 'en-US'] || '', content: app.value?.shortDescription[locale.value as "zh-CN" | "en-US"] || "",
}, },
], ],
}) });
</script> </script>
<template> <template>
<div v-if="app" class="min-h-screen relative overflow-hidden bg-gradient-to-br from-gray-50 via-blue-50/30 to-purple-50/30 dark:from-gray-950 dark:via-blue-950/20 dark:to-purple-950/20"> <div v-if="app" class="min-h-screen relative overflow-hidden bg-gradient-to-br from-gray-50 via-blue-50/30 to-purple-50/30 dark:from-gray-950 dark:via-blue-950/20 dark:to-purple-950/20">
<!-- 科技感网格背景 --> <!-- 科技感网格背景 -->
<div class="fixed inset-0 opacity-30 dark:opacity-20 pointer-events-none"> <div class="fixed inset-0 opacity-30 dark:opacity-20 pointer-events-none">
<div class="absolute inset-0" style="background-image: linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px); background-size: 50px 50px;"></div> <div class="absolute inset-0" style="background-image: linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px); background-size: 50px 50px;" />
</div> </div>
<!-- 动态发光球体背景 --> <!-- 动态发光球体背景 -->
<div class="fixed inset-0 pointer-events-none overflow-hidden"> <div class="fixed inset-0 pointer-events-none overflow-hidden">
<div class="absolute top-1/4 -left-48 w-96 h-96 bg-primary-500/20 rounded-full blur-3xl animate-pulse"></div> <div class="absolute top-1/4 -left-48 w-96 h-96 bg-primary-500/20 rounded-full blur-3xl animate-pulse" />
<div class="absolute bottom-1/4 -right-48 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl animate-pulse" style="animation-delay: 1s;"></div> <div class="absolute bottom-1/4 -right-48 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl animate-pulse" style="animation-delay: 1s;" />
</div> </div>
<UContainer class="relative z-10"> <UContainer class="relative z-10">
@@ -91,10 +91,10 @@ useHead({
<!-- App Header --> <!-- App Header -->
<div class="flex items-start gap-6 mb-8 group"> <div class="flex items-start gap-6 mb-8 group">
<div class="relative"> <div class="relative">
<div class="absolute -inset-2 bg-gradient-to-r from-primary-500 via-purple-500 to-blue-500 rounded-3xl opacity-50 blur-xl group-hover:opacity-100 transition-all duration-500 animate-pulse"></div> <div class="absolute -inset-2 bg-gradient-to-r from-primary-500 via-purple-500 to-blue-500 rounded-3xl opacity-50 blur-xl group-hover:opacity-100 transition-all duration-500 animate-pulse" />
<div class="size-24 rounded-3xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-4xl shrink-0 shadow-2xl shadow-blue-500/50 relative overflow-hidden group-hover:scale-110 transition-all duration-500 p-3"> <div class="size-24 rounded-3xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-4xl shrink-0 shadow-2xl shadow-blue-500/50 relative overflow-hidden group-hover:scale-110 transition-all duration-500 p-3">
<div class="absolute inset-0 bg-gradient-to-tr from-white/0 via-white/30 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></div> <div class="absolute inset-0 bg-gradient-to-tr from-white/0 via-white/30 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700" />
<img :src="app.icon" :alt="app.name" class="size-full object-contain relative z-10 rounded-2xl" /> <img :src="app.icon" :alt="app.name" class="size-full object-contain relative z-10 rounded-2xl">
</div> </div>
</div> </div>
<div class="flex-1"> <div class="flex-1">
@@ -115,7 +115,7 @@ useHead({
<!-- Download Buttons --> <!-- Download Buttons -->
<div class="grid md:grid-cols-3 gap-4 mb-8"> <div class="grid md:grid-cols-3 gap-4 mb-8">
<div v-if="app.downloads.ios" class="relative group"> <div v-if="app.downloads.ios" class="relative group">
<div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div> <div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
<UButton <UButton
icon="i-heroicons-device-phone-mobile" icon="i-heroicons-device-phone-mobile"
size="xl" size="xl"
@@ -123,16 +123,18 @@ useHead({
class="relative transition-all duration-300 hover:shadow-2xl hover:shadow-blue-500/50 hover:-translate-y-2" class="relative transition-all duration-300 hover:shadow-2xl hover:shadow-blue-500/50 hover:-translate-y-2"
@click="handleDownload('ios')" @click="handleDownload('ios')"
> >
<div class="text-left w-full"> <div class="text-left w-full">
<div class="font-semibold text-base">iOS</div> <div class="font-semibold text-base">
<div v-if="app.size?.ios" class="text-xs opacity-80"> iOS
{{ app.size.ios }} </div>
<div v-if="app.size?.ios" class="text-xs opacity-80">
{{ app.size.ios }}
</div>
</div> </div>
</div>
</UButton> </UButton>
</div> </div>
<div v-if="app.downloads.android" class="relative group"> <div v-if="app.downloads.android" class="relative group">
<div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div> <div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
<UButton <UButton
icon="i-heroicons-device-tablet" icon="i-heroicons-device-tablet"
size="xl" size="xl"
@@ -140,16 +142,18 @@ useHead({
class="relative transition-all duration-300 hover:shadow-2xl hover:shadow-blue-500/50 hover:-translate-y-2" class="relative transition-all duration-300 hover:shadow-2xl hover:shadow-blue-500/50 hover:-translate-y-2"
@click="handleDownload('android')" @click="handleDownload('android')"
> >
<div class="text-left w-full"> <div class="text-left w-full">
<div class="font-semibold text-base">Android</div> <div class="font-semibold text-base">
<div v-if="app.size?.android" class="text-xs opacity-80"> Android
{{ app.size.android }} </div>
<div v-if="app.size?.android" class="text-xs opacity-80">
{{ app.size.android }}
</div>
</div> </div>
</div>
</UButton> </UButton>
</div> </div>
<div v-if="app.downloads.h5" class="relative group"> <div v-if="app.downloads.h5" class="relative group">
<div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div> <div class="absolute -inset-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
<UButton <UButton
icon="i-heroicons-globe-alt" icon="i-heroicons-globe-alt"
size="xl" size="xl"
@@ -158,7 +162,9 @@ useHead({
@click="handleDownload('h5')" @click="handleDownload('h5')"
> >
<div class="text-left w-full"> <div class="text-left w-full">
<div class="font-semibold text-base">Web</div> <div class="font-semibold text-base">
Web
</div>
<div class="text-xs opacity-80"> <div class="text-xs opacity-80">
PWA PWA
</div> </div>
@@ -170,12 +176,12 @@ useHead({
<!-- Stats --> <!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="relative group"> <div class="relative group">
<div class="absolute -inset-0.5 bg-gradient-to-r from-primary-500 to-purple-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div> <div class="absolute -inset-0.5 bg-gradient-to-r from-primary-500 to-purple-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-primary-500/20 transition-all duration-500 hover:-translate-y-1"> <UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-primary-500/20 transition-all duration-500 hover:-translate-y-1">
<div class="text-center"> <div class="text-center">
<div class="text-3xl font-bold bg-gradient-to-r from-primary-500 to-purple-500 bg-clip-text text-transparent"> <div class="text-3xl font-bold bg-gradient-to-r from-primary-500 to-purple-500 bg-clip-text text-transparent">
{{ app.stats.total.toLocaleString() }} {{ app.stats.total.toLocaleString() }}
</div> </div>
<div class="text-sm text-gray-600 dark:text-gray-400 mt-2"> <div class="text-sm text-gray-600 dark:text-gray-400 mt-2">
{{ locale === 'zh-CN' ? '总下载' : 'Total Downloads' }} {{ locale === 'zh-CN' ? '总下载' : 'Total Downloads' }}
</div> </div>
@@ -183,7 +189,7 @@ useHead({
</UCard> </UCard>
</div> </div>
<div class="relative group"> <div class="relative group">
<div class="absolute -inset-0.5 bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div> <div class="absolute -inset-0.5 bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-purple-500/20 transition-all duration-500 hover:-translate-y-1"> <UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-purple-500/20 transition-all duration-500 hover:-translate-y-1">
<div class="text-center"> <div class="text-center">
<div class="text-3xl font-bold bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent"> <div class="text-3xl font-bold bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent">
@@ -196,7 +202,7 @@ useHead({
</UCard> </UCard>
</div> </div>
<div class="relative group"> <div class="relative group">
<div class="absolute -inset-0.5 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div> <div class="absolute -inset-0.5 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-blue-500/20 transition-all duration-500 hover:-translate-y-1"> <UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-blue-500/20 transition-all duration-500 hover:-translate-y-1">
<div class="text-center"> <div class="text-center">
<div class="text-3xl font-bold bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent"> <div class="text-3xl font-bold bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent">
@@ -209,7 +215,7 @@ useHead({
</UCard> </UCard>
</div> </div>
<div class="relative group"> <div class="relative group">
<div class="absolute -inset-0.5 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500"></div> <div class="absolute -inset-0.5 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-xl opacity-0 group-hover:opacity-100 blur transition-all duration-500" />
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-indigo-500/20 transition-all duration-500 hover:-translate-y-1"> <UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-indigo-500/20 transition-all duration-500 hover:-translate-y-1">
<div class="text-center"> <div class="text-center">
<div class="text-3xl font-bold bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent"> <div class="text-3xl font-bold bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
@@ -225,7 +231,7 @@ useHead({
<!-- Description --> <!-- Description -->
<div class="relative group mb-8"> <div class="relative group mb-8">
<div class="absolute -inset-0.5 bg-gradient-to-r from-primary-500 via-purple-500 to-blue-500 rounded-xl opacity-0 group-hover:opacity-30 blur transition-all duration-500"></div> <div class="absolute -inset-0.5 bg-gradient-to-r from-primary-500 via-purple-500 to-blue-500 rounded-xl opacity-0 group-hover:opacity-30 blur transition-all duration-500" />
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-primary-500/10 transition-all duration-500"> <UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-primary-500/10 transition-all duration-500">
<h3 class="text-xl font-semibold bg-gradient-to-r from-gray-900 via-primary-600 to-purple-600 dark:from-white dark:via-primary-400 dark:to-purple-400 bg-clip-text text-transparent mb-4"> <h3 class="text-xl font-semibold bg-gradient-to-r from-gray-900 via-primary-600 to-purple-600 dark:from-white dark:via-primary-400 dark:to-purple-400 bg-clip-text text-transparent mb-4">
{{ locale === 'zh-CN' ? '应用介绍' : 'Description' }} {{ locale === 'zh-CN' ? '应用介绍' : 'Description' }}
@@ -238,11 +244,11 @@ useHead({
<!-- What's New --> <!-- What's New -->
<div class="relative group"> <div class="relative group">
<div class="absolute -inset-0.5 bg-gradient-to-r from-purple-500 via-pink-500 to-rose-500 rounded-xl opacity-0 group-hover:opacity-30 blur transition-all duration-500"></div> <div class="absolute -inset-0.5 bg-gradient-to-r from-purple-500 via-pink-500 to-rose-500 rounded-xl opacity-0 group-hover:opacity-30 blur transition-all duration-500" />
<UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-purple-500/10 transition-all duration-500"> <UCard class="relative backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 hover:shadow-xl hover:shadow-purple-500/10 transition-all duration-500">
<h3 class="text-xl font-semibold bg-gradient-to-r from-gray-900 via-purple-600 to-pink-600 dark:from-white dark:via-purple-400 dark:to-pink-400 bg-clip-text text-transparent mb-4"> <h3 class="text-xl font-semibold bg-gradient-to-r from-gray-900 via-purple-600 to-pink-600 dark:from-white dark:via-purple-400 dark:to-pink-400 bg-clip-text text-transparent mb-4">
{{ locale === 'zh-CN' ? '更新内容' : "What's New" }} {{ locale === 'zh-CN' ? '更新内容' : "What's New" }}
</h3> </h3>
<ul class="space-y-3"> <ul class="space-y-3">
<li <li
v-for="(note, index) in app.releaseNotes[locale as 'zh-CN' | 'en-US']" v-for="(note, index) in app.releaseNotes[locale as 'zh-CN' | 'en-US']"

View File

@@ -1,104 +1,104 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AppInfo } from '~/types' import type { AppInfo } from "~/types";
import { apps as appsData, categories as categoriesData } from '~/data/apps' import { apps as appsData, categories as categoriesData } from "~/data/apps";
const { t, locale, setLocale } = useI18n() const { t, locale, setLocale } = useI18n();
const colorMode = useColorMode() const colorMode = useColorMode();
// 直接使用数据文件 // 直接使用数据文件
const apps = computed(() => appsData) const apps = computed(() => appsData);
const categories = computed(() => categoriesData) const categories = computed(() => categoriesData);
// 当前选中的分类 // 当前选中的分类
const selectedCategory = ref('all') const selectedCategory = ref("all");
// 搜索关键词 // 搜索关键词
const searchKeyword = ref('') const searchKeyword = ref("");
// 过滤后的应用列表 // 过滤后的应用列表
const filteredApps = computed(() => { const filteredApps = computed(() => {
let result = apps.value let result = apps.value;
// 按分类过滤 // 按分类过滤
if (selectedCategory.value !== 'all') { if (selectedCategory.value !== "all") {
result = result.filter(app => app.category === selectedCategory.value) result = result.filter(app => app.category === selectedCategory.value);
} }
// 按搜索关键词过滤 // 按搜索关键词过滤
if (searchKeyword.value.trim()) { if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.toLowerCase() const keyword = searchKeyword.value.toLowerCase();
result = result.filter(app => result = result.filter(app =>
app.name.toLowerCase().includes(keyword) app.name.toLowerCase().includes(keyword)
|| app.shortDescription[locale.value as 'zh-CN' | 'en-US'].toLowerCase().includes(keyword), || app.shortDescription[locale.value as "zh-CN" | "en-US"].toLowerCase().includes(keyword),
) );
} }
return result return result;
}) });
// 切换语言 // 切换语言
function toggleLanguage() { function toggleLanguage() {
setLocale(locale.value === 'zh-CN' ? 'en-US' : 'zh-CN') setLocale(locale.value === "zh-CN" ? "en-US" : "zh-CN");
} }
// 打开应用详情 // 打开应用详情
function openAppDetail(app: AppInfo) { function openAppDetail(app: AppInfo) {
navigateTo(`/apps/${app.id}`) navigateTo(`/apps/${app.id}`);
} }
// 下载处理 // 下载处理
async function handleDownload(app: AppInfo, type: 'ios' | 'android' | 'h5', event?: MouseEvent | TouchEvent) { async function handleDownload(app: AppInfo, type: "ios" | "android" | "h5", event?: MouseEvent | TouchEvent) {
const url = app.downloads[type] const url = app.downloads[type];
if (!url) { if (!url) {
return return;
} }
if (type === 'h5') { if (type === "h5") {
navigateTo(url, { external: true, open: { target: '_blank' } }) navigateTo(url, { external: true, open: { target: "_blank" } });
} }
else { else {
navigateTo(url, { external: true }) navigateTo(url, { external: true });
} }
await $fetch(`/api/track/${type}`, { await $fetch(`/api/track/${type}`, {
method: 'POST', method: "POST",
body: { appId: app.id }, body: { appId: app.id },
}) });
} }
const isDark = computed(() => colorMode.value === 'dark') const isDark = computed(() => colorMode.value === "dark");
// SEO // SEO
useHead({ useHead({
title: locale.value === 'zh-CN' ? 'Riwa 应用商店' : 'Riwa App Store', title: locale.value === "zh-CN" ? "Riwa 应用商店" : "Riwa App Store",
meta: [ meta: [
{ {
name: 'description', name: "description",
content: locale.value === 'zh-CN' content: locale.value === "zh-CN"
? '下载 Riwa 系列应用,包括 Riwa 主应用、Riwa 钱包和 Riwa 聊天等' ? "下载 Riwa 系列应用,包括 Riwa 主应用、Riwa 钱包和 Riwa 聊天等"
: 'Download Riwa apps including Riwa main app, Riwa Wallet, and Riwa Chat', : "Download Riwa apps including Riwa main app, Riwa Wallet, and Riwa Chat",
}, },
], ],
}) });
</script> </script>
<template> <template>
<div class="min-h-screen relative overflow-hidden bg-linear-to-br from-gray-50 via-blue-50/30 to-purple-50/30 dark:from-gray-950 dark:via-blue-950/20 dark:to-purple-950/20"> <div class="min-h-screen relative overflow-hidden bg-linear-to-br from-gray-50 via-blue-50/30 to-purple-50/30 dark:from-gray-950 dark:via-blue-950/20 dark:to-purple-950/20">
<!-- 科技感网格背景 --> <!-- 科技感网格背景 -->
<div class="fixed inset-0 opacity-30 dark:opacity-20 pointer-events-none"> <div class="fixed inset-0 opacity-30 dark:opacity-20 pointer-events-none">
<div class="absolute inset-0 animate-pulse-subtle" style="background-image: linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px); background-size: 50px 50px;"></div> <div class="absolute inset-0 animate-pulse-subtle" style="background-image: linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px); background-size: 50px 50px;" />
</div> </div>
<!-- 动态发光球体背景 --> <!-- 动态发光球体背景 -->
<div class="fixed inset-0 pointer-events-none overflow-hidden"> <div class="fixed inset-0 pointer-events-none overflow-hidden">
<div class="absolute top-1/4 -left-48 w-96 h-96 bg-primary-500/20 rounded-full blur-3xl animate-breathe"></div> <div class="absolute top-1/4 -left-48 w-96 h-96 bg-primary-500/20 rounded-full blur-3xl animate-breathe" />
<div class="absolute bottom-1/4 -right-48 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl animate-breathe" style="animation-delay: 1s;"></div> <div class="absolute bottom-1/4 -right-48 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl animate-breathe" style="animation-delay: 1s;" />
<div class="absolute top-1/2 left-1/2 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-breathe" style="animation-delay: 2s;"></div> <div class="absolute top-1/2 left-1/2 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-breathe" style="animation-delay: 2s;" />
<!-- 额外的动态光球 --> <!-- 额外的动态光球 -->
<div class="absolute top-1/3 right-1/4 w-64 h-64 bg-cyan-500/15 rounded-full blur-2xl animate-float" style="animation-delay: 0.5s;"></div> <div class="absolute top-1/3 right-1/4 w-64 h-64 bg-cyan-500/15 rounded-full blur-2xl animate-float" style="animation-delay: 0.5s;" />
<div class="absolute bottom-1/3 left-1/3 w-72 h-72 bg-indigo-500/15 rounded-full blur-2xl animate-float" style="animation-delay: 1.5s;"></div> <div class="absolute bottom-1/3 left-1/3 w-72 h-72 bg-indigo-500/15 rounded-full blur-2xl animate-float" style="animation-delay: 1.5s;" />
</div> </div>
<!-- Header --> <!-- Header -->
@@ -107,8 +107,8 @@ useHead({
<div class="flex items-center justify-between py-4"> <div class="flex items-center justify-between py-4">
<div class="flex items-center gap-3 group"> <div class="flex items-center gap-3 group">
<div class="size-10 rounded-xl bg-linear-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-xl relative overflow-hidden shadow-lg shadow-blue-500/50"> <div class="size-10 rounded-xl bg-linear-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-xl relative overflow-hidden shadow-lg shadow-blue-500/50">
<div class="absolute inset-0 bg-linear-to-tr from-white/0 via-white/20 to-white/0 -translate-x-full group-hover:translate-x-full transition-transform duration-700"></div> <div class="absolute inset-0 bg-linear-to-tr from-white/0 via-white/20 to-white/0 -translate-x-full group-hover:translate-x-full transition-transform duration-700" />
<div class="absolute -inset-1 bg-blue-400/30 rounded-xl blur-md opacity-0 group-hover:opacity-100 animate-pulse-ring"></div> <div class="absolute -inset-1 bg-blue-400/30 rounded-xl blur-md opacity-0 group-hover:opacity-100 animate-pulse-ring" />
<span class="relative z-10">R</span> <span class="relative z-10">R</span>
</div> </div>
<h1 class="text-xl font-bold bg-linear-to-r from-gray-900 via-primary-600 to-purple-600 dark:from-white dark:via-primary-400 dark:to-purple-400 bg-clip-text text-transparent animate-gradient"> <h1 class="text-xl font-bold bg-linear-to-r from-gray-900 via-primary-600 to-purple-600 dark:from-white dark:via-primary-400 dark:to-purple-400 bg-clip-text text-transparent animate-gradient">
@@ -180,30 +180,30 @@ useHead({
class="cursor-pointer backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 duration-500 hover:shadow-2xl hover:shadow-primary-500/20 hover:-translate-y-2 hover:rotate-y-2 active:scale-95 active:shadow-lg active:shadow-primary-500/40 relative overflow-hidden" class="cursor-pointer backdrop-blur-sm bg-white/90 dark:bg-gray-900/90 border border-gray-200/50 dark:border-gray-800/50 duration-500 hover:shadow-2xl hover:shadow-primary-500/20 hover:-translate-y-2 hover:rotate-y-2 active:scale-95 active:shadow-lg active:shadow-primary-500/40 relative overflow-hidden"
@click="openAppDetail(app)" @click="openAppDetail(app)"
> >
<!-- 内部发光效果 --> <!-- 内部发光效果 -->
<div class="absolute inset-0 bg-linear-to-br from-primary-500/5 to-purple-500/5"></div> <div class="absolute inset-0 bg-linear-to-br from-primary-500/5 to-purple-500/5" />
<div class="flex items-start gap-4 relative z-10"> <div class="flex items-start gap-4 relative z-10">
<!-- App Icon --> <!-- App Icon -->
<div class="size-16 rounded-2xl bg-linear-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-2xl shrink-0 shadow-lg shadow-blue-500/50 relative overflow-hidden group-hover:shadow-2xl group-hover:shadow-blue-500/60 transition-all duration-500 group-hover:scale-110 group-hover:rotate-3 group-active:scale-105 group-active:rotate-1 p-2"> <div class="size-16 rounded-2xl bg-linear-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-2xl shrink-0 shadow-lg shadow-blue-500/50 relative overflow-hidden group-hover:shadow-2xl group-hover:shadow-blue-500/60 transition-all duration-500 group-hover:scale-110 group-hover:rotate-3 group-active:scale-105 group-active:rotate-1 p-2">
<!-- 动态发光效果 --> <!-- 动态发光效果 -->
<div class="absolute inset-0 bg-linear-to-tr from-white/0 via-white/30 to-white/0 -translate-x-full group-hover:translate-x-full group-active:translate-x-[50%] transition-transform duration-700"></div> <div class="absolute inset-0 bg-linear-to-tr from-white/0 via-white/30 to-white/0 -translate-x-full group-hover:translate-x-full group-active:translate-x-[50%] transition-transform duration-700" />
<img :src="app.icon" :alt="app.name" class="size-full object-contain relative z-10 rounded-lg transition-all duration-300 group-active:scale-95 group-hover:-rotate-3" /> <img :src="app.icon" :alt="app.name" class="size-full object-contain relative z-10 rounded-lg transition-all duration-300 group-active:scale-95 group-hover:-rotate-3">
</div> </div>
<!-- App Info --> <!-- App Info -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="font-bold text-lg text-gray-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300"> <h3 class="font-bold text-lg text-gray-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300">
{{ app.name }} {{ app.name }}
</h3> </h3>
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1 transition-colors duration-300 group-hover:text-gray-700 dark:group-hover:text-gray-300"> <p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1 transition-colors duration-300 group-hover:text-gray-700 dark:group-hover:text-gray-300">
{{ app.shortDescription[locale as 'zh-CN' | 'en-US'] }} {{ app.shortDescription[locale as 'zh-CN' | 'en-US'] }}
</p> </p>
<div class="flex items-center gap-3 mt-3 text-xs text-gray-500 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300"> <div class="flex items-center gap-3 mt-3 text-xs text-gray-500 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300">
<span class="animate-pulse-subtle">v{{ app.version }}</span> <span class="animate-pulse-subtle">v{{ app.version }}</span>
<span></span> <span></span>
<span class="font-medium">{{ app.stats.total.toLocaleString() }} {{ locale === 'zh-CN' ? '次下载' : 'downloads' }}</span> <span class="font-medium">{{ app.stats.total.toLocaleString() }} {{ locale === 'zh-CN' ? '次下载' : 'downloads' }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -217,8 +217,7 @@ useHead({
block block
class="transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50 hover:-translate-y-1 active:scale-95 active:shadow-xl active:shadow-blue-500/60 relative overflow-hidden group/btn" class="transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50 hover:-translate-y-1 active:scale-95 active:shadow-xl active:shadow-blue-500/60 relative overflow-hidden group/btn"
@click.stop="(e) => handleDownload(app, 'ios', e)" @click.stop="(e) => handleDownload(app, 'ios', e)"
> />
</UButton>
<UButton <UButton
v-if="app.downloads.android" v-if="app.downloads.android"
icon="i-heroicons-device-tablet" icon="i-heroicons-device-tablet"
@@ -227,8 +226,7 @@ useHead({
block block
class="transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50 hover:-translate-y-1 active:scale-95 active:shadow-xl active:shadow-blue-500/60 relative overflow-hidden group/btn" class="transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50 hover:-translate-y-1 active:scale-95 active:shadow-xl active:shadow-blue-500/60 relative overflow-hidden group/btn"
@click.stop="(e) => handleDownload(app, 'android', e)" @click.stop="(e) => handleDownload(app, 'android', e)"
> />
</UButton>
<UButton <UButton
v-if="app.downloads.h5" v-if="app.downloads.h5"
icon="i-heroicons-globe-alt" icon="i-heroicons-globe-alt"
@@ -237,8 +235,7 @@ useHead({
block block
class="transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50 hover:-translate-y-1 active:scale-95 active:shadow-xl active:shadow-blue-500/60 relative overflow-hidden group/btn" class="transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50 hover:-translate-y-1 active:scale-95 active:shadow-xl active:shadow-blue-500/60 relative overflow-hidden group/btn"
@click.stop="(e) => handleDownload(app, 'h5', e)" @click.stop="(e) => handleDownload(app, 'h5', e)"
> />
</UButton>
</div> </div>
</UCard> </UCard>
</div> </div>
@@ -262,4 +259,4 @@ useHead({
</UContainer> </UContainer>
</footer> </footer>
</div> </div>
</template> </template>

View File

@@ -1,8 +1,8 @@
import { getAllApps, categories } from '~/data/apps' import { categories, getAllApps } from "~/data/apps";
export default defineEventHandler(() => { export default defineEventHandler(() => {
return { return {
apps: getAllApps(), apps: getAllApps(),
categories, categories,
} };
}) });

View File

@@ -1,23 +1,23 @@
import { getAppById } from '~/data/apps' import { getAppById } from "~/data/apps";
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const id = getRouterParam(event, 'id') const id = getRouterParam(event, "id");
if (!id) { if (!id) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
message: 'App ID is required', message: "App ID is required",
}) });
} }
const app = getAppById(id) const app = getAppById(id);
if (!app) { if (!app) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
message: 'App not found', message: "App not found",
}) });
} }
return app return app;
}) });

View File

@@ -1,3 +1,3 @@
export default defineEventHandler(async () => { export default defineEventHandler(async () => {
return await fetchDownloadStats() return await fetchDownloadStats();
}) });

View File

@@ -1,16 +1,16 @@
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const platform = getRouterParam(event, 'platform') as 'ios' | 'android' | 'h5' const platform = getRouterParam(event, "platform") as "ios" | "android" | "h5";
const body = await readBody(event) const body = await readBody(event);
const appId = body?.appId const appId = body?.appId;
if (!['ios', 'android', 'h5'].includes(platform)) { if (!["ios", "android", "h5"].includes(platform)) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
message: 'Invalid platform', message: "Invalid platform",
}) });
} }
await trackDownload(platform) await trackDownload(platform);
return { success: true, appId, platform } return { success: true, appId, platform };
}) });

View File

@@ -1,5 +1,5 @@
import { currentVersion } from '~/data/versions' import { currentVersion } from "~/data/versions";
export default defineEventHandler(() => { export default defineEventHandler(() => {
return currentVersion return currentVersion;
}) });

View File

@@ -1,16 +1,16 @@
import type { DownloadStats } from '~/types' import type { DownloadStats } from "~/types";
import { mockDownloadStats } from '~/data/versions' import { mockDownloadStats } from "~/data/versions";
// 获取下载统计(可替换为真实 API // 获取下载统计(可替换为真实 API
export async function fetchDownloadStats(): Promise<DownloadStats> { export async function fetchDownloadStats(): Promise<DownloadStats> {
// 模拟 API 延迟 // 模拟 API 延迟
await new Promise(resolve => setTimeout(resolve, 500)) await new Promise(resolve => setTimeout(resolve, 500));
return mockDownloadStats return mockDownloadStats;
} }
// 记录下载事件(可替换为真实 API // 记录下载事件(可替换为真实 API
export async function trackDownload(platform: 'ios' | 'android' | 'h5'): Promise<void> { export async function trackDownload(platform: "ios" | "android" | "h5"): Promise<void> {
// 模拟 API 延迟 // 模拟 API 延迟
await new Promise(resolve => setTimeout(resolve, 200)) await new Promise(resolve => setTimeout(resolve, 200));
console.log(`Download tracked: ${platform}`) console.log(`Download tracked: ${platform}`);
} }

View File

@@ -1,65 +1,65 @@
export interface AppInfo { export interface AppInfo {
id: string id: string;
name: string name: string;
icon: string icon: string;
shortDescription: { shortDescription: {
'zh-CN': string "zh-CN": string;
'en-US': string "en-US": string;
} };
description: { description: {
'zh-CN': string "zh-CN": string;
'en-US': string "en-US": string;
} };
category: string category: string;
version: string version: string;
buildNumber: string buildNumber: string;
releaseDate: string releaseDate: string;
releaseNotes: { releaseNotes: {
'zh-CN': string[] "zh-CN": string[];
'en-US': string[] "en-US": string[];
} };
downloads: { downloads: {
ios?: string ios?: string;
android?: string android?: string;
h5?: string h5?: string;
} };
screenshots?: string[] screenshots?: string[];
size?: { size?: {
ios?: string ios?: string;
android?: string android?: string;
} };
stats: DownloadStats stats: DownloadStats;
} }
export interface AppVersion { export interface AppVersion {
version: string version: string;
buildNumber: string buildNumber: string;
releaseDate: string releaseDate: string;
releaseNotes: { releaseNotes: {
'zh-CN': string[] "zh-CN": string[];
'en-US': string[] "en-US": string[];
} };
downloads: { downloads: {
ios: string ios: string;
android: string android: string;
h5: string h5: string;
} };
} }
export interface DownloadStats { export interface DownloadStats {
total: number total: number;
today: number today: number;
ios: number ios: number;
android: number android: number;
} }
export type Platform = 'ios' | 'android' | 'desktop' | 'unknown' export type Platform = "ios" | "android" | "desktop" | "unknown";
export interface AppCategory { export interface AppCategory {
id: string id: string;
name: { name: {
'zh-CN': string "zh-CN": string;
'en-US': string "en-US": string;
} };
icon?: string icon?: string;
} }

48
pnpm-lock.yaml generated
View File

@@ -62,9 +62,9 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.18 specifier: ^4.1.18
version: 4.1.18(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) version: 4.1.18(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))
'@vee-validate/yup': '@vee-validate/zod':
specifier: ^4.15.1 specifier: ^4.15.1
version: 4.15.1(vue@3.5.25(typescript@5.9.3))(yup@1.7.1) version: 4.15.1(vue@3.5.25(typescript@5.9.3))(zod@3.25.76)
'@vueuse/core': '@vueuse/core':
specifier: ^14.1.0 specifier: ^14.1.0
version: 14.1.0(vue@3.5.25(typescript@5.9.3)) version: 14.1.0(vue@3.5.25(typescript@5.9.3))
@@ -119,9 +119,9 @@ importers:
vue-router: vue-router:
specifier: ^4.6.3 specifier: ^4.6.3
version: 4.6.3(vue@3.5.25(typescript@5.9.3)) version: 4.6.3(vue@3.5.25(typescript@5.9.3))
yup: zod:
specifier: ^1.7.1 specifier: ^3.24.1
version: 1.7.1 version: 3.25.76
devDependencies: devDependencies:
'@antfu/eslint-config': '@antfu/eslint-config':
specifier: ^6.6.1 specifier: ^6.6.1
@@ -3692,10 +3692,10 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@vee-validate/yup@4.15.1': '@vee-validate/zod@4.15.1':
resolution: {integrity: sha512-+u6lI1IZftjHphj+mTCPJRruwBBwv1IKKCI1EFm6ipQroAPibkS5M8UNX+yeVYG5++ix6m1rsv4/SJvJJQTWJg==} resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
peerDependencies: peerDependencies:
yup: ^1.3.2 zod: ^3.24.0
'@vercel/nft@0.30.4': '@vercel/nft@0.30.4':
resolution: {integrity: sha512-wE6eAGSXScra60N2l6jWvNtVK0m+sh873CpfZW4KI2v8EHuUQp+mSEi4T+IcdPCSEDgCdAS/7bizbhQlkjzrSA==} resolution: {integrity: sha512-wE6eAGSXScra60N2l6jWvNtVK0m+sh873CpfZW4KI2v8EHuUQp+mSEi4T+IcdPCSEDgCdAS/7bizbhQlkjzrSA==}
@@ -9264,6 +9264,9 @@ packages:
zod@3.22.3: zod@3.22.3:
resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.1.13: zod@4.1.13:
resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==}
@@ -10036,7 +10039,7 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)': '@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)':
dependencies: dependencies:
'@better-auth/utils': 0.3.0 '@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.18 '@better-fetch/fetch': 1.1.18
@@ -10047,9 +10050,9 @@ snapshots:
nanostores: 1.1.0 nanostores: 1.1.0
zod: 4.1.13 zod: 4.1.13
'@better-auth/telemetry@1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))': '@better-auth/telemetry@1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))':
dependencies: dependencies:
'@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0) '@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
'@better-auth/utils': 0.3.0 '@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.18 '@better-fetch/fetch': 1.1.18
@@ -12957,11 +12960,11 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1': '@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true optional: true
'@vee-validate/yup@4.15.1(vue@3.5.25(typescript@5.9.3))(yup@1.7.1)': '@vee-validate/zod@4.15.1(vue@3.5.25(typescript@5.9.3))(zod@3.25.76)':
dependencies: dependencies:
type-fest: 4.41.0 type-fest: 4.41.0
vee-validate: 4.15.1(vue@3.5.25(typescript@5.9.3)) vee-validate: 4.15.1(vue@3.5.25(typescript@5.9.3))
yup: 1.7.1 zod: 3.25.76
transitivePeerDependencies: transitivePeerDependencies:
- vue - vue
@@ -13584,8 +13587,8 @@ snapshots:
better-auth@1.4.6(vue@3.5.25(typescript@5.9.3)): better-auth@1.4.6(vue@3.5.25(typescript@5.9.3)):
dependencies: dependencies:
'@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0) '@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)) '@better-auth/telemetry': 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
'@better-auth/utils': 0.3.0 '@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.18 '@better-fetch/fetch': 1.1.18
'@noble/ciphers': 2.1.1 '@noble/ciphers': 2.1.1
@@ -17476,7 +17479,8 @@ snapshots:
kleur: 3.0.3 kleur: 3.0.3
sisteransi: 1.0.5 sisteransi: 1.0.5
property-expr@2.0.6: {} property-expr@2.0.6:
optional: true
prosemirror-changeset@2.3.1: prosemirror-changeset@2.3.1:
dependencies: dependencies:
@@ -18408,7 +18412,8 @@ snapshots:
through@2.3.8: {} through@2.3.8: {}
tiny-case@1.0.3: {} tiny-case@1.0.3:
optional: true
tiny-inflate@1.0.3: {} tiny-inflate@1.0.3: {}
@@ -18464,7 +18469,8 @@ snapshots:
dependencies: dependencies:
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
toposort@2.0.2: {} toposort@2.0.2:
optional: true
tosource@2.0.0-alpha.3: {} tosource@2.0.0-alpha.3: {}
@@ -18523,7 +18529,8 @@ snapshots:
type-fest@0.8.1: {} type-fest@0.8.1: {}
type-fest@2.19.0: {} type-fest@2.19.0:
optional: true
type-fest@4.41.0: {} type-fest@4.41.0: {}
@@ -19493,6 +19500,7 @@ snapshots:
tiny-case: 1.0.3 tiny-case: 1.0.3
toposort: 2.0.2 toposort: 2.0.2
type-fest: 2.19.0 type-fest: 2.19.0
optional: true
zip-stream@6.0.1: zip-stream@6.0.1:
dependencies: dependencies:
@@ -19502,6 +19510,8 @@ snapshots:
zod@3.22.3: {} zod@3.22.3: {}
zod@3.25.76: {}
zod@4.1.13: {} zod@4.1.13: {}
zwitch@2.0.4: {} zwitch@2.0.4: {}

View File

@@ -1,2 +1,6 @@
catalogMode: prefer
shellEmulator: true
trustPolicy: no-downgrade
packages: packages:
- "packages/*" - "packages/*"

View File

@@ -1,8 +1,8 @@
import type { PhoneCountry } from "./type"; import type { PhoneCountry } from "./type";
import { toTypedSchema } from "@vee-validate/yup"; import { toTypedSchema } from "@vee-validate/zod";
import { emailOTPClient, phoneNumberClient, usernameClient } from "better-auth/client/plugins"; import { emailOTPClient, phoneNumberClient, usernameClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/vue"; import { createAuthClient } from "better-auth/vue";
import * as yup from "yup"; import { z } from "zod";
import CircleFlagsCnHk from "~icons/circle-flags/cn-hk"; import CircleFlagsCnHk from "~icons/circle-flags/cn-hk";
import CircleFlagsEnUs from "~icons/circle-flags/en-us"; import CircleFlagsEnUs from "~icons/circle-flags/en-us";
import CircleFlagsTw from "~icons/circle-flags/tw"; import CircleFlagsTw from "~icons/circle-flags/tw";
@@ -23,15 +23,15 @@ export const authClient = createAuthClient({
plugins: [emailOTPClient(), phoneNumberClient(), usernameClient()], plugins: [emailOTPClient(), phoneNumberClient(), usernameClient()],
}); });
export const emailSchema = toTypedSchema(yup.object({ export const emailSchema = toTypedSchema(z.object({
email: yup email: z
.string() .string({ message: i18n.global.t("auth.login.validation.emailRequired") })
.required(i18n.global.t("auth.login.validation.emailRequired")) .min(1, i18n.global.t("auth.login.validation.emailRequired"))
.email(i18n.global.t("auth.login.validation.emailInvalid")), .email(i18n.global.t("auth.login.validation.emailInvalid")),
otp: yup otp: z
.string() .string({ message: i18n.global.t("auth.login.validation.otpRequired") })
.required(i18n.global.t("auth.login.validation.otpRequired")) .min(1, i18n.global.t("auth.login.validation.otpRequired"))
.matches(/^\d{6}$/, i18n.global.t("auth.login.validation.otpInvalid")), .regex(/^\d{6}$/, i18n.global.t("auth.login.validation.otpInvalid")),
})); }));
export const countries: PhoneCountry[] = [ export const countries: PhoneCountry[] = [

View File

@@ -1,5 +1,5 @@
.unselectable { .unselectable {
user-select: none; user-select: none;
--moz-user-select: none; --moz-user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }

View File

@@ -1,3 +1,3 @@
@import "tailwindcss"; @import "tailwindcss";
@config "../../tailwind.config.ts"; @config "../../tailwind.config.ts";
@import "./common.css"; @import "./common.css";

View File

@@ -3,7 +3,7 @@ import type { GenericObject } from "vee-validate";
import type { EmailVerifyClient } from "@/api/types"; import type { EmailVerifyClient } from "@/api/types";
import { toastController } from "@ionic/vue"; import { toastController } from "@ionic/vue";
import { Field, Form } from "vee-validate"; import { Field, Form } from "vee-validate";
import * as yup from "yup"; import { z } from "zod";
import { authClient, emailSchema } from "@/auth"; import { authClient, emailSchema } from "@/auth";
const emit = defineEmits<{ const emit = defineEmits<{
@@ -19,7 +19,7 @@ const canResend = computed(() => countdown.value === 0 && !isSending.value);
const email = ref(""); const email = ref("");
const emailError = ref(""); const emailError = ref("");
let timer: number | null = null; let timer: NodeJS.Timeout | null = null;
function startCountdown() { function startCountdown() {
countdown.value = 60; countdown.value = 60;
@@ -42,7 +42,7 @@ async function sendOtp() {
} }
try { try {
await yup.string().email().validate(emailValue); await z.string().email().parseAsync(emailValue);
} }
catch { catch {
emailError.value = t("auth.login.validation.emailInvalid"); emailError.value = t("auth.login.validation.emailInvalid");

View File

@@ -3,10 +3,10 @@ import type { GenericObject } from "vee-validate";
import type { PhoneNumberVerifyClient } from "@/api/types"; import type { PhoneNumberVerifyClient } from "@/api/types";
import type { PhoneCountry } from "@/auth/type"; import type { PhoneCountry } from "@/auth/type";
import { toastController } from "@ionic/vue"; import { toastController } from "@ionic/vue";
import { toTypedSchema } from "@vee-validate/yup"; import { toTypedSchema } from "@vee-validate/zod";
import { chevronDown } from "ionicons/icons"; import { chevronDown } from "ionicons/icons";
import { Field, Form } from "vee-validate"; import { Field, Form } from "vee-validate";
import * as yup from "yup"; import { z } from "zod";
import { authClient, countries } from "@/auth"; import { authClient, countries } from "@/auth";
import Country from "./country.vue"; import Country from "./country.vue";
@@ -42,19 +42,18 @@ function validatePhoneNumber(phone: string): boolean {
return currentCountry.value.pattern.test(phone); return currentCountry.value.pattern.test(phone);
} }
const schema = computed(() => toTypedSchema(yup.object({ const schema = computed(() => toTypedSchema(z.object({
phoneNumber: yup phoneNumber: z
.string() .string({ message: t("auth.login.validation.phoneNumberRequired") })
.required(t("auth.login.validation.phoneNumberRequired")) .min(1, t("auth.login.validation.phoneNumberRequired"))
.test( .refine(
"phone-format", value => validatePhoneNumber(value),
t("auth.login.validation.phoneNumberInvalid"), t("auth.login.validation.phoneNumberInvalid"),
value => !value || validatePhoneNumber(value),
), ),
code: yup code: z
.string() .string({ message: t("auth.login.validation.codeRequired") })
.required(t("auth.login.validation.codeRequired")) .min(1, t("auth.login.validation.codeRequired"))
.matches(/^\d{6}$/, t("auth.login.validation.codeInvalid")), .regex(/^\d{6}$/, t("auth.login.validation.codeInvalid")),
}))); })));
function startCountdown() { function startCountdown() {

View File

@@ -1,10 +1,10 @@
<script lang='ts' setup> <script lang='ts' setup>
import type { GenericObject } from "vee-validate"; import type { GenericObject } from "vee-validate";
import type { RwaIssuanceCategoriesData, RwaIssuanceProductBody } from "@/api/types"; import type { RwaIssuanceCategoriesData, RwaIssuanceProductBody } from "@/api/types";
import { toTypedSchema } from "@vee-validate/yup"; import { toTypedSchema } from "@vee-validate/zod";
import { ErrorMessage, Field, Form } from "vee-validate"; import { ErrorMessage, Field, Form } from "vee-validate";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import * as yup from "yup"; import { z } from "zod";
const props = defineProps<{ const props = defineProps<{
initialData: RwaIssuanceProductBody["product"]; initialData: RwaIssuanceProductBody["product"];
@@ -16,10 +16,10 @@ const emit = defineEmits<{
const { t } = useI18n(); const { t } = useI18n();
const schema = toTypedSchema( const schema = toTypedSchema(
yup.object({ z.object({
name: yup.string().required(t("asset.issue.apply.validation.nameRequired")), name: z.string({ message: t("asset.issue.apply.validation.nameRequired") }).min(1, t("asset.issue.apply.validation.nameRequired")),
code: yup.string().required(t("asset.issue.apply.validation.codeRequired")), code: z.string({ message: t("asset.issue.apply.validation.codeRequired") }).min(1, t("asset.issue.apply.validation.codeRequired")),
categoryId: yup.string().required(t("asset.issue.apply.validation.categoryRequired")), categoryId: z.string({ message: t("asset.issue.apply.validation.categoryRequired") }).min(1, t("asset.issue.apply.validation.categoryRequired")),
}), }),
); );

View File

@@ -1,11 +1,11 @@
<script lang='ts' setup> <script lang='ts' setup>
import type { GenericObject } from "vee-validate"; import type { GenericObject } from "vee-validate";
import type { RwaIssuanceProductBody } from "@/api/types"; import type { RwaIssuanceProductBody } from "@/api/types";
import { toTypedSchema } from "@vee-validate/yup"; import { toTypedSchema } from "@vee-validate/zod";
import { addOutline, removeOutline } from "ionicons/icons"; import { addOutline, removeOutline } from "ionicons/icons";
import { ErrorMessage, Field, FieldArray, Form } from "vee-validate"; import { ErrorMessage, Field, FieldArray, Form } from "vee-validate";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import * as yup from "yup"; import { z } from "zod";
const props = defineProps<{ const props = defineProps<{
initialData: RwaIssuanceProductBody["editions"]; initialData: RwaIssuanceProductBody["editions"];
@@ -35,45 +35,40 @@ const launchDate = ref(new Date().toISOString());
const subscriptionStartDate = ref(new Date().toISOString()); const subscriptionStartDate = ref(new Date().toISOString());
const subscriptionEndDate = ref(new Date().toISOString()); const subscriptionEndDate = ref(new Date().toISOString());
const schema = toTypedSchema(yup.object({ const schema = toTypedSchema(z.object({
editions: yup.array().of( editions: z.array(
yup.object({ z.object({
editionName: yup.string().required(t("asset.issue.apply.validation.editionNameRequired")), editionName: z.string({ message: t("asset.issue.apply.validation.editionNameRequired") }).min(1, t("asset.issue.apply.validation.editionNameRequired")),
launchDate: yup.string() launchDate: z.string({ message: t("asset.issue.apply.validation.launchDateRequired") })
.required(t("asset.issue.apply.validation.launchDateRequired")) .min(1, t("asset.issue.apply.validation.launchDateRequired"))
.test("not-past", t("asset.issue.apply.validation.launchDateNotPast"), (value) => { .refine(
if (!value) value => new Date(value) >= new Date(now.value.toDateString()),
return true; t("asset.issue.apply.validation.launchDateNotPast"),
return new Date(value) >= new Date(now.value.toDateString()); )
}) .refine(
.test("before-subscription", t("asset.issue.apply.validation.launchBeforeSubscription"), (value) => { value => !subscriptionStartDate.value || new Date(value) < new Date(subscriptionStartDate.value),
if (!value || !subscriptionStartDate.value) t("asset.issue.apply.validation.launchBeforeSubscription"),
return true; ),
return new Date(value) < new Date(subscriptionStartDate.value); subscriptionStartDate: z.string({ message: t("asset.issue.apply.validation.subscriptionStartDateRequired") })
}), .min(1, t("asset.issue.apply.validation.subscriptionStartDateRequired"))
subscriptionStartDate: yup.string() .refine(
.required(t("asset.issue.apply.validation.subscriptionStartDateRequired")) value => new Date(value) >= new Date(now.value.toDateString()),
.test("not-past", t("asset.issue.apply.validation.subscriptionStartDateNotPast"), (value) => { t("asset.issue.apply.validation.subscriptionStartDateNotPast"),
if (!value) )
return true; .refine(
return new Date(value) >= new Date(now.value.toDateString()); value => !launchDate.value || new Date(value) > new Date(launchDate.value),
}) t("asset.issue.apply.validation.subscriptionAfterLaunch"),
.test("after-launch", t("asset.issue.apply.validation.subscriptionAfterLaunch"), (value) => { ),
if (!value || !launchDate.value) subscriptionEndDate: z.string({ message: t("asset.issue.apply.validation.subscriptionEndDateRequired") })
return true; .min(1, t("asset.issue.apply.validation.subscriptionEndDateRequired"))
return new Date(value) > new Date(launchDate.value); .refine(
}), value => !subscriptionStartDate.value || new Date(value) > new Date(subscriptionStartDate.value),
subscriptionEndDate: yup.string() t("asset.issue.apply.validation.subscriptionEndAfterStart"),
.required(t("asset.issue.apply.validation.subscriptionEndDateRequired")) ),
.test("after-start", t("asset.issue.apply.validation.subscriptionEndAfterStart"), (value) => { perUserLimit: z.string({ message: t("asset.issue.apply.validation.perUserLimitRequired") }).min(1, t("asset.issue.apply.validation.perUserLimitRequired")),
if (!value || !subscriptionStartDate.value) totalSupply: z.string({ message: t("asset.issue.apply.validation.totalSupplyRequired") }).min(1, t("asset.issue.apply.validation.totalSupplyRequired")),
return true; unitPrice: z.string({ message: t("asset.issue.apply.validation.unitPriceRequired") }).min(1, t("asset.issue.apply.validation.unitPriceRequired")),
return new Date(value) > new Date(subscriptionStartDate.value); dividendRate: z.string({ message: t("asset.issue.apply.validation.dividendRateRequired") }).min(1, t("asset.issue.apply.validation.dividendRateRequired")),
}),
perUserLimit: yup.string().required(t("asset.issue.apply.validation.perUserLimitRequired")),
totalSupply: yup.string().required(t("asset.issue.apply.validation.totalSupplyRequired")),
unitPrice: yup.string().required(t("asset.issue.apply.validation.unitPriceRequired")),
dividendRate: yup.string().required(t("asset.issue.apply.validation.dividendRateRequired")),
}), }),
), ),
})); }));

View File

@@ -1,10 +1,10 @@
<script lang='ts' setup> <script lang='ts' setup>
import type { GenericObject } from "vee-validate"; import type { GenericObject } from "vee-validate";
import { SelectChangeEventDetail, toastController } from "@ionic/vue"; import { SelectChangeEventDetail, toastController } from "@ionic/vue";
import { toTypedSchema } from "@vee-validate/yup"; import { toTypedSchema } from "@vee-validate/zod";
import { informationCircle, shieldCheckmark } from "ionicons/icons"; import { informationCircle, shieldCheckmark } from "ionicons/icons";
import { ErrorMessage, Field, Form } from "vee-validate"; import { ErrorMessage, Field, Form } from "vee-validate";
import * as yup from "yup"; import { z } from "zod";
import { client, safeClient } from "@/api"; import { client, safeClient } from "@/api";
const { t } = useI18n(); const { t } = useI18n();
@@ -17,12 +17,12 @@ const formInst = useTemplateRef<FormInstance>("formInst");
// 表单验证 Schema // 表单验证 Schema
const schema = toTypedSchema( const schema = toTypedSchema(
yup.object({ z.object({
bankName: yup.string().required(t("bankCard.form.validation.bankRequired")), bankName: z.string({ message: t("bankCard.form.validation.bankRequired") }).min(1, t("bankCard.form.validation.bankRequired")),
accountNumber: yup accountNumber: z
.string() .string({ message: t("bankCard.form.validation.accountNumberRequired") })
.required(t("bankCard.form.validation.accountNumberRequired")), .min(1, t("bankCard.form.validation.accountNumberRequired")),
accountName: yup.string().required(t("bankCard.form.validation.accountNameRequired")), accountName: z.string({ message: t("bankCard.form.validation.accountNameRequired") }).min(1, t("bankCard.form.validation.accountNameRequired")),
}), }),
); );

View File

@@ -1,9 +1,130 @@
<script lang='ts' setup> <script lang='ts' setup>
import type { SpotOrderBody } from "@/api/types";
import { modalController, toastController } from "@ionic/vue";
import { closeOutline } from "ionicons/icons";
import { client, safeClient } from "@/api";
import { tradeWayConfig } from "../config";
const props = defineProps<{
form: SpotOrderBody & { amount: string };
}>();
const currentTradeWay = computed(() => {
return tradeWayConfig.find(item => item.value === props.form.orderType);
});
function onClose() {
modalController.dismiss();
}
async function onConfirm() {
await safeClient(client.api.spot_order.create.post({
orderType: props.form.orderType,
quantity: props.form.quantity,
side: props.form.side,
symbol: props.form.symbol,
memo: props.form.memo,
price: props.form.price,
}));
const toast = await toastController.create({
message: "订单提交成功",
duration: 2000,
position: "top",
color: "success",
});
await toast.present();
modalController.dismiss();
}
</script> </script>
<template> <template>
Hello world <div class="ion-padding h-80">
<div class="flex justify-between items-center mb-5">
<div class="font-semibold">
下单确认
</div>
<ion-icon :icon="closeOutline" class="text-2xl" @click="onClose" />
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<div class="text-sm">
{{ form.symbol }}
</div>
<ui-tag size="mini" :type="form.side === 'buy' ? 'success' : 'danger'">
{{ form.side === 'buy' ? '买入' : '卖出' }}
</ui-tag>
</div>
<template v-if="form.orderType === 'limit'">
<div class="cell">
<div class="label">
委托价格
</div>
<div class="value">
{{ form.price }} USDT
</div>
</div>
<div class="cell">
<div class="label">
数量
</div>
<div class="value">
{{ form.quantity }}
</div>
</div>
<div class="cell">
<div class="label">
金额
</div>
<div class="value">
{{ form.amount }} USDT
</div>
</div>
<div class="cell">
<div class="label">
类型
</div>
<div class="value">
{{ currentTradeWay?.name }}
</div>
</div>
</template>
<template v-else-if="form.orderType === 'market'">
<div class="cell">
<div class="label">
委托价格
</div>
<div class="value">
{{ form.price }} USDT
</div>
</div>
<div class="cell">
<div class="label">
数量
</div>
<div class="value">
{{ form.quantity }}
</div>
</div>
</template>
</div>
<div class="mt-6">
<ion-button expand="block" color="success" @click="onConfirm">
确认下单
</ion-button>
</div>
</div>
</template> </template>
<style lang='css' scoped></style> <style lang='css' scoped>
@reference "tailwindcss";
.cell {
@apply flex justify-between items-center py-1;
}
.label {
@apply text-sm text-(--ion-text-color-step-400);
}
.value {
@apply text-sm font-semibold;
}
</style>

View File

@@ -7,6 +7,9 @@ import { caretDownOutline } from "ionicons/icons";
import { tradeWayConfig } from "../config"; import { tradeWayConfig } from "../config";
const model = defineModel({ type: Object as PropType<SpotOrderBody>, required: true }); const model = defineModel({ type: Object as PropType<SpotOrderBody>, required: true });
const currentTradeWay = computed(() => {
return tradeWayConfig.find(item => item.value === model.value.orderType);
});
function onSelectTradeWay(item: TradeWayConfig) { function onSelectTradeWay(item: TradeWayConfig) {
model.value.orderType = item.value; model.value.orderType = item.value;
@@ -17,7 +20,7 @@ function onSelectTradeWay(item: TradeWayConfig) {
<template> <template>
<div id="open-modal" class="bg-faint flex items-center justify-between px-4 py-2 rounded-md"> <div id="open-modal" class="bg-faint flex items-center justify-between px-4 py-2 rounded-md">
<div class="text-xs font-medium text-text-300"> <div class="text-xs font-medium text-text-300">
市场 {{ currentTradeWay?.name }}
</div> </div>
<ion-icon :icon="caretDownOutline" /> <ion-icon :icon="caretDownOutline" />
</div> </div>

View File

@@ -1,4 +1,4 @@
import * as yup from "yup"; import { z } from "zod";
export enum TradeWayValueEnum { export enum TradeWayValueEnum {
LIMIT = "limit", LIMIT = "limit",
@@ -21,28 +21,35 @@ export const tradeWayConfig: TradeWayConfig[] = [
description: "以指定价格买入或卖出", description: "以指定价格买入或卖出",
icon: "hugeicons:trade-up", icon: "hugeicons:trade-up",
}, },
{
name: "市价委托",
value: "market",
description: "以市场价格买入或卖出",
icon: "hugeicons:trade-down",
},
]; ];
export const confirmOrderSchema = yup.object({ export const confirmOrderSchema = z.object({
price: yup.number().when("way", { quantity: z.coerce.number({ message: "请输入有效的数量" }).gt(0, "数量必须大于0"),
is: TradeWayValueEnum.LIMIT !== undefined, price: z.coerce.number({ message: "请输入有效的价格" }).gt(0, "价格必须大于0").optional().or(z.coerce.number().optional()),
then: yup orderType: z.enum([TradeWayValueEnum.LIMIT, TradeWayValueEnum.MARKET], {
.number() message: "请选择有效的交易方式",
.typeError("请输入有效的价格") }) as z.ZodType<TradeWayValue>,
.required("价格为必填项") }).refine(
.moreThan(0, "价格必须大于0"), (data) => {
otherwise: yup.number().notRequired(), if (data.orderType === TradeWayValueEnum.LIMIT) {
}), return data.price !== undefined && data.price > 0;
amount: yup }
.number() return true;
.typeError("请输入有效的数量") },
.required("数量为必填项") {
.moreThan(0, "数量必须大于0"), message: "价格为必填项",
way: yup path: ["price"],
.mixed<TradeWayValue>() },
.oneOf( );
Object.values(TradeWayValueEnum),
"请选择有效的交易方式", export const confirmOrderSubmitSchema = confirmOrderSchema.transform(data => ({
) ...data,
.required("交易方式为必填项"), quantity: data.quantity.toString(),
}); price: data.price?.toString() ?? "",
}));

View File

@@ -2,6 +2,7 @@
import type { ChartingLibraryWidgetOptions } from "#/charting_library"; import type { ChartingLibraryWidgetOptions } from "#/charting_library";
import type { SpotOrderBody } from "@/api/types"; import type { SpotOrderBody } from "@/api/types";
import type { TradingViewInst } from "@/tradingview/index"; import type { TradingViewInst } from "@/tradingview/index";
import type { ModalInstance } from "@/utils";
import { modalController } from "@ionic/vue"; import { modalController } from "@ionic/vue";
import { useRouteQuery } from "@vueuse/router"; import { useRouteQuery } from "@vueuse/router";
import { caretDownOutline, ellipsisHorizontal } from "ionicons/icons"; import { caretDownOutline, ellipsisHorizontal } from "ionicons/icons";
@@ -9,28 +10,32 @@ import MaterialSymbolsCandlestickChartOutline from "~icons/material-symbols/cand
import { client, safeClient } from "@/api"; import { client, safeClient } from "@/api";
import { TradeTypeEnum } from "@/api/enum"; import { TradeTypeEnum } from "@/api/enum";
import { TradingViewChart } from "@/tradingview/index"; import { TradingViewChart } from "@/tradingview/index";
import ConfirmOrder from "./components/confirm-order.vue";
import OrdersPanel from "./components/orders-panel.vue"; import OrdersPanel from "./components/orders-panel.vue";
import TradePairsModal from "./components/trade-pairs-modal.vue"; import TradePairsModal from "./components/trade-pairs-modal.vue";
import TradeSwitch from "./components/trade-switch.vue"; import TradeSwitch from "./components/trade-switch.vue";
import TradeWay from "./components/trade-way.vue"; import TradeWay from "./components/trade-way.vue";
import { confirmOrderSchema, TradeWayValueEnum } from "./config"; import { confirmOrderSubmitSchema, TradeWayValueEnum } from "./config";
const { data } = await safeClient(client.api.trading_pairs.get({ query: { limit: 1 } }));
const mode = useRouteQuery<TradeTypeEnum>("mode", TradeTypeEnum.BUY); const mode = useRouteQuery<TradeTypeEnum>("mode", TradeTypeEnum.BUY);
const symbol = useRouteQuery<string>("symbol", "BTCUSD"); const symbol = useRouteQuery<string>("symbol", data.value?.data[0].symbol);
const tradingviewOptions: Partial<ChartingLibraryWidgetOptions> = { const tradingviewOptions: Partial<ChartingLibraryWidgetOptions> = {
disabled_features: [ disabled_features: [
"create_volume_indicator_by_default", "create_volume_indicator_by_default",
], ],
}; };
const tradingViewInst = useTemplateRef<TradingViewInst>("tradingViewInst"); const tradingViewInst = useTemplateRef<TradingViewInst>("tradingViewInst");
const [form] = useResetRef<SpotOrderBody>({ const confirmModalInst = useTemplateRef<ModalInstance>("confirmModalInst");
const [form] = useResetRef<SpotOrderBody & { amount: string }>({
orderType: TradeWayValueEnum.LIMIT, orderType: TradeWayValueEnum.LIMIT,
quantity: "", quantity: "",
side: mode.value, side: mode.value,
symbol: symbol.value, symbol: symbol.value,
memo: "", memo: "",
price: "", price: "",
amount: "",
}); });
async function openTradePairs() { async function openTradePairs() {
@@ -40,22 +45,38 @@ async function openTradePairs() {
initialBreakpoint: 0.95, initialBreakpoint: 0.95,
handle: true, handle: true,
}); });
await modal.present(); await modal.present();
const { data: result } = await modal.onWillDismiss<string>(); const { data: result } = await modal.onWillDismiss<string>();
result && (symbol.value = result);
if (result) { }
symbol.value = result; function handleChangeQuantity(event) {
const val = (event.target as HTMLInputElement).value;
if (val && form.value.price) {
const amount = Number(val) * Number(form.value.price);
form.value.amount = amount.toString();
}
else {
form.value.amount = "";
} }
} }
function handleChangeAmount(event) {
function handleSubmit() { const val = (event.target as HTMLInputElement).value;
confirmOrderSchema.validate(form.value).then(() => { if (val && form.value.price) {
console.log("submit successfully"); const quantity = Number(val) / Number(form.value.price);
}).catch((err) => { form.value.quantity = quantity.toString();
console.log("submit failed:", err); }
}); else {
form.value.quantity = "";
}
}
async function handleSubmit() {
try {
await confirmOrderSubmitSchema.parseAsync(form.value);
confirmModalInst.value?.$el.present();
}
catch (err) {
console.error("订单验证失败:", err);
}
} }
</script> </script>
@@ -90,21 +111,37 @@ function handleSubmit() {
<TradeSwitch v-model:active="mode" @update:active="val => form.side = val" /> <TradeSwitch v-model:active="mode" @update:active="val => form.side = val" />
<TradeWay v-model="form" /> <TradeWay v-model="form" />
<template v-if="form.orderType === 'limit'"> <template v-if="form.orderType === 'limit'">
<ion-input v-model="form.price" label="价格" class="count" inputmode="decimal" type="number" placeholder="请输入价格(USDT)" /> <ion-input v-model="form.price" label="价格" class="count" inputmode="decimal" type="number" placeholder="请输入价格">
<span slot="end">USDT</span>
</ion-input>
<ion-input v-model="form.quantity" label="数量" class="count" inputmode="decimal" type="number" placeholder="请输入交易数量" @ion-input="handleChangeQuantity">
<span slot="end">{{ symbol }}</span>
</ion-input>
<ion-input v-model="form.amount" label="金额" class="count" inputmode="decimal" type="number" placeholder="请输入交易金额" @ion-input="handleChangeAmount">
<span slot="end">USDT</span>
</ion-input>
</template> </template>
<ion-input v-model="form.quantity" label="数量" class="count" inputmode="decimal" type="number" placeholder="请输入交易数量"> <template v-else-if="form.orderType === 'market'">
<span slot="end">{{ symbol }}</span> <ion-input v-model="form.price" label="价格" class="count" inputmode="decimal" type="number" placeholder="请输入价格">
</ion-input> <span slot="end">USDT</span>
<ion-input v-model="form.price" label="金额" class="count" inputmode="decimal" type="number" placeholder="请输入交易金额"> </ion-input>
<span slot="end">USDT</span> <ion-input v-model="form.quantity" label="数量" class="count" inputmode="decimal" type="number" placeholder="请输入交易数量" @ion-input="handleChangeQuantity">
</ion-input> <span slot="end">{{ symbol }}</span>
</ion-input>
</template>
<!-- <ion-range class="range" aria-label="Range with ticks" :pin="true" :ticks="true" :snaps="true" :min="0" :max="5" /> --> <!-- <ion-range class="range" aria-label="Range with ticks" :pin="true" :ticks="true" :snaps="true" :min="0" :max="5" /> -->
<ion-button expand="block" size="small" shape="round" :color="mode === TradeTypeEnum.BUY ? 'success' : 'danger'" @click="handleSubmit"> <ion-button expand="block" size="small" shape="round" :color="mode === TradeTypeEnum.BUY ? 'success' : 'danger'" @click="handleSubmit">
{{ mode === TradeTypeEnum.BUY ? '买入' : '卖出' }} {{ mode === TradeTypeEnum.BUY ? '买入' : '卖出' }}
</ion-button> </ion-button>
<ion-modal ref="confirmModalInst" class="confirm-modal" :breakpoints="[0, 1]" :initial-breakpoint="1" :handle="false">
<ConfirmOrder :form="form" />
</ion-modal>
</div> </div>
<div class="col-span-2" /> <div class="col-span-2" />
</div> </div>
<div class="mt-6 px-4 pb-4"> <div class="mt-6 px-4 pb-4">
<OrdersPanel /> <OrdersPanel />
</div> </div>
@@ -145,4 +182,7 @@ ion-range.range::part(tick-active) {
top: 18px; top: 18px;
border-radius: 100%; border-radius: 100%;
} }
.confirm-modal {
--height: auto;
}
</style> </style>

View File

@@ -3,7 +3,7 @@ import type { GenericObject } from "vee-validate";
import { toastController } from "@ionic/vue"; import { toastController } from "@ionic/vue";
import { arrowBackOutline } from "ionicons/icons"; import { arrowBackOutline } from "ionicons/icons";
import { Field, Form } from "vee-validate"; import { Field, Form } from "vee-validate";
import * as yup from "yup"; import { z } from "zod";
import { safeClient } from "@/api"; import { safeClient } from "@/api";
import { authClient, emailSchema } from "@/auth"; import { authClient, emailSchema } from "@/auth";
@@ -17,7 +17,7 @@ const isSending = ref(false);
const canResend = computed(() => countdown.value === 0 && !isSending.value); const canResend = computed(() => countdown.value === 0 && !isSending.value);
const emailError = ref(""); const emailError = ref("");
let timer: number | null = null; let timer: NodeJS.Timeout | null = null;
function startCountdown() { function startCountdown() {
countdown.value = 60; countdown.value = 60;
@@ -40,7 +40,7 @@ async function sendOtp() {
} }
try { try {
await yup.string().email().validate(emailValue); await z.string().email().parseAsync(emailValue);
} }
catch { catch {
emailError.value = t("auth.login.validation.emailInvalid"); emailError.value = t("auth.login.validation.emailInvalid");

View File

@@ -4,7 +4,7 @@ import type { FormInstance } from "@/utils";
import { loadingController, toastController } from "@ionic/vue"; import { loadingController, toastController } from "@ionic/vue";
import { swapVerticalOutline } from "ionicons/icons"; import { swapVerticalOutline } from "ionicons/icons";
import { ErrorMessage, Field, Form } from "vee-validate"; import { ErrorMessage, Field, Form } from "vee-validate";
import * as yup from "yup"; import { z } from "zod";
import { client, safeClient } from "@/api"; import { client, safeClient } from "@/api";
import { AssetCodeEnum } from "@/api/enum"; import { AssetCodeEnum } from "@/api/enum";
import { getCryptoIcon } from "@/config/crypto"; import { getCryptoIcon } from "@/config/crypto";
@@ -43,15 +43,15 @@ const availableBalance = computed(() => {
}); });
// 验证规则 // 验证规则
const schema = computed(() => yup.object({ const schema = computed(() => z.object({
assetCode: yup.string().required(t("transfer.assetCodeRequired")), assetCode: z.string({ message: t("transfer.assetCodeRequired") }).min(1, t("transfer.assetCodeRequired")),
amount: yup amount: z
.string() .string({ message: t("transfer.amountRequired") })
.required(t("transfer.amountRequired")) .min(1, t("transfer.amountRequired"))
.test("min", t("transfer.amountMinError"), value => Number(value) > 0) .refine(value => Number(value) > 0, t("transfer.amountMinError"))
.test("max", t("transfer.amountMaxError", { amount: availableBalance.value }), value => Number(value) <= Number(availableBalance.value)), .refine(value => Number(value) <= Number(availableBalance.value), t("transfer.amountMaxError", { amount: availableBalance.value })),
fromAccount: yup.string().required(t("transfer.fromAccountRequired")), fromAccount: z.string({ message: t("transfer.fromAccountRequired") }).min(1, t("transfer.fromAccountRequired")),
toAccount: yup.string().required(t("transfer.toAccountRequired")), toAccount: z.string({ message: t("transfer.toAccountRequired") }).min(1, t("transfer.toAccountRequired")),
})); }));
// 交换账户 // 交换账户

View File

@@ -1,43 +1,70 @@
import { toTypedSchema } from "@vee-validate/yup"; import { toTypedSchema } from "@vee-validate/zod";
import * as yup from "yup"; import { z } from "zod";
import { WithdrawMethodEnum } from "@/api/enum"; import { WithdrawMethodEnum } from "@/api/enum";
export function createWithdrawSchema(t: (key: string, params?: any) => string, maxAmount: string) { export function createWithdrawSchema(t: (key: string, params?: any) => string, maxAmount: string) {
return toTypedSchema( return toTypedSchema(
yup.object({ z.object({
assetCode: yup.string().required(t("withdraw.validation.assetCodeRequired")), assetCode: z.string({ message: t("withdraw.validation.assetCodeRequired") }).min(1, t("withdraw.validation.assetCodeRequired")),
amount: yup amount: z
.string() .string({ message: t("withdraw.validation.amountRequired") })
.required(t("withdraw.validation.amountRequired")) .min(1, t("withdraw.validation.amountRequired"))
.test("is-number", t("withdraw.validation.amountInvalid"), (value) => { .refine(
return /^\d+(?:\.\d+)?$/.test(value || ""); value => /^\d+(?:\.\d+)?$/.test(value),
}) t("withdraw.validation.amountInvalid"),
.test("max-amount", t("withdraw.validation.amountExceedsBalance"), (value) => { )
if (!value || maxAmount === "0") .refine(
return false; (value) => {
return Number.parseFloat(value) <= Number.parseFloat(maxAmount); if (maxAmount === "0")
}) return false;
.test("min-amount", t("withdraw.validation.amountTooSmall"), (value) => { return Number.parseFloat(value) <= Number.parseFloat(maxAmount);
if (!value) },
return false; t("withdraw.validation.amountExceedsBalance"),
return Number.parseFloat(value) > 0; )
}), .refine(
withdrawMethod: yup.string().required(t("withdraw.validation.methodRequired")), value => Number.parseFloat(value) > 0,
bankAccountId: yup.string().when("withdrawMethod", { t("withdraw.validation.amountTooSmall"),
is: WithdrawMethodEnum.BANK, ),
then: schema => schema.required(t("withdraw.validation.bankAccountRequired")), withdrawMethod: z.string({ message: t("withdraw.validation.methodRequired") }).min(1, t("withdraw.validation.methodRequired")),
otherwise: schema => schema.optional(), bankAccountId: z.string().optional(),
}), chain: z.string().optional(),
chain: yup.string().when("withdrawMethod", { toAddress: z.string().optional(),
is: WithdrawMethodEnum.CRYPTO, })
then: schema => schema.required(t("withdraw.validation.chainRequired")), .refine(
otherwise: schema => schema.optional(), (data) => {
}), if (data.withdrawMethod === WithdrawMethodEnum.BANK) {
toAddress: yup.string().when("withdrawMethod", { return !!data.bankAccountId;
is: WithdrawMethodEnum.CRYPTO, }
then: schema => schema.required(t("withdraw.validation.addressRequired")).min(10, t("withdraw.validation.addressTooShort")), return true;
otherwise: schema => schema.optional(), },
}), {
}), message: t("withdraw.validation.bankAccountRequired"),
path: ["bankAccountId"],
},
)
.refine(
(data) => {
if (data.withdrawMethod === WithdrawMethodEnum.CRYPTO) {
return !!data.chain;
}
return true;
},
{
message: t("withdraw.validation.chainRequired"),
path: ["chain"],
},
)
.refine(
(data) => {
if (data.withdrawMethod === WithdrawMethodEnum.CRYPTO) {
return !!data.toAddress && data.toAddress.length >= 10;
}
return true;
},
{
message: t("withdraw.validation.addressRequired"),
path: ["toAddress"],
},
),
); );
} }

File diff suppressed because it is too large Load Diff

2059
types/datafeeds.d.ts vendored

File diff suppressed because it is too large Load Diff