4 Commits

Author SHA1 Message Date
fe093ab8dc thay doi readme 2025-10-09 07:09:25 +07:00
b74ef6a892 update readme, add link video 2025-10-09 06:59:16 +07:00
c17f0d3d9c category management, upload cloudinary 2025-10-08 08:54:30 +07:00
21e77456c9 toi uu authen, add loading global 2025-10-08 07:45:39 +07:00
20 changed files with 432 additions and 29 deletions

6
.env
View File

@@ -1 +1,7 @@
VITE_SV_HOST="http://localhost:3000"
VITE_JWT_TOKEN="phuocntbasdasasdasd"
VITE_CLOUDINARY_UPLOAD_PRESET="testpreset"
VITE_CLOUDINARY_CLOUD_NAME="dusw32tsq"
VITE_CLOUDINARY_API_SECRET="NeUuUfbjlZSSyq0Zf390LECRdcI"
VITE_CLOUDINARY_API_KEY="114544946391646"

View File

@@ -1,3 +1,4 @@
(Video)[https://rikkeieducation.sg.larksuite.com/minutes/obsgcl8da4w9x316w6jae174]
## Rikkei Store
## Bán máy tính & điện thoại
@@ -14,3 +15,9 @@
- Đăng ký / đăng nhập X
- Layout người dùng X
## Cloudinary
npm i cloudinary
## Update test

44
db.json
View File

@@ -33,5 +33,49 @@
"status": "ACTIVE",
"banReason": ""
}
],
"categories": [
{
"id": "aa",
"title": "danh mục 1",
"iconUrl": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTLx0GLJkUHO42P1w4qBVzajal81_-N7oP-6w&s",
"isActive": true
},
{
"id": "bb",
"title": "danh mục 2",
"iconUrl": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTLx0GLJkUHO42P1w4qBVzajal81_-N7oP-6w&s",
"isActive": false
},
{
"id": "cafa",
"title": "Danh mục 3",
"iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759887991/gtsjxafutf5nu140skar.png",
"isActive": true
},
{
"id": "20d1",
"title": "Điện Thoại",
"iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759888235/mehgtcrul6kmfp9oklko.png",
"isActive": true
},
{
"id": "545a",
"title": "Mèo",
"iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759888284/ewzw5idmjosvqzk4c2cn.png",
"isActive": true
},
{
"id": "37f1",
"title": "Mèo 12121",
"iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759888337/whofwib0nrquknf4v5b6.png",
"isActive": true
},
{
"id": "2d29",
"title": "Mèo 12121",
"iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759888364/oululvqiwuwpn8se3dww.png",
"isActive": true
}
]
}

41
package-lock.json generated
View File

@@ -13,6 +13,8 @@
"@tailwindcss/vite": "^4.1.13",
"antd": "^5.27.4",
"axios": "^1.12.2",
"cloudinary": "^2.7.0",
"jose": "^6.1.0",
"json-server": "^1.0.0-beta.3",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@@ -3176,6 +3178,19 @@
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/cloudinary": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.7.0.tgz",
"integrity": "sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21",
"q": "^1.5.1"
},
"engines": {
"node": ">=9"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4157,6 +4172,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4539,6 +4563,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4912,6 +4942,17 @@
"node": ">=6"
}
},
"node_modules/q": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
"integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==",
"deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)",
"license": "MIT",
"engines": {
"node": ">=0.6.0",
"teleport": ">=0.2.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@@ -16,6 +16,8 @@
"@tailwindcss/vite": "^4.1.13",
"antd": "^5.27.4",
"axios": "^1.12.2",
"cloudinary": "^2.7.0",
"jose": "^6.1.0",
"json-server": "^1.0.0-beta.3",
"react": "^19.1.1",
"react-dom": "^19.1.1",

View File

@@ -1,9 +1,18 @@
import { createContext } from 'react'
import RouterSetup from './RouterSetup'
import { useSelector } from 'react-redux'
import type { StoreType } from './stores'
import Loading from './components/Loading'
export default function App() {
const userStore = useSelector((store: StoreType) => store.user)
return (
<RouterSetup />
<>
{
userStore.loading ? <Loading />
: <RouterSetup />
}
</>
)
}

View File

@@ -5,6 +5,7 @@ import Admin from './pages/admin/Admin'
import ProtectedAdmin from './pages/admin/auth/ProtectedAdmin'
import Auth from './pages/home/auth/Auth'
import UserManagement from './pages/admin/User/UserManagement'
import CategoryManagement from './pages/admin/Category/CategoryManagement'
export default function RouterSetup() {
return <Routes>
@@ -16,7 +17,7 @@ export default function RouterSetup() {
</ProtectedAdmin>
}>
<Route path='user' element={<UserManagement />}></Route>
<Route path='category' element={<CategoryManagement />}></Route>
</Route>
</Routes>
}

View File

@@ -0,0 +1,20 @@
import axios from "axios"
export const CategoryApi = {
getAll: async () => {
let resultRes = await axios.get(`${import.meta.env.VITE_SV_HOST}/categories`)
return resultRes.data
},
create: async (data: {
title: string;
iconUrl: string
}) => {
let result = await axios.post(`${import.meta.env.VITE_SV_HOST}/categories`, {
...data,
isActive: true
})
return result.data
}
}

View File

@@ -0,0 +1,12 @@
import axios from "axios"
export const CloudianryApi = {
upload: async (file: File) => {
let formData = new FormData()
formData.append("file", file)
formData.append("upload_preset", import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET)
formData.append("cloud_name", import.meta.env.VITE_CLOUDINARY_CLOUD_NAME)
let result = await axios.post(`https://api.cloudinary.com/v1_1/${import.meta.env.VITE_CLOUDINARY_CLOUD_NAME}/image/upload`, formData)
return result.data.secure_url
}
}

View File

@@ -2,6 +2,7 @@ import axios from "axios"
import type { User } from "../../types/user.type"
import { message } from "antd"
import { ApiUtil } from "../../utils/api.util"
import * as jose from 'jose'
export interface UserSignInDTO {
emailOrUserName: string
@@ -16,10 +17,7 @@ export interface UserFindAllDTO {
}
export const UserApi = {
signIn: async (data: UserSignInDTO): Promise<{
message: string
data: any
}> => {
signIn: async (data: UserSignInDTO) => {
let userData = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?email=${data.emailOrUserName}`)
if (userData.data.length == 0) {
userData = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?userName=${data.emailOrUserName}`)
@@ -36,10 +34,7 @@ export const UserApi = {
data: null
})
}
return {
message: "Đăng nhập thành công!",
data: userData.data[0] as User
}
return createToken(userData.data[0].id)
}
},
signUp: async (data: User) => {
@@ -106,5 +101,59 @@ export const UserApi = {
findAll: async (query?: UserFindAllDTO) => {
let result = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?` + ApiUtil.writeQuery(query))
return result.data
},
me: async (token: string) => {
let tokenData = await decodeToken(token)
if (!tokenData) {
throw ({
message: "Token không chính xác!"
})
}
let { userId } = tokenData;
let getUserByIdRes = await axios.get(`${import.meta.env.VITE_SV_HOST}/users/${userId}`)
if (!getUserByIdRes.data) {
throw ({
message: "Lỗi lấy dữ liệu"
})
}
let data = await new Promise((resolve) => {
setTimeout(() => {
resolve(getUserByIdRes.data)
}, 1000)
})
return data
}
}
async function createToken(userId: string) {
const secret = new TextEncoder().encode(import.meta.env.VITE_JWT_TOKEN);
const token = await new jose.SignJWT({ userId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('2h')
.sign(secret);
return token
}
async function decodeToken(token: string) {
try {
const secret = new TextEncoder().encode(import.meta.env.VITE_JWT_TOKEN);
const { payload } = await jose.jwtVerify(token, secret, {
algorithms: ['HS256'],
});
return payload;
} catch (error) {
console.error('Token không hợp lệ hoặc đã hết hạn:', error);
return null;
}
}

View File

@@ -1,5 +1,9 @@
import { CategoryApi } from "./core/category.api";
import { CloudianryApi } from "./core/cloudinary.api";
import { UserApi } from "./core/user.api";
export const Apis = {
user: UserApi
user: UserApi,
category: CategoryApi,
cloundInary: CloudianryApi
}

View File

@@ -0,0 +1,22 @@
import React from 'react'
export default function Loading() {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-white/70 backdrop-blur-sm">
<div className="relative mb-4">
<div className="h-14 w-14 rounded-full bg-gray-200 animate-pulse" />
<svg
className="absolute -right-2 -bottom-2 h-7 w-7 animate-spin"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="text-gray-300 opacity-70" />
<path d="M22 12a10 10 0 00-10-10" stroke="currentColor" strokeWidth="3" className="text-indigo-500" strokeLinecap="round" />
</svg>
</div>
<div className="text-gray-600 font-medium">Đang tải dữ liệu xác thực...</div>
</div>
)
}

View File

@@ -0,0 +1,163 @@
import React, { useEffect, useState, type FormEvent } from 'react'
import { Apis } from '../../../apis'
import type { Category } from '../../../types/category.type'
import { Button, Form, Input, Modal, Space, Table, Upload } from 'antd'
import axios from 'axios'
export default function CategoryManagement() {
/* State Modal */
const [createNewModalState, setCreateNewModalState] = useState(false)
const [categories, setCategories] = useState<Category[]>([])
const [loadingState, setLoadingState] = useState(false)
async function getCategories() {
try {
let result = await Apis.category.getAll()
setCategories(result)
} catch (err) {
/* err */
alert("tạch!")
}
}
useEffect(() => {
getCategories()
}, [])
const columns = [
{
title: 'Số Thứ Tự',
key: 'index',
render: (_: any, __: any, index: number) => index + 1
},
{
title: 'Tiêu Đề',
dataIndex: 'title',
key: 'title',
},
{
title: 'Icon',
dataIndex: 'iconUrl',
key: 'iconUrl',
render: (_: any, record: Category) => (
<Space size="middle">
<img src={record.iconUrl} style={{
width: "50px",
height: "50px",
borderRadius: "50"
}} />
</Space>
)
},
{
title: 'Trạng Thái',
dataIndex: 'isActive',
key: 'isActive',
render: (_: any, record: Category) => (
<Space size="middle">
{
record.isActive ?
<Button color="danger" variant="solid">Ngừng Hoạt Đng</Button>
: <Button type='primary'>Kích Hoạt</Button>
}
</Space>
)
},
{
title: 'Công Cụ',
dataIndex: 'isActive',
key: 'isActive',
render: (_: any, record: Category) => (
<Space size="middle">
<Button color="danger" variant="solid">Xóa</Button>
<Button type='primary'>Sửa</Button>
</Space>
)
}
];
/* form */
const [form] = Form.useForm()
return (
<div style={{
position: "relative"
}}>
<h1>Quản Danh Mục</h1>
<Button onClick={() => {
setCreateNewModalState(true)
}} type='primary'>Thêm Mới</Button>
<Table
dataSource={categories}
columns={columns}
/>
{/* Modal Create new */}
<Modal
title="Thêm mới danh mục"
closable={{ 'aria-label': 'Custom Close Button' }}
open={createNewModalState}
onOk={() => {
if(loadingState) {
return
}
// setCreateNewModalState(false)
try {
form
.validateFields()
.then(async (values) => {
setLoadingState(true)
let result = await Apis.cloundInary.upload(values.file.file)
let newCategory = {
title: values.title,
iconUrl: result
}
let resultRes = await Apis.category.create(newCategory)
setCategories([
...categories,
resultRes
])
setLoadingState(false)
setCreateNewModalState(false)
})
} catch (er) {
setLoadingState(false)
setCreateNewModalState(true)
}
}}
onCancel={() => {
setCreateNewModalState(false)
}}
okButtonProps={{
htmlType: "submit"
}}
>
<Form form={form} layout="vertical">
<Form.Item
name="title"
label="Tên danh mục"
rules={[{ required: true, message: 'Nhập tên danh mục!' }]}
>
<Input placeholder="Nhập title" />
</Form.Item>
<Form.Item name="file" label="Ảnh">
<Upload beforeUpload={() => false}>
<Input type="file" />
</Upload>
</Form.Item>
</Form>
</Modal>
{/* Loading */}
{
loadingState && <div style={{
zIndex:"99999999"
}} className="absolute inset-0 flex items-center justify-center bg-white/60 backdrop-blur-sm z-50">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
}
</div>
)
}

View File

@@ -14,7 +14,7 @@ export default function ProtectedAdmin(
return store.user
})
/* Đã đăng nhập và là master hoặc admin */
if (userStore.data?.role == UserRole.MASTER || userStore.data?.role == UserRole.ADMIN) {
return (
<>
@@ -23,7 +23,8 @@ export default function ProtectedAdmin(
)
}
if (!userStore.data?.role) {
/* chưa đăng nhập */
if (!userStore.data && !userStore.loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-4">
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden">
@@ -90,11 +91,16 @@ export default function ProtectedAdmin(
)
}
/* đã đăng nhập nhưng không phải master hoặc admin */
if (userStore.data?.role) {
window.location.href = "/"
return <></>
}
/* chưa vào case, default */
async function signInHandle(e: FormEvent) {
e.preventDefault()
let data: UserSignInDTO = {
@@ -103,10 +109,9 @@ export default function ProtectedAdmin(
}
try {
let result = await Apis.user.signIn(data)
localStorage.setItem("userLogin", JSON.stringify(result.data))
localStorage.setItem("token", result)
Modal.confirm({
title: "Đăng nhập thành công",
content: result.message,
onOk: () => {
window.location.reload()
},

View File

@@ -28,7 +28,7 @@ export default function HeaderCom({ collapsed, setCollapsed }: { collapsed: bool
</select>
<Button onClick={() => {
localStorage.removeItem("userLogin")
localStorage.removeItem("token")
window.location.reload()
}}>logout</Button>
</Header>

View File

@@ -13,6 +13,11 @@ export default function Slider({ collapsed }: { collapsed: boolean }) {
key: 'user',
icon: <UserOutlined />,
label: "Quản lý người dùng",
},
{
key: 'category',
icon: <UserOutlined />,
label: "Quản lý danh mục",
}
]

View File

@@ -49,9 +49,9 @@ export default function Auth() {
console.log("loginData", loginData)
try {
let data = await Apis.user.signIn(loginData)
localStorage.setItem("userLogin", data.data.id)
localStorage.setItem("token", data)
Modal.confirm({
title: `Chào mừng ${data.data.userName} đã quay trở lại`,
title: `Chào mừng bạn đã quay trở lại`,
content: ``,
onOk: () => {
window.location.href = "/"

View File

@@ -70,7 +70,7 @@ export default function Header() {
window.location.href = "/admin"
}} className="fa-solid fa-lock"></i>}
<i onClick={() => {
window.localStorage.removeItem("userLogin")
window.localStorage.removeItem("token")
window.location.href = "/auth"
}} className="cursor-pointer fa-solid fa-right-from-bracket"></i>
</div>

View File

@@ -21,9 +21,16 @@ const userSlice = createSlice({
},
extraReducers: (bd) => {
bd.addCase(fetchUserData.pending, (state, action) => {
state.loading = true
})
bd.addCase(fetchUserData.fulfilled, (state, action) => {
state.loading = false
state.data = action.payload
})
bd.addCase(fetchUserData.rejected, (state, action) => {
state.loading = false
})
}
})
@@ -31,8 +38,8 @@ const userSlice = createSlice({
const fetchUserData = createAsyncThunk(
"user/fetchUserData",
async () => {
let result = await Apis.user.findById(localStorage.getItem("userLogin"))
return result.data
let result = await Apis.user.me(localStorage.getItem("token")) as any
return result
}
)

View File

@@ -0,0 +1,6 @@
export interface Category {
id: string;
title: string;
iconUrl: string;
isActive: boolean;
}