Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eba067088e | |||
| ab6a2ffb08 | |||
| 3be0bc68a0 | |||
| 1883d9ba06 | |||
| 2b883b5468 | |||
| 5e0c2f2654 | |||
| 550b6cb73c | |||
| 6c5e76d32e | |||
| b1e85b68df | |||
| 21a66be8a6 | |||
| 4a848247fa | |||
| af38a296c5 | |||
| f021ff2a74 | |||
| 0fc133798b | |||
| 61117e50bc | |||
| ad902105f5 | |||
| f83c96bb77 | |||
| 6930d4543f | |||
| 7b2f35ad3d | |||
| 5256bd0d8b | |||
| 33270dd026 |
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
import axios from "axios"
|
||||
import apiClient from "./client"
|
||||
|
||||
// 类型定义
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 注册
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
|
||||
// 请求密码重置
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 验证邮箱
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ========== 微信登录 ========== */
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
|
||||
// 获取微信授权链接
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 微信回调登录
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 绑定联系方式
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import apiClient from "../client"
|
||||
import type { SendVerificationCodeRequest, BindContactRequest, BindContactResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定联系方式
|
||||
*/
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import apiClient from "../client"
|
||||
import type { User, UserResponse } from "./types"
|
||||
import { normalizeUser } from "./user"
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*/
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
* 保持向后兼容,从子模块 re-export
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
User,
|
||||
UserResponse,
|
||||
WechatAuthUrlResponse,
|
||||
WechatCallbackResponse,
|
||||
SendVerificationCodeRequest,
|
||||
BindContactRequest,
|
||||
BindContactResponse,
|
||||
} from "./types"
|
||||
|
||||
// 用户工具函数
|
||||
export { normalizeUser } from "./user"
|
||||
|
||||
// 登录/注册/登出/刷新
|
||||
export { login, refreshAccessToken, register, logout } from "./login"
|
||||
|
||||
// 当前用户
|
||||
export { getCurrentUser } from "./currentUser"
|
||||
|
||||
// 密码重置
|
||||
export { requestPasswordReset, resetPassword } from "./password"
|
||||
|
||||
// 邮箱验证
|
||||
export { verifyEmail } from "./email"
|
||||
|
||||
// 微信登录
|
||||
export { getWechatAuthUrl, wechatCallback } from "./wechat"
|
||||
|
||||
// 联系方式
|
||||
export { sendVerificationCode, bindContact } from "./contact"
|
||||
@@ -0,0 +1,37 @@
|
||||
import axios from "axios"
|
||||
import apiClient from "../client"
|
||||
import type { LoginRequest, LoginResponse, RegisterRequest } from "./types"
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
*/
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/**
|
||||
* 请求密码重置
|
||||
*/
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 认证相关类型定义
|
||||
*/
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { User, UserResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 规范化用户数据,兼容不同后端返回格式
|
||||
*/
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import apiClient from "../client"
|
||||
import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 获取微信授权链接
|
||||
*/
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回调登录
|
||||
*/
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Form, message } from "antd"
|
||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
||||
import { useCountdown } from "./useCountdown"
|
||||
|
||||
type ContactType = "email" | "phone"
|
||||
|
||||
interface UseBindContactFormOptions {
|
||||
open: boolean
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定联系方式表单 Hook
|
||||
* 封装表单状态、验证码发送、提交绑定等业务逻辑
|
||||
*/
|
||||
export const useBindContactForm = ({ open, onSuccess }: UseBindContactFormOptions) => {
|
||||
const [activeTab, setActiveTab] = useState<ContactType>("email")
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeLoading, setCodeLoading] = useState(false)
|
||||
const { countdown, isRunning, start: startCountdown, reset: resetCountdown } = useCountdown(60)
|
||||
|
||||
/* ── 打开时重置表单 ── */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields()
|
||||
resetCountdown()
|
||||
}
|
||||
}, [open, form, resetCountdown])
|
||||
|
||||
/* ── 发送验证码 ── */
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const fieldName = activeTab === "email" ? "email" : "phone"
|
||||
const value = form.getFieldValue(fieldName)
|
||||
if (!value) {
|
||||
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
||||
return
|
||||
}
|
||||
setCodeLoading(true)
|
||||
await sendVerificationCode({
|
||||
target: activeTab,
|
||||
value,
|
||||
purpose: "bind",
|
||||
})
|
||||
message.success("验证码已发送")
|
||||
startCountdown()
|
||||
} catch {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setCodeLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 提交绑定 ── */
|
||||
const handleSubmit = async () => {
|
||||
// 1. 表单验证(失败时 Ant Design Form 会自动展示错误信息)
|
||||
let values
|
||||
try {
|
||||
values = await form.validateFields()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 调用绑定接口
|
||||
setLoading(true)
|
||||
try {
|
||||
const payload =
|
||||
activeTab === "email"
|
||||
? { email: values.email, email_code: values.email_code }
|
||||
: { phone: values.phone, phone_code: values.phone_code }
|
||||
|
||||
const result = await bindContact(payload)
|
||||
|
||||
message.success("绑定成功")
|
||||
onSuccess?.(result.user)
|
||||
} catch {
|
||||
// API 错误由全局拦截器统一处理提示
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
form,
|
||||
loading,
|
||||
codeLoading,
|
||||
countdown,
|
||||
codeDisabled: isRunning,
|
||||
handleSendCode,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* 倒计时 Hook
|
||||
* 用于验证码发送等场景的倒计时控制
|
||||
*/
|
||||
export const useCountdown = (initialSeconds = 60) => {
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1)
|
||||
}, 1000)
|
||||
} else if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
const start = useCallback(() => {
|
||||
setCountdown(initialSeconds)
|
||||
}, [initialSeconds])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCountdown(0)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
countdown,
|
||||
isRunning: countdown > 0,
|
||||
start,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
+20
-83
@@ -1,84 +1,20 @@
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { Modal, Tabs, Form, Input, Button, message } from "antd"
|
||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
||||
|
||||
interface BindContactModalProps {
|
||||
open: boolean
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
import React from "react"
|
||||
import { Modal, Tabs, Form, Input, Button } from "antd"
|
||||
import { useBindContactForm } from "./hooks/useBindContactForm"
|
||||
import type { BindContactModalProps } from "./types"
|
||||
|
||||
const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, onCancel }) => {
|
||||
const [activeTab, setActiveTab] = useState<"email" | "phone">("email")
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeLoading, setCodeLoading] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1)
|
||||
}, 1000)
|
||||
} else if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields()
|
||||
setCountdown(0)
|
||||
}
|
||||
}, [open, form])
|
||||
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const value = form.getFieldValue(activeTab === "email" ? "email" : "phone")
|
||||
if (!value) {
|
||||
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
||||
return
|
||||
}
|
||||
setCodeLoading(true)
|
||||
await sendVerificationCode({
|
||||
target: activeTab,
|
||||
value,
|
||||
purpose: "bind",
|
||||
})
|
||||
message.success("验证码已发送")
|
||||
setCountdown(60)
|
||||
} catch (error) {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setCodeLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
|
||||
const payload =
|
||||
activeTab === "email"
|
||||
? { email: values.email, email_code: values.code }
|
||||
: { phone: values.phone, phone_code: values.code }
|
||||
|
||||
const result = await bindContact(payload)
|
||||
|
||||
message.success("绑定成功")
|
||||
onSuccess?.(result.user)
|
||||
} catch (error) {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
form,
|
||||
loading,
|
||||
codeLoading,
|
||||
countdown,
|
||||
codeDisabled,
|
||||
handleSendCode,
|
||||
handleSubmit,
|
||||
} = useBindContactForm({ open, onSuccess, onCancel })
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -111,7 +47,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
<Input placeholder="请输入邮箱地址" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
name="email_code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: "请输入验证码" }]}
|
||||
>
|
||||
@@ -121,7 +57,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
loading={codeLoading}
|
||||
disabled={countdown > 0}
|
||||
disabled={codeDisabled}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
@@ -146,7 +82,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
<Input placeholder="请输入手机号" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
name="phone_code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: "请输入验证码" }]}
|
||||
>
|
||||
@@ -156,7 +92,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
loading={codeLoading}
|
||||
disabled={countdown > 0}
|
||||
disabled={codeDisabled}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
@@ -178,3 +114,4 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
}
|
||||
|
||||
export default BindContactModal
|
||||
export type { BindContactModalProps } from "./types"
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { BindContactResponse } from "@/api/auth"
|
||||
|
||||
/** BindContactModal 组件属性 */
|
||||
export interface BindContactModalProps {
|
||||
/** 是否打开 */
|
||||
open: boolean
|
||||
/** 绑定成功回调 */
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
/** 取消回调 */
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/** 联系方式类型 */
|
||||
export type ContactType = "email" | "phone"
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
import { TEXT_PRESET_STYLES } from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface StickerPreviewProps {
|
||||
sticker: StickerItem
|
||||
}
|
||||
|
||||
export const StickerPreview: React.FC<StickerPreviewProps> = ({ sticker }) => (
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.height}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
Regular → Executable
+5
-75
@@ -2,11 +2,9 @@
|
||||
* 选中贴纸的属性编辑器
|
||||
*/
|
||||
import React from "react"
|
||||
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
|
||||
import {
|
||||
TEXT_PRESET_STYLES,
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "@/pages/editing-planner/constants/sticker"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
import { StickerPreview } from "./StickerPreview"
|
||||
import { TextStickerPropsEditor } from "./TextStickerPropsEditor"
|
||||
|
||||
interface StickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
@@ -121,78 +119,10 @@ const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 文字贴纸特有属性 */}
|
||||
{sticker.type === "text" && (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) =>
|
||||
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
|
||||
}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TextStickerPropsEditor sticker={sticker} onUpdate={onUpdate} />
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.width}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StickerPreview sticker={sticker} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import React from "react"
|
||||
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
|
||||
import { TEXT_STICKER_PRESET_LABELS } from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface TextStickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
onUpdate: (id: string, partial: Partial<StickerItem>) => void
|
||||
}
|
||||
|
||||
export const TextStickerPropsEditor: React.FC<TextStickerPropsEditorProps> = ({
|
||||
sticker,
|
||||
onUpdate,
|
||||
}) => {
|
||||
if (sticker.type !== "text") return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) =>
|
||||
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
|
||||
}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Auth API 测试
|
||||
* 对应 api/auth/ 目录化后的模块
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
normalizeUser,
|
||||
@@ -10,7 +14,6 @@ import {
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
} from "@/api/auth"
|
||||
|
||||
const mockPost = vi.fn()
|
||||
const mockGet = vi.fn()
|
||||
const mockAxiosPost = vi.fn()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import BindContactModal from "@/components/auth/BindContactModal"
|
||||
import type { BindContactModalProps, ContactType } from "@/components/auth/BindContactModal/types"
|
||||
import { useBindContactForm } from "@/components/auth/BindContactModal/hooks/useBindContactForm"
|
||||
import { useCountdown } from "@/components/auth/BindContactModal/hooks/useCountdown"
|
||||
|
||||
describe("BindContactModal", () => {
|
||||
it("主组件可正常导入", () => {
|
||||
expect(BindContactModal).toBeDefined()
|
||||
})
|
||||
|
||||
it("Hook 可正常导入", () => {
|
||||
expect(useBindContactForm).toBeDefined()
|
||||
expect(useCountdown).toBeDefined()
|
||||
})
|
||||
|
||||
it("类型导出正常", () => {
|
||||
const props: BindContactModalProps = { open: false }
|
||||
expect(props.open).toBe(false)
|
||||
|
||||
const contactType: ContactType = "email"
|
||||
expect(contactType).toBe("email")
|
||||
})
|
||||
})
|
||||
Regular → Executable
+2
@@ -61,6 +61,8 @@ import "@/pages/editing-planner/components/pip-config/LayerConfig"
|
||||
import "@/pages/editing-planner/components/sticker/StickerLibrary"
|
||||
import "@/pages/editing-planner/components/sticker/StickerList"
|
||||
import "@/pages/editing-planner/components/sticker/StickerPropsEditor"
|
||||
import "@/pages/editing-planner/components/sticker/StickerPreview"
|
||||
import "@/pages/editing-planner/components/sticker/TextStickerPropsEditor"
|
||||
import "@/pages/editing-planner/components/filter/FilterPresetGrid"
|
||||
import "@/pages/editing-planner/components/filter/FilterManualAdjust"
|
||||
import "@/pages/editing-planner/components/intro-outro/IntroOutroBlock"
|
||||
|
||||
Executable
+281
@@ -0,0 +1,281 @@
|
||||
"""edit_template 剪辑模板实体单测."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from domain.editing_mode import EditingMode
|
||||
|
||||
# ── EditTemplateStatus 枚举 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateStatus:
|
||||
"""EditTemplateStatus 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert EditTemplateStatus.ACTIVE.value == "active"
|
||||
assert EditTemplateStatus.INACTIVE.value == "inactive"
|
||||
|
||||
def test_is_str_enum(self):
|
||||
assert isinstance(EditTemplateStatus.ACTIVE, str)
|
||||
assert EditTemplateStatus.ACTIVE == "active"
|
||||
|
||||
def test_from_string(self):
|
||||
assert EditTemplateStatus("active") == EditTemplateStatus.ACTIVE
|
||||
assert EditTemplateStatus("inactive") == EditTemplateStatus.INACTIVE
|
||||
|
||||
def test_invalid_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
EditTemplateStatus("deleted")
|
||||
|
||||
|
||||
# ── EditTemplate.create 工厂方法 ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateCreate:
|
||||
"""EditTemplate.create 工厂方法"""
|
||||
|
||||
def test_minimal_create(self):
|
||||
t = EditTemplate.create("测试模板")
|
||||
assert t.id is not None
|
||||
assert len(t.id) == 32 # uuid4 hex
|
||||
assert t.name == "测试模板"
|
||||
assert t.description == ""
|
||||
assert t.template_type == "default"
|
||||
assert t.editing_mode == "one_take"
|
||||
assert t.config == {}
|
||||
assert t.preview_url == ""
|
||||
assert t.sort_weight == 0
|
||||
assert t.status == EditTemplateStatus.ACTIVE
|
||||
assert t.version == 1
|
||||
|
||||
def test_unique_ids(self):
|
||||
t1 = EditTemplate.create("模板A")
|
||||
t2 = EditTemplate.create("模板B")
|
||||
assert t1.id != t2.id
|
||||
|
||||
def test_custom_fields(self):
|
||||
t = EditTemplate.create(
|
||||
"自定义模板",
|
||||
description="这是一个自定义模板",
|
||||
template_type="story",
|
||||
editing_mode="one_take",
|
||||
config={"key": "value"},
|
||||
preview_url="https://example.com/preview.mp4",
|
||||
sort_weight=100,
|
||||
status=EditTemplateStatus.INACTIVE,
|
||||
version=2,
|
||||
)
|
||||
assert t.name == "自定义模板"
|
||||
assert t.description == "这是一个自定义模板"
|
||||
assert t.template_type == "story"
|
||||
assert t.editing_mode == "one_take"
|
||||
assert t.config == {"key": "value"}
|
||||
assert t.preview_url == "https://example.com/preview.mp4"
|
||||
assert t.sort_weight == 100
|
||||
assert t.status == EditTemplateStatus.INACTIVE
|
||||
assert t.version == 2
|
||||
|
||||
def test_name_stripped(self):
|
||||
t = EditTemplate.create(" 带空格的模板 ")
|
||||
assert t.name == "带空格的模板"
|
||||
|
||||
def test_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="名称"):
|
||||
EditTemplate.create("")
|
||||
|
||||
def test_whitespace_only_name_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
EditTemplate.create(" ")
|
||||
|
||||
def test_invalid_editing_mode_raises(self):
|
||||
with pytest.raises(ValueError, match="editing_mode"):
|
||||
EditTemplate.create("测试", editing_mode="invalid_mode")
|
||||
|
||||
def test_empty_editing_mode_falls_back_to_default(self):
|
||||
t = EditTemplate.create("测试", editing_mode="")
|
||||
assert t.editing_mode == "one_take"
|
||||
|
||||
def test_whitespace_editing_mode_falls_back(self):
|
||||
t = EditTemplate.create("测试", editing_mode=" ")
|
||||
assert t.editing_mode == "one_take"
|
||||
|
||||
def test_editing_mode_stripped(self):
|
||||
t = EditTemplate.create("测试", editing_mode=" one_take ")
|
||||
assert t.editing_mode == "one_take"
|
||||
|
||||
def test_description_stripped(self):
|
||||
t = EditTemplate.create("测试", description=" 描述 ")
|
||||
assert t.description == "描述"
|
||||
|
||||
def test_template_type_stripped(self):
|
||||
t = EditTemplate.create("测试", template_type=" vlog ")
|
||||
assert t.template_type == "vlog"
|
||||
|
||||
def test_empty_template_type_falls_back(self):
|
||||
t = EditTemplate.create("测试", template_type="")
|
||||
assert t.template_type == "default"
|
||||
|
||||
def test_none_config_becomes_empty_dict(self):
|
||||
t = EditTemplate.create("测试", config=None)
|
||||
assert t.config == {}
|
||||
assert isinstance(t.config, dict)
|
||||
|
||||
def test_preview_url_stripped(self):
|
||||
t = EditTemplate.create("测试", preview_url=" https://x.com/a.mp4 ")
|
||||
assert t.preview_url == "https://x.com/a.mp4"
|
||||
|
||||
def test_timestamps_are_utc(self):
|
||||
t = EditTemplate.create("测试")
|
||||
assert t.created_at.tzinfo is not None
|
||||
assert t.updated_at.tzinfo is not None
|
||||
|
||||
def test_created_at_equals_updated_at_on_create(self):
|
||||
t = EditTemplate.create("测试")
|
||||
# 创建时两个时间应该非常接近
|
||||
diff = abs((t.updated_at - t.created_at).total_seconds())
|
||||
assert diff < 1.0
|
||||
|
||||
|
||||
# ── 状态操作 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateStatusOperations:
|
||||
"""EditTemplate 状态操作"""
|
||||
|
||||
def test_activate_sets_active(self):
|
||||
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
|
||||
t.activate()
|
||||
assert t.status == EditTemplateStatus.ACTIVE
|
||||
assert t.is_active is True
|
||||
|
||||
def test_deactivate_sets_inactive(self):
|
||||
t = EditTemplate.create("测试")
|
||||
t.deactivate()
|
||||
assert t.status == EditTemplateStatus.INACTIVE
|
||||
assert t.is_active is False
|
||||
|
||||
def test_is_active_true(self):
|
||||
t = EditTemplate.create("测试")
|
||||
assert t.is_active is True
|
||||
|
||||
def test_is_active_false(self):
|
||||
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
|
||||
assert t.is_active is False
|
||||
|
||||
def test_activate_updates_updated_at(self):
|
||||
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
|
||||
old_updated = t.updated_at
|
||||
t.activate()
|
||||
assert t.updated_at >= old_updated
|
||||
|
||||
def test_deactivate_updates_updated_at(self):
|
||||
t = EditTemplate.create("测试")
|
||||
old_updated = t.updated_at
|
||||
t.deactivate()
|
||||
assert t.updated_at >= old_updated
|
||||
|
||||
|
||||
# ── 版本操作 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateVersion:
|
||||
"""EditTemplate 版本操作"""
|
||||
|
||||
def test_bump_version_increments(self):
|
||||
t = EditTemplate.create("测试")
|
||||
assert t.version == 1
|
||||
t.bump_version()
|
||||
assert t.version == 2
|
||||
|
||||
def test_bump_version_multiple(self):
|
||||
t = EditTemplate.create("测试", version=5)
|
||||
t.bump_version()
|
||||
t.bump_version()
|
||||
t.bump_version()
|
||||
assert t.version == 8
|
||||
|
||||
def test_bump_version_updates_updated_at(self):
|
||||
t = EditTemplate.create("测试")
|
||||
old_updated = t.updated_at
|
||||
t.bump_version()
|
||||
assert t.updated_at >= old_updated
|
||||
|
||||
|
||||
# ── dataclass 基础特性 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateBasics:
|
||||
"""EditTemplate 基础特性"""
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
t = EditTemplate.create("测试")
|
||||
with pytest.raises(AttributeError):
|
||||
t.nonexistent_field = "value"
|
||||
|
||||
def test_direct_construction_minimal(self):
|
||||
# 最小构造:仅必填字段 + 状态,其余走默认值
|
||||
t = EditTemplate(
|
||||
id="custom_id",
|
||||
name="直接构造",
|
||||
status=EditTemplateStatus.ACTIVE,
|
||||
)
|
||||
assert t.id == "custom_id"
|
||||
assert t.name == "直接构造"
|
||||
assert t.status == EditTemplateStatus.ACTIVE
|
||||
# 默认值检查
|
||||
assert t.description == ""
|
||||
assert t.config == {}
|
||||
assert t.version == 1
|
||||
assert t.editing_mode == EditingMode.ONE_TAKE.value
|
||||
assert isinstance(t.created_at, datetime)
|
||||
assert isinstance(t.updated_at, datetime)
|
||||
|
||||
def test_direct_construction_full(self):
|
||||
# 完整构造:所有字段都传
|
||||
now = datetime(2025, 1, 1, tzinfo=timezone.utc)
|
||||
t = EditTemplate(
|
||||
id="full_id",
|
||||
name="完整构造",
|
||||
description="测试描述",
|
||||
template_type="custom",
|
||||
editing_mode=EditingMode.PIP.value,
|
||||
config={"key": "value"},
|
||||
preview_url="https://example.com/preview.jpg",
|
||||
sort_weight=100,
|
||||
status=EditTemplateStatus.INACTIVE,
|
||||
version=3,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
assert t.id == "full_id"
|
||||
assert t.name == "完整构造"
|
||||
assert t.description == "测试描述"
|
||||
assert t.template_type == "custom"
|
||||
assert t.editing_mode == EditingMode.PIP.value
|
||||
assert t.config == {"key": "value"}
|
||||
assert t.preview_url == "https://example.com/preview.jpg"
|
||||
assert t.sort_weight == 100
|
||||
assert t.status == EditTemplateStatus.INACTIVE
|
||||
assert t.version == 3
|
||||
assert t.created_at == now
|
||||
assert t.updated_at == now
|
||||
|
||||
def test_config_is_independent(self):
|
||||
# 不同实例的 config 应该是独立的 dict
|
||||
t1 = EditTemplate.create("模板1")
|
||||
t2 = EditTemplate.create("模板2")
|
||||
t1.config["key"] = "value"
|
||||
assert "key" not in t2.config
|
||||
|
||||
def test_equality(self):
|
||||
# 两个不同实例即使内容相同也不等(id不同)
|
||||
t1 = EditTemplate.create("同名模板")
|
||||
t2 = EditTemplate.create("同名模板")
|
||||
assert t1 != t2
|
||||
|
||||
def test_same_id_equal(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
t1 = EditTemplate(id="same", name="同名", created_at=now, updated_at=now)
|
||||
t2 = EditTemplate(id="same", name="同名", created_at=now, updated_at=now)
|
||||
assert t1 == t2
|
||||
Reference in New Issue
Block a user