feat: update environment variables for development and production; add user store and authentication client
- Updated VITE_API_URL in .env.development to point to local server. - Retained VITE_API_URL in .env.production for production use. - Added user store for managing user profile and authentication state. - Created authentication client for handling user login and token management. - Introduced safeClient utility for making API requests with error handling. - Updated various components and views to utilize new user store and authentication logic. - Enhanced UI styles for better visual consistency across the application.
This commit is contained in:
124
src/api/index.ts
Normal file
124
src/api/index.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { App } from "@capp/eden";
|
||||
import type { WatchSource } from "vue";
|
||||
import { treaty } from "@elysiajs/eden";
|
||||
import { toastController } from "@ionic/vue";
|
||||
import { i18n } from "@/locales";
|
||||
|
||||
const baseURL = import.meta.env.DEV ? window.location.origin : import.meta.env.VITE_API_URL;
|
||||
|
||||
export const client = treaty<App>(baseURL, {
|
||||
fetch: {
|
||||
credentials: "include",
|
||||
},
|
||||
headers() {
|
||||
const token = localStorage.getItem("user-token") || "";
|
||||
const headers = new Headers();
|
||||
headers.append("Content-Type", "application/json");
|
||||
headers.append("Authorization", token ? `Bearer ${token}` : "");
|
||||
return headers;
|
||||
},
|
||||
});
|
||||
|
||||
export interface SafeClientOptions {
|
||||
silent?: boolean;
|
||||
immediate?: boolean;
|
||||
watchSource?: WatchSource; // 用于监听的响应式数据源
|
||||
}
|
||||
|
||||
export interface SafeClientReturn<T, E> {
|
||||
data: Ref<T | null>;
|
||||
error: Ref<E | null>;
|
||||
isPending: Ref<boolean>;
|
||||
execute: () => Promise<void>;
|
||||
onFetchResponse: (callback: (data: T, error: E) => void) => void;
|
||||
stopWatching?: () => void;
|
||||
}
|
||||
|
||||
export type RequestPromise<T, E> = Promise<{ data: T; error: E; status?: number; response?: Response }>;
|
||||
|
||||
export function safeClient<T, E>(
|
||||
requestPromise: (() => RequestPromise<T, E>) | RequestPromise<T, E>,
|
||||
options: SafeClientOptions = {},
|
||||
): SafeClientReturn<T, E> & Promise<SafeClientReturn<T, E>> {
|
||||
const { immediate = true, watchSource } = options;
|
||||
const data = ref<T | null>(null);
|
||||
const error = ref<E | null>(null);
|
||||
const isPending = ref(false);
|
||||
let responseCallback: ((data: T, error: E) => void) | null = null;
|
||||
let stopWatcher: (() => void) | undefined;
|
||||
|
||||
const execute = async () => {
|
||||
isPending.value = true;
|
||||
let request: () => RequestPromise<T, E>;
|
||||
if (typeof requestPromise !== "function") {
|
||||
request = () => Promise.resolve(requestPromise);
|
||||
}
|
||||
else {
|
||||
request = requestPromise;
|
||||
}
|
||||
|
||||
const res = await request().finally(() => {
|
||||
isPending.value = false;
|
||||
});
|
||||
|
||||
if (res.error && res.status === 418) {
|
||||
if (!options.silent) {
|
||||
const toast = await toastController.create({
|
||||
message: i18n.global.t((res.error as any).value.code, {
|
||||
...(res.error as any).value.context,
|
||||
}),
|
||||
duration: 3000,
|
||||
position: "bottom",
|
||||
color: "danger",
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
|
||||
throw res.error;
|
||||
}
|
||||
data.value = res.data;
|
||||
error.value = res.error;
|
||||
|
||||
// 调用注册的回调函数
|
||||
if (responseCallback) {
|
||||
responseCallback(res.data, res.error);
|
||||
}
|
||||
};
|
||||
|
||||
function onFetchResponse(callback: (data: T, error: E) => void) {
|
||||
responseCallback = callback;
|
||||
}
|
||||
|
||||
function stopWatching() {
|
||||
if (stopWatcher) {
|
||||
stopWatcher();
|
||||
stopWatcher = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果提供了 watchSource,则监听其变化
|
||||
if (watchSource) {
|
||||
stopWatcher = watch(
|
||||
watchSource,
|
||||
() => {
|
||||
execute();
|
||||
},
|
||||
{ immediate: false }, // 不立即执行,避免与 immediate 选项冲突
|
||||
);
|
||||
}
|
||||
|
||||
const result: SafeClientReturn<T, E> = {
|
||||
data: data as Ref<T | null>,
|
||||
error: error as Ref<E | null>,
|
||||
isPending,
|
||||
execute,
|
||||
onFetchResponse,
|
||||
stopWatching,
|
||||
};
|
||||
|
||||
const promise = immediate ? execute().then(() => result) : Promise.resolve(result);
|
||||
|
||||
Object.assign(promise, result);
|
||||
|
||||
return promise as SafeClientReturn<T, E> & Promise<SafeClientReturn<T, E>>;
|
||||
}
|
||||
12
src/api/types.ts
Normal file
12
src/api/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Treaty } from "@elysiajs/eden";
|
||||
import type { client } from ".";
|
||||
|
||||
export type TreatyQuery<T> = T extends (...args: any[]) => any
|
||||
? NonNullable<NonNullable<Parameters<T>[0]>["query"]>
|
||||
: never;
|
||||
|
||||
export type TreatyBody<T> = T extends (...args: any[]) => any
|
||||
? NonNullable<Parameters<T>[0]>
|
||||
: never;
|
||||
|
||||
export type UserProfileData = Treaty.Data<typeof client.api.user.profile.get>;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 193 KiB |
16
src/auth/index.ts
Normal file
16
src/auth/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { usernameClient } from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/vue";
|
||||
|
||||
const baseURL = import.meta.env.DEV ? window.location.origin : import.meta.env.VITE_API_URL;
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL,
|
||||
fetchOptions: {
|
||||
credentials: "include",
|
||||
auth: {
|
||||
type: "Bearer",
|
||||
token: () => localStorage.getItem("user-token") || "",
|
||||
},
|
||||
},
|
||||
plugins: [usernameClient()],
|
||||
});
|
||||
@@ -45,7 +45,7 @@ const { t } = useI18n();
|
||||
ion-tab-bar {
|
||||
height: 70px;
|
||||
--background: white;
|
||||
box-shadow: 0px 0px 12px var(--ion-color-tertiary);
|
||||
box-shadow: 0px 0px 12px #cfe3ff;
|
||||
padding-bottom: var(--ion-safe-area-bottom);
|
||||
}
|
||||
.icon {
|
||||
|
||||
@@ -2,6 +2,14 @@ import type { Router } from "vue-router";
|
||||
|
||||
export function createRouterGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const userStore = useUserStore();
|
||||
// if (to.meta.requiresAuth && !userStore.isAuthenticated) {
|
||||
// if (from.path === "/auth/login") {
|
||||
// return next("/");
|
||||
// }
|
||||
// const redirect = encodeURIComponent(to.fullPath);
|
||||
// return next({ path: "/auth/login", query: { redirect } });
|
||||
// }
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,18 +20,22 @@ const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: "home",
|
||||
component: () => import("@/views/home/index.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "service",
|
||||
component: () => import("@/views/service/index.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "product",
|
||||
component: () => import("@/views/product/index.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
component: () => import("@/views/profile/index.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
1
src/store/index.ts
Normal file
1
src/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./user";
|
||||
39
src/store/user.ts
Normal file
39
src/store/user.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { UserProfileData } from "@/api/types";
|
||||
import { client, safeClient } from "@/api";
|
||||
|
||||
interface State {
|
||||
userProfile: UserProfileData | null;
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore("user", () => {
|
||||
const token = useStorageAsync<string | null>("user-token", null);
|
||||
const state = reactive<State>({
|
||||
userProfile: null,
|
||||
});
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value);
|
||||
|
||||
async function updateProfile() {
|
||||
const { data } = await safeClient(client.api.user.profile.get(), { silent: true });
|
||||
state.userProfile = data.value || null;
|
||||
}
|
||||
|
||||
function setToken(value: string) {
|
||||
localStorage.setItem("user-token", value);
|
||||
token.value = value;
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
token.value = null;
|
||||
state.userProfile = null;
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
token,
|
||||
isAuthenticated,
|
||||
signOut,
|
||||
setToken,
|
||||
updateProfile,
|
||||
};
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
:root {
|
||||
/* :root {
|
||||
--ion-color-primary: #c31d39;
|
||||
--ion-color-primary-rgb: 195,29,57;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
@@ -62,6 +62,72 @@
|
||||
--ion-color-dark-shade: #292929;
|
||||
--ion-color-dark-tint: #444444;
|
||||
|
||||
} */
|
||||
|
||||
:root {
|
||||
--ion-color-primary: #2065c3;
|
||||
--ion-color-primary-rgb: 32,101,195;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
--ion-color-primary-contrast-rgb: 255,255,255;
|
||||
--ion-color-primary-shade: #1c59ac;
|
||||
--ion-color-primary-tint: #3674c9;
|
||||
|
||||
--ion-color-secondary: #0163aa;
|
||||
--ion-color-secondary-rgb: 1,99,170;
|
||||
--ion-color-secondary-contrast: #ffffff;
|
||||
--ion-color-secondary-contrast-rgb: 255,255,255;
|
||||
--ion-color-secondary-shade: #015796;
|
||||
--ion-color-secondary-tint: #1a73b3;
|
||||
|
||||
--ion-color-tertiary: #6030ff;
|
||||
--ion-color-tertiary-rgb: 96,48,255;
|
||||
--ion-color-tertiary-contrast: #ffffff;
|
||||
--ion-color-tertiary-contrast-rgb: 255,255,255;
|
||||
--ion-color-tertiary-shade: #542ae0;
|
||||
--ion-color-tertiary-tint: #7045ff;
|
||||
|
||||
--ion-color-success: #2dd55b;
|
||||
--ion-color-success-rgb: 45,213,91;
|
||||
--ion-color-success-contrast: #000000;
|
||||
--ion-color-success-contrast-rgb: 0,0,0;
|
||||
--ion-color-success-shade: #28bb50;
|
||||
--ion-color-success-tint: #42d96b;
|
||||
|
||||
--ion-color-warning: #ffc409;
|
||||
--ion-color-warning-rgb: 255,196,9;
|
||||
--ion-color-warning-contrast: #000000;
|
||||
--ion-color-warning-contrast-rgb: 0,0,0;
|
||||
--ion-color-warning-shade: #e0ac08;
|
||||
--ion-color-warning-tint: #ffca22;
|
||||
|
||||
--ion-color-danger: #c5000f;
|
||||
--ion-color-danger-rgb: 197,0,15;
|
||||
--ion-color-danger-contrast: #ffffff;
|
||||
--ion-color-danger-contrast-rgb: 255,255,255;
|
||||
--ion-color-danger-shade: #ad000d;
|
||||
--ion-color-danger-tint: #cb1a27;
|
||||
|
||||
--ion-color-light: #f6f8fc;
|
||||
--ion-color-light-rgb: 246,248,252;
|
||||
--ion-color-light-contrast: #000000;
|
||||
--ion-color-light-contrast-rgb: 0,0,0;
|
||||
--ion-color-light-shade: #d8dade;
|
||||
--ion-color-light-tint: #f7f9fc;
|
||||
|
||||
--ion-color-medium: #5f5f5f;
|
||||
--ion-color-medium-rgb: 95,95,95;
|
||||
--ion-color-medium-contrast: #ffffff;
|
||||
--ion-color-medium-contrast-rgb: 255,255,255;
|
||||
--ion-color-medium-shade: #545454;
|
||||
--ion-color-medium-tint: #6f6f6f;
|
||||
|
||||
--ion-color-dark: #2f2f2f;
|
||||
--ion-color-dark-rgb: 47,47,47;
|
||||
--ion-color-dark-contrast: #ffffff;
|
||||
--ion-color-dark-contrast-rgb: 255,255,255;
|
||||
--ion-color-dark-shade: #292929;
|
||||
--ion-color-dark-tint: #444444;
|
||||
|
||||
}
|
||||
|
||||
.ion-toolbar {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script lang='ts' setup>
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const phoneNumber = ref("");
|
||||
@@ -23,7 +21,7 @@ function handleLogin() {
|
||||
}
|
||||
|
||||
function handleSignup() {
|
||||
router.push("/auth/signup");
|
||||
router.push(`/auth/signup?redirect=${encodeURIComponent(route.query.redirect as string || "/")}`);
|
||||
}
|
||||
|
||||
function goToTerms(type: "service" | "privacy") {
|
||||
|
||||
@@ -66,10 +66,10 @@ const newsList = ref([
|
||||
const router = useRouter();
|
||||
// 快捷入口
|
||||
const quickActions = ref([
|
||||
{ id: 1, name: "签到", icon: calendarOutline, color: "#c32120" },
|
||||
{ id: 2, name: "团队中心", icon: peopleOutline, color: "#c32120" },
|
||||
{ id: 3, name: "邀请好友", icon: rocketOutline, color: "#c32120" },
|
||||
{ id: 4, name: "在线客服", icon: chatbubblesOutline, color: "#c32120" },
|
||||
{ id: 1, name: "签到", icon: calendarOutline, color: "#2373c3" },
|
||||
{ id: 2, name: "团队中心", icon: peopleOutline, color: "#2373c3" },
|
||||
{ id: 3, name: "邀请好友", icon: rocketOutline, color: "#2373c3" },
|
||||
{ id: 4, name: "在线客服", icon: chatbubblesOutline, color: "#2373c3" },
|
||||
]);
|
||||
|
||||
function handleQuickAction(action: any) {
|
||||
@@ -149,7 +149,7 @@ onUnmounted(() => {
|
||||
@click="handleQuickAction(action)"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex-center shadow-lg"
|
||||
class="w-12 h-12 rounded-xl flex-center shadow-lg"
|
||||
:style="{ background: action.color }"
|
||||
>
|
||||
<ion-icon :icon="action.icon" class="text-xl text-white" />
|
||||
@@ -182,7 +182,7 @@ onUnmounted(() => {
|
||||
>
|
||||
<div class="relative w-28 h-28 flex-shrink-0 overflow-hidden">
|
||||
<img :src="news.image" :alt="news.title" class="w-full h-full object-cover">
|
||||
<div class="news-badge absolute top-2 left-2 bg-gradient-to-br from-[#c41e3a] to-[#8b1a2e] text-white px-2 py-0.5 rounded-lg text-xs font-semibold shadow-lg">
|
||||
<div class="news-badge absolute top-2 left-2 bg-linear-to-br from-[#78d0ff] to-[#1879aa] text-white px-2 py-0.5 rounded-lg text-xs font-semibold shadow-lg">
|
||||
热点
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,12 +174,12 @@ function handleSubscribe(product: any, event: Event) {
|
||||
}
|
||||
|
||||
.subscribe-btn {
|
||||
--background: linear-gradient(135deg, #c41e3a 0%, #8b1a2e 100%);
|
||||
--background-activated: linear-gradient(135deg, #8b1a2e 0%, #c41e3a 100%);
|
||||
--background: linear-gradient(135deg, #1778ac 0%, #265166 100%);
|
||||
--background-activated: linear-gradient(135deg, #1778ac 0%, #265166 100%);
|
||||
--border-radius: 12px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
--box-shadow: 0 2px 8px rgba(196, 30, 58, 0.3);
|
||||
--box-shadow: 0 2px 8px rgba(30, 124, 196, 0.3);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
height: 32px;
|
||||
|
||||
@@ -118,13 +118,13 @@ function handleLogout() {
|
||||
<img alt="用户头像" src="@/assets/images/avatar.jpg">
|
||||
</ion-avatar>
|
||||
<div>
|
||||
<div class="text-primary text-xl font-bold">
|
||||
<div class="text-danger text-xl font-bold">
|
||||
{{ userInfo.name }}
|
||||
</div>
|
||||
<div class="text-primary text-sm font-semibold opacity-90">
|
||||
<div class="text-danger text-sm font-semibold opacity-90">
|
||||
手机号:{{ userInfo.phone }}
|
||||
</div>
|
||||
<div class="text-primary text-sm font-semibold opacity-90">
|
||||
<div class="text-danger text-sm font-semibold opacity-90">
|
||||
邀请码:{{ userInfo.inviteCode }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,7 +136,7 @@ function handleLogout() {
|
||||
<section class="mb-4 -mt-12">
|
||||
<div class="card rounded-2xl shadow-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ion-icon :icon="walletOutline" class="text-2xl text-[#c41e3a]" />
|
||||
<ion-icon :icon="walletOutline" class="text-2xl text-[#1e71c4]" />
|
||||
<div class="text-lg font-bold text-[#1a1a1a] m-0">
|
||||
我的钱包
|
||||
</div>
|
||||
@@ -144,19 +144,19 @@ function handleLogout() {
|
||||
|
||||
<!-- 余额展示 -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="bg-gradient-to-br from-[#fff7f0] to-[#ffe8e8] rounded-xl p-4">
|
||||
<div class="bg-gradient-to-br from-[#ffffff] to-[#e8f5ff] rounded-xl p-4">
|
||||
<div class="text-xs mb-1">
|
||||
收益钱包
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-[#c41e3a] mb-1">
|
||||
<div class="text-2xl font-bold text-[#1e71c4] mb-1">
|
||||
¥{{ wallet.profitBalance.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-[#fff7f0] to-[#ffe8e8] rounded-xl p-4">
|
||||
<div class="bg-gradient-to-br from-[#ffffff] to-[#e8f5ff] rounded-xl p-4">
|
||||
<div class="text-xs mb-1">
|
||||
账户余额
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-[#c41e3a] mb-1">
|
||||
<div class="text-2xl font-bold text-[#1e71c4] mb-1">
|
||||
¥{{ wallet.accountBalance.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,7 +189,7 @@ function handleLogout() {
|
||||
<section class="my-5">
|
||||
<div class="card rounded-2xl shadow-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ion-icon :icon="homeOutline" class="text-2xl text-[#c41e3a]" />
|
||||
<ion-icon :icon="homeOutline" class="text-2xl text-[#1e71c4]" />
|
||||
<div class="text-lg font-bold text-[#1a1a1a] m-0">
|
||||
我的应用
|
||||
</div>
|
||||
@@ -227,13 +227,13 @@ function handleLogout() {
|
||||
|
||||
<style lang='css' scoped>
|
||||
.card {
|
||||
background: linear-gradient(180deg, #ffeef1, #ffffff 15%);
|
||||
background: linear-gradient(180deg, #eef9ff, #ffffff 15%);
|
||||
}
|
||||
.recharge-btn {
|
||||
--background: linear-gradient(135deg, #c41e3a 0%, #8b1a2e 100%);
|
||||
--background-activated: linear-gradient(135deg, #8b1a2e 0%, #c41e3a 100%);
|
||||
--background: linear-gradient(135deg, #1778ac 0%, #265166 100%);
|
||||
--background-activated: linear-gradient(135deg, #1778ac 0%, #265166 100%);
|
||||
--border-radius: 12px;
|
||||
--box-shadow: 0 2px 8px rgba(196, 30, 58, 0.3);
|
||||
--box-shadow: 0 2px 8px rgba(30, 124, 196, 0.3);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
height: 44px;
|
||||
@@ -241,8 +241,8 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
.withdraw-btn {
|
||||
--border-color: #c41e3a;
|
||||
--color: #c41e3a;
|
||||
--border-color: #1972a2;
|
||||
--color: #1e6ac4;
|
||||
--border-radius: 12px;
|
||||
--border-width: 2px;
|
||||
font-weight: 600;
|
||||
@@ -255,6 +255,6 @@ function handleLogout() {
|
||||
font-weight: 600;
|
||||
}
|
||||
.app-item {
|
||||
background: linear-gradient(135deg, rgb(249 103 102 / 93%), rgb(195, 33, 32));
|
||||
background: #3f8bba;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -63,7 +63,7 @@ function handleNewsClick(news: any) {
|
||||
>
|
||||
<div class="relative w-full h-45 overflow-hidden">
|
||||
<img :src="news.image" :alt="news.title" class="w-full h-full object-cover">
|
||||
<div class="news-badge absolute top-3 left-3 bg-gradient-to-br from-[#c41e3a] to-[#8b1a2e] text-white px-3 py-1 rounded-xl text-xs font-semibold shadow-lg">
|
||||
<div class="news-badge absolute top-3 left-3 bg-linear-to-br from-[#78d0ff] to-[#1879aa] text-white px-3 py-1 rounded-xl text-xs font-semibold shadow-lg">
|
||||
热点
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user