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:
2026-01-17 17:23:38 +07:00
parent 1239935b57
commit 7ec2522fa0
22 changed files with 353 additions and 68 deletions

124
src/api/index.ts Normal file
View 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
View 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
View 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()],
});

View File

@@ -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 {

View File

@@ -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();
});
}

View File

@@ -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
View File

@@ -0,0 +1 @@
export * from "./user";

39
src/store/user.ts Normal file
View 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,
};
});

View File

@@ -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 {

View File

@@ -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") {

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>