完成登陆注册接口

This commit is contained in:
2026-05-09 22:09:49 +08:00
parent c9c1092702
commit 20215143af
9 changed files with 549 additions and 125 deletions

View File

@@ -0,0 +1,70 @@
import { HttpClient } from '../utils/HttpClient';
import { TokenStore } from '../utils/TokenStore';
import {
RegisterRequest,
LoginRequest,
RefreshTokenRequest,
AuthResponseData,
TokenPair,
AuthMeData,
MessageResponse
} from '../types/Auth';
/**
* Auth 模块接口
* 对应文档: /api/auth/*
*/
export class AuthApi {
/**
* POST /api/auth/register
* 用户注册
*/
static async register(req: RegisterRequest): Promise<AuthResponseData> {
const data = await HttpClient.post<AuthResponseData>('/api/auth/register', req, false);
await TokenStore.saveTokens(data.accessToken, data.refreshToken, data.user.id);
return data;
}
/**
* POST /api/auth/login
* 用户登录
*/
static async login(req: LoginRequest): Promise<AuthResponseData> {
const data = await HttpClient.post<AuthResponseData>('/api/auth/login', req, false);
await TokenStore.saveTokens(data.accessToken, data.refreshToken, data.user.id);
return data;
}
/**
* POST /api/auth/refresh
* 使用 refreshToken 刷新令牌
*/
static async refresh(): Promise<TokenPair> {
const refreshToken = await TokenStore.getRefreshToken();
const req: RefreshTokenRequest = { refreshToken };
const data = await HttpClient.post<TokenPair>('/api/auth/refresh', req, false);
await TokenStore.saveTokens(data.accessToken, data.refreshToken);
return data;
}
/**
* POST /api/auth/logout
* 用户登出
*/
static async logout(): Promise<MessageResponse> {
try {
const result = await HttpClient.post<MessageResponse>('/api/auth/logout', undefined, true);
return result;
} finally {
await TokenStore.clear();
}
}
/**
* GET /api/auth/me
* 获取当前认证用户信息
*/
static getMe(): Promise<AuthMeData> {
return HttpClient.get<AuthMeData>('/api/auth/me', true);
}
}

View File

@@ -0,0 +1,10 @@
/**
* 应用配置
* 注意:模拟器中无法直接访问主机的 localhost需要将下面的 BASE_URL 改为:
* - 鸿蒙模拟器:使用主机局域网 IP如 http://192.168.x.x:3000
* - 真机调试:手机与电脑在同一局域网下,使用电脑的局域网 IP
*/
export class AppConfig {
static readonly BASE_URL: string = 'http://localhost:3000';
static readonly REQUEST_TIMEOUT: number = 10000;
}

View File

@@ -1,6 +1,7 @@
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit'; import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI'; import { window } from '@kit.ArkUI';
import { TokenStore } from '../utils/TokenStore';
const DOMAIN = 0x0000; const DOMAIN = 0x0000;
@@ -11,6 +12,10 @@ export default class EntryAbility extends UIAbility {
} catch (err) { } catch (err) {
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err)); hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
} }
// 初始化 Token 存储
TokenStore.init(this.context).catch((err: Error) => {
hilog.error(DOMAIN, 'testTag', 'TokenStore init failed: %{public}s', err.message);
});
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
} }

View File

@@ -1,9 +1,11 @@
import { promptAction, router } from '@kit.ArkUI'; import { promptAction, router } from '@kit.ArkUI';
import { AuthApi } from '../api/AuthApi';
import { HttpError } from '../utils/HttpClient';
@Entry @Entry
@Component @Component
struct LoginPage { struct LoginPage {
@State username: string = ''; @State email: string = '';
@State password: string = ''; @State password: string = '';
@State isLoading: boolean = false; @State isLoading: boolean = false;
@@ -29,21 +31,22 @@ struct LoginPage {
// 登录表单 // 登录表单
Column() { Column() {
// 用户名输入框 // 邮箱输入框
Column() { Column() {
Text('账号') Text('邮箱')
.fontSize(14) .fontSize(14)
.fontColor($r('app.color.text_primary')) .fontColor($r('app.color.text_primary'))
.margin({ bottom: 8 }) .margin({ bottom: 8 })
TextInput({ placeholder: '请输入手机号/邮箱', text: this.username }) TextInput({ placeholder: '请输入邮箱', text: this.email })
.height(48) .height(48)
.fontSize(16) .fontSize(16)
.backgroundColor($r('app.color.input_background')) .backgroundColor($r('app.color.input_background'))
.borderRadius(8) .borderRadius(8)
.padding({ left: 16, right: 16 }) .padding({ left: 16, right: 16 })
.type(InputType.Email)
.onChange((value: string) => { .onChange((value: string) => {
this.username = value; this.email = value;
}) })
} }
.width('100%') .width('100%')
@@ -93,8 +96,8 @@ struct LoginPage {
.backgroundColor($r('app.color.primary_color')) .backgroundColor($r('app.color.primary_color'))
.borderRadius(24) .borderRadius(24)
.margin({ top: 32 }) .margin({ top: 32 })
.enabled(!this.isLoading && this.username.length > 0 && this.password.length > 0) .enabled(!this.isLoading && this.email.length > 0 && this.password.length > 0)
.opacity(this.username.length > 0 && this.password.length > 0 ? 1 : 0.6) .opacity(this.email.length > 0 && this.password.length > 0 ? 1 : 0.6)
.onClick(() => { .onClick(() => {
this.handleLogin(); this.handleLogin();
}) })
@@ -125,20 +128,27 @@ struct LoginPage {
.backgroundColor($r('app.color.card_background')) .backgroundColor($r('app.color.card_background'))
} }
private handleLogin(): void { private async handleLogin(): Promise<void> {
if (this.username.length === 0 || this.password.length === 0) { if (this.email.length === 0 || this.password.length === 0) {
return; return;
} }
this.isLoading = true; this.isLoading = true;
try {
setTimeout(() => { await AuthApi.login({
if (this.username === 'admin' && this.password === 'admin123') { email: this.email,
password: this.password
});
promptAction.showToast({ message: '登录成功', duration: 1500 });
router.replaceUrl({ url: 'pages/HomePage' }); router.replaceUrl({ url: 'pages/HomePage' });
} else { } catch (err) {
const httpErr = err as HttpError;
const message = httpErr.code === 401
? '邮箱或密码错误'
: (httpErr.message || '登录失败,请稍后重试');
promptAction.showToast({ message, duration: 2000 });
} finally {
this.isLoading = false; this.isLoading = false;
promptAction.showToast({ message: '账号或密码错误', duration: 2000 }); }
}
}, 500);
} }
} }

View File

@@ -1,9 +1,13 @@
import { router } from '@kit.ArkUI'; import { promptAction, router } from '@kit.ArkUI';
import { AuthApi } from '../api/AuthApi';
import { HttpError } from '../utils/HttpClient';
@Entry @Entry
@Component @Component
struct RegisterPage { struct RegisterPage {
@State username: string = ''; @State username: string = '';
@State email: string = '';
@State nickname: string = '';
@State password: string = ''; @State password: string = '';
@State confirmPassword: string = ''; @State confirmPassword: string = '';
@State isLoading: boolean = false; @State isLoading: boolean = false;
@@ -37,15 +41,16 @@ struct RegisterPage {
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
// 表单区域 // 表单区域
Scroll() {
Column() { Column() {
// 账号 // 用户名
Column() { Column() {
Text('账号') Text('用户名')
.fontSize(14) .fontSize(14)
.fontColor($r('app.color.text_primary')) .fontColor($r('app.color.text_primary'))
.margin({ bottom: 8 }) .margin({ bottom: 8 })
TextInput({ placeholder: '请输入手机号/邮箱', text: this.username }) TextInput({ placeholder: '请输入用户名', text: this.username })
.height(48) .height(48)
.fontSize(16) .fontSize(16)
.backgroundColor($r('app.color.input_background')) .backgroundColor($r('app.color.input_background'))
@@ -58,6 +63,49 @@ struct RegisterPage {
.width('100%') .width('100%')
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
// 邮箱
Column() {
Text('邮箱')
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.margin({ bottom: 8 })
TextInput({ placeholder: '请输入邮箱', text: this.email })
.height(48)
.fontSize(16)
.backgroundColor($r('app.color.input_background'))
.borderRadius(8)
.padding({ left: 16, right: 16 })
.type(InputType.Email)
.onChange((value: string) => {
this.email = value;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 20 })
// 昵称(可选)
Column() {
Text('昵称(可选)')
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.margin({ bottom: 8 })
TextInput({ placeholder: '请输入昵称', text: this.nickname })
.height(48)
.fontSize(16)
.backgroundColor($r('app.color.input_background'))
.borderRadius(8)
.padding({ left: 16, right: 16 })
.onChange((value: string) => {
this.nickname = value;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 20 })
// 密码 // 密码
Column() { Column() {
Text('密码') Text('密码')
@@ -141,11 +189,15 @@ struct RegisterPage {
.width('100%') .width('100%')
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.margin({ top: 20 }) .margin({ top: 20 })
.padding({ bottom: 24 })
} }
.width('100%') .width('100%')
.padding({ left: 32, right: 32 }) .padding({ left: 32, right: 32 })
.margin({ top: 24 }) .margin({ top: 24 })
} }
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%') .width('100%')
.height('100%') .height('100%')
.backgroundColor($r('app.color.card_background')) .backgroundColor($r('app.color.card_background'))
@@ -153,22 +205,34 @@ struct RegisterPage {
private isFormValid(): boolean { private isFormValid(): boolean {
return this.username.length > 0 return this.username.length > 0
&& this.email.length > 0
&& this.password.length >= 6 && this.password.length >= 6
&& this.password === this.confirmPassword; && this.password === this.confirmPassword;
} }
private handleRegister(): void { private async handleRegister(): Promise<void> {
if (!this.isFormValid()) { if (!this.isFormValid()) {
return; return;
} }
this.isLoading = true; this.isLoading = true;
try {
// 模拟注册请求 await AuthApi.register({
setTimeout(() => { username: this.username,
email: this.email,
password: this.password,
nickname: this.nickname.length > 0 ? this.nickname : undefined
});
promptAction.showToast({ message: '注册成功', duration: 1500 });
router.replaceUrl({ url: 'pages/HomePage' });
} catch (err) {
const httpErr = err as HttpError;
const message = httpErr.code === 409
? '用户名或邮箱已被注册'
: (httpErr.message || '注册失败,请稍后重试');
promptAction.showToast({ message, duration: 2000 });
} finally {
this.isLoading = false; this.isLoading = false;
// 注册成功,返回登录页 }
router.back();
}, 1500);
} }
} }

View File

@@ -0,0 +1,68 @@
/**
* Auth 相关请求/响应类型定义
*/
// 注册请求
export interface RegisterRequest {
username: string;
email: string;
password: string;
nickname?: string;
}
// 登录请求
export interface LoginRequest {
email: string;
password: string;
}
// 刷新令牌请求
export interface RefreshTokenRequest {
refreshToken: string;
}
// 用户基本信息(登录/注册返回)
export interface AuthUser {
id: string;
username: string;
email: string;
}
// 认证响应数据(登录/注册返回的 data 部分)
export interface AuthResponseData {
user: AuthUser;
accessToken: string;
refreshToken: string;
}
// 令牌对(刷新接口返回的 data 部分)
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
// 当前认证用户信息(/auth/me 返回的 data 部分)
export interface AuthMeData {
id: string;
username: string;
email: string;
nickname: string | null;
avatar: string | null;
bio: string | null;
status: string;
createdAt: string;
roles: string[];
}
// 通用消息响应
export interface MessageResponse {
message: string;
}
// 通用包装响应
export interface ApiResponse<T> {
data?: T;
error?: string;
code?: string;
message?: string;
}

View File

@@ -0,0 +1,115 @@
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { AppConfig } from '../config/AppConfig';
import { TokenStore } from './TokenStore';
/**
* HTTP 请求异常
*/
export class HttpError extends Error {
code: number;
errorCode: string;
constructor(message: string, code: number, errorCode: string = '') {
super(message);
this.code = code;
this.errorCode = errorCode;
}
}
/**
* HTTP 客户端,统一处理请求/响应、Token 注入、错误转换
*/
export class HttpClient {
/**
* 发送请求
* @param path 接口路径,如 /api/auth/login
* @param method 请求方法
* @param body 请求体(对象会自动序列化为 JSON
* @param needAuth 是否需要 Bearer Token默认 false
* @returns 解析后的 data 字段
*/
static async request<T>(
path: string,
method: http.RequestMethod,
body?: object,
needAuth: boolean = false
): Promise<T> {
const httpRequest = http.createHttp();
try {
const url = AppConfig.BASE_URL + path;
const header: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
if (needAuth) {
const token = await TokenStore.getAccessToken();
if (token.length > 0) {
header['Authorization'] = 'Bearer ' + token;
}
}
const options: http.HttpRequestOptions = {
method: method,
header: header,
connectTimeout: AppConfig.REQUEST_TIMEOUT,
readTimeout: AppConfig.REQUEST_TIMEOUT,
expectDataType: http.HttpDataType.STRING
};
if (body !== undefined && body !== null) {
options.extraData = JSON.stringify(body);
}
const response: http.HttpResponse = await httpRequest.request(url, options);
const status = response.responseCode;
const text = response.result as string;
let parsed: Record<string, ESObject> = {};
if (text && text.length > 0) {
try {
parsed = JSON.parse(text) as Record<string, ESObject>;
} catch (e) {
throw new HttpError('响应解析失败: ' + text, status);
}
}
if (status >= 200 && status < 300) {
// 成功响应统一格式 { data: ... }
if (parsed['data'] !== undefined) {
return parsed['data'] as T;
}
return parsed as T;
}
// 错误响应
const errorMessage = (parsed['error'] as string) || (parsed['message'] as string) || '请求失败';
const errorCode = (parsed['code'] as string) || '';
throw new HttpError(errorMessage, status, errorCode);
} catch (err) {
if (err instanceof HttpError) {
throw err as HttpError;
}
const businessErr = err as BusinessError;
throw new HttpError(
businessErr.message || '网络请求异常',
businessErr.code || -1
);
} finally {
httpRequest.destroy();
}
}
static get<T>(path: string, needAuth: boolean = false): Promise<T> {
return HttpClient.request<T>(path, http.RequestMethod.GET, undefined, needAuth);
}
static post<T>(path: string, body?: object, needAuth: boolean = false): Promise<T> {
return HttpClient.request<T>(path, http.RequestMethod.POST, body, needAuth);
}
static delete<T>(path: string, needAuth: boolean = false): Promise<T> {
return HttpClient.request<T>(path, http.RequestMethod.DELETE, undefined, needAuth);
}
}

View File

@@ -0,0 +1,74 @@
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
/**
* Token 持久化存储工具,基于 @ohos.data.preferences 实现
*/
export class TokenStore {
private static readonly PREF_NAME: string = 'auth_pref';
private static readonly KEY_ACCESS_TOKEN: string = 'access_token';
private static readonly KEY_REFRESH_TOKEN: string = 'refresh_token';
private static readonly KEY_USER_ID: string = 'user_id';
private static prefs: preferences.Preferences | null = null;
// 内存中的 Token 缓存,用于同步获取
private static accessTokenCache: string = '';
static async init(context: common.UIAbilityContext | Context): Promise<void> {
if (TokenStore.prefs) {
return;
}
TokenStore.prefs = await preferences.getPreferences(context, TokenStore.PREF_NAME);
TokenStore.accessTokenCache = await TokenStore.prefs.get(TokenStore.KEY_ACCESS_TOKEN, '') as string;
}
static async saveTokens(accessToken: string, refreshToken: string, userId?: string): Promise<void> {
if (!TokenStore.prefs) {
throw new Error('TokenStore 未初始化,请先调用 TokenStore.init(context)');
}
await TokenStore.prefs.put(TokenStore.KEY_ACCESS_TOKEN, accessToken);
await TokenStore.prefs.put(TokenStore.KEY_REFRESH_TOKEN, refreshToken);
if (userId) {
await TokenStore.prefs.put(TokenStore.KEY_USER_ID, userId);
}
await TokenStore.prefs.flush();
TokenStore.accessTokenCache = accessToken;
}
static async getAccessToken(): Promise<string> {
if (!TokenStore.prefs) {
return '';
}
const value = await TokenStore.prefs.get(TokenStore.KEY_ACCESS_TOKEN, '') as string;
TokenStore.accessTokenCache = value;
return value;
}
static async getRefreshToken(): Promise<string> {
if (!TokenStore.prefs) {
return '';
}
return await TokenStore.prefs.get(TokenStore.KEY_REFRESH_TOKEN, '') as string;
}
static getAccessTokenSync(): string {
return TokenStore.accessTokenCache;
}
static async clear(): Promise<void> {
if (!TokenStore.prefs) {
return;
}
await TokenStore.prefs.delete(TokenStore.KEY_ACCESS_TOKEN);
await TokenStore.prefs.delete(TokenStore.KEY_REFRESH_TOKEN);
await TokenStore.prefs.delete(TokenStore.KEY_USER_ID);
await TokenStore.prefs.flush();
TokenStore.accessTokenCache = '';
}
static async isLoggedIn(): Promise<boolean> {
const token = await TokenStore.getAccessToken();
return token.length > 0;
}
}

View File

@@ -11,6 +11,14 @@
"deliveryWithInstall": true, "deliveryWithInstall": true,
"installationFree": false, "installationFree": false,
"pages": "$profile:main_pages", "pages": "$profile:main_pages",
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
},
{
"name": "ohos.permission.GET_NETWORK_INFO"
}
],
"abilities": [ "abilities": [
{ {
"name": "EntryAbility", "name": "EntryAbility",