提交 8d6dd989 authored 作者: 王鹏飞's avatar 王鹏飞

chore: update

上级 d8033dd5
import { post } from '@/utils/http'
export interface AdminAccess {
has_access: boolean
token?: string
}
export async function requestAdminAccess(issue = false) {
return post<AdminAccess>('/api/admin/user/memberSsoLogin', { issue: issue ? 1 : 0 })
}
import { ApiError, shouldRetryQuery } from './errors'
import { describe, expect, it } from 'vitest'
describe('shouldRetryQuery', () => {
it('retries only safe transient transport failures', () => {
expect(shouldRetryQuery(0, new ApiError({ kind: 'transport', transient: true }))).toBe(true)
expect(shouldRetryQuery(2, new ApiError({ kind: 'transport', transient: true }))).toBe(false)
expect(shouldRetryQuery(0, new ApiError({ kind: 'auth' }))).toBe(false)
expect(shouldRetryQuery(0, new ApiError({ kind: 'business' }))).toBe(false)
})
})
import axios from 'axios'
export type ApiErrorKind = 'auth' | 'business' | 'cancelled' | 'transport'
interface ApiErrorOptions {
kind: ApiErrorKind
message?: string
status?: number
transient?: boolean
cause?: unknown
}
export class ApiError extends Error {
readonly kind: ApiErrorKind
readonly status?: number
readonly transient: boolean
override readonly cause?: unknown
constructor({ kind, message, status, transient = false, cause }: ApiErrorOptions) {
super(message ?? '请求失败')
this.name = 'ApiError'
this.kind = kind
this.status = status
this.transient = transient
this.cause = cause
}
}
export function normalizeApiError(error: unknown): ApiError {
if (error instanceof ApiError) return error
if (axios.isCancel(error) || (axios.isAxiosError(error) && error.code === 'ERR_CANCELED')) {
return new ApiError({ kind: 'cancelled', message: '请求已取消', cause: error })
}
if (axios.isAxiosError(error)) {
const status = error.response?.status
if (status === 401 || status === 403) {
return new ApiError({ kind: 'auth', message: '会话已失效', status, cause: error })
}
return new ApiError({
kind: 'transport',
message: error.message || '网络请求失败',
status,
transient: !status || status >= 500,
cause: error,
})
}
return new ApiError({
kind: 'transport',
message: '网络请求失败',
transient: true,
cause: error,
})
}
export function shouldRetryQuery(failureCount: number, error: unknown) {
const normalized = normalizeApiError(error)
return normalized.kind === 'transport' && normalized.transient && failureCount < 2
}
import { describe, expect, it } from 'vitest'
import { applyRequestAuthentication } from './http-auth'
describe('applyRequestAuthentication', () => {
it('uses optional development bearer and signer providers without owning signing logic', async () => {
const request = await applyRequestAuthentication(
{ headers: {} },
{
getBearerToken: () => 'member-session',
signRequest: () => ({ Sign: 'delegated-signature', appId: 'dev-app' }),
},
)
expect(request.headers).toMatchObject({
Authorization: 'member-session',
Sign: 'delegated-signature',
appId: 'dev-app',
})
})
it('does not manufacture credentials when no providers are configured', async () => {
expect(await applyRequestAuthentication({ headers: {} }, {})).toEqual({ headers: {} })
})
})
export type RequestHeaders = Record<string, string>
export interface RequestAuthenticationInput {
baseURL?: string
url?: string
method?: string
params?: unknown
data?: unknown
headers: RequestHeaders
}
export interface DevelopmentRequestAuthProviders {
getBearerToken?: () => string | undefined | Promise<string | undefined>
signRequest?: (request: RequestAuthenticationInput) => RequestHeaders | Promise<RequestHeaders>
}
let configuredProviders: DevelopmentRequestAuthProviders = {}
export function configureDevelopmentRequestAuth(providers: DevelopmentRequestAuthProviders) {
configuredProviders = providers
}
export function getDevelopmentRequestAuth() {
return configuredProviders
}
export async function applyRequestAuthentication<
T extends Omit<RequestAuthenticationInput, 'headers'> & { headers?: RequestHeaders },
>(
request: T,
providers: DevelopmentRequestAuthProviders,
): Promise<T & { headers: RequestHeaders }> {
const headers = { ...(request.headers ?? {}) }
const token = await providers.getBearerToken?.()
if (token) headers.Authorization = token
return {
...request,
headers: providers.signRequest
? { ...headers, ...(await providers.signRequest({ ...request, headers })) }
: headers,
}
}
export interface ApiEnvelope<T> {
code: number
message?: string
msg?: string
data: T
}
import { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios'
import { describe, expect, it } from 'vitest'
import { normalizeApiError } from './errors'
import { unwrapApiData } from './unwrap'
function response<T>(data: T, status = 200): AxiosResponse<T> {
return {
data,
status,
statusText: '',
headers: new AxiosHeaders(),
config: { headers: new AxiosHeaders() },
}
}
describe('unwrapApiData', () => {
it('returns data from successful APP envelopes', () => {
expect(unwrapApiData(response({ code: 200, message: 'ok', data: { id: 1 } }))).toEqual({
id: 1,
})
})
it('normalizes APP business and HTTP auth failures', () => {
expect(() => unwrapApiData(response({ code: 422, msg: '不可购买', data: null }))).toThrow(
expect.objectContaining({ kind: 'business', message: '不可购买' }),
)
expect(() => unwrapApiData(response({ code: 200, data: null }, 401))).toThrow(
expect.objectContaining({ kind: 'auth', status: 401 }),
)
})
it('normalizes cancelled and transient transport errors', () => {
expect(normalizeApiError(new AxiosError('canceled', 'ERR_CANCELED'))).toMatchObject({
kind: 'cancelled',
})
expect(normalizeApiError(new AxiosError('network down', 'ERR_NETWORK'))).toMatchObject({
kind: 'transport',
transient: true,
})
})
})
import type { AxiosResponse } from 'axios'
import { ApiError } from './errors'
import type { ApiEnvelope } from './types'
export function unwrapApiData<T>(response: AxiosResponse<ApiEnvelope<T>>): T {
if (response.status === 401 || response.status === 403) {
throw new ApiError({ kind: 'auth', status: response.status, message: '会话已失效' })
}
if (response.status < 200 || response.status >= 300) {
throw new ApiError({ kind: 'transport', status: response.status, message: '请求失败' })
}
if (response.data.code !== 200) {
throw new ApiError({
kind: 'business',
message: response.data.message ?? response.data.msg ?? '业务请求失败',
})
}
return response.data.data
}
......@@ -2,7 +2,6 @@ import { AndroidOutlined, AppleOutlined, CloseCircleFilled } from '@ant-design/i
import { Button, Image, Modal, QRCode, Typography } from 'antd'
import { useState } from 'react'
import appDownloadQr from '@/assets/app-download-qr.png'
import { useMobileDownloadStore } from '../mobile-download'
import './MobileDownloadModal.css'
const iosAppStoreUrl =
......@@ -10,14 +9,17 @@ const iosAppStoreUrl =
type Platform = 'ios' | 'android'
export function MobileDownloadModal() {
const open = useMobileDownloadStore((state) => state.open)
const hide = useMobileDownloadStore((state) => state.hide)
interface MobileDownloadModalProps {
open: boolean
onClose: () => void
}
export function MobileDownloadModal({ open, onClose }: MobileDownloadModalProps) {
const [hoveredPlatform, setHoveredPlatform] = useState<Platform | null>(null)
const close = () => {
setHoveredPlatform(null)
hide()
onClose()
}
const platformVisual = (platform: Platform) => {
......
import { BellOutlined } from '@ant-design/icons'
import { Badge, Button, Layout } from 'antd'
import { Link, Outlet, useLocation, useNavigate } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import './LearningLayout.css'
import { getTopNavigationKey, navigationItems } from './navigation'
import { useSessionStore } from '@/modules/auth/session'
import { useSessionStore } from '@/stores/session'
import logo from '@/assets/logo.png'
import { AccountMenu } from './components/AccountMenu'
import { WorkbenchEntry } from './components/WorkbenchEntry'
import { MobileDownloadModal } from '@/modules/download/components/MobileDownloadModal'
import { useMobileDownloadStore } from '@/modules/download/mobile-download'
import { MobileDownloadModal } from '@/components/MobileDownloadModal'
import { requestAdminAccess, type AdminAccess } from '@/api/admin-access'
import { logout } from '@/modules/auth/api'
import { unreadMessageCountOptions } from '@/modules/my/messages/query'
export function LearningLayout() {
......@@ -17,10 +18,34 @@ export function LearningLayout() {
const navigate = useNavigate()
const session = useSessionStore((state) => state.session)
const clearSession = useSessionStore((state) => state.clearSession)
const showMobileDownload = useMobileDownloadStore((state) => state.show)
const [mobileDownloadOpen, setMobileDownloadOpen] = useState(false)
const adminAccessQuery = useQuery({
queryKey: ['admin-access'],
queryFn: () => requestAdminAccess(),
enabled: Boolean(session),
staleTime: 5 * 60 * 1000,
})
const issueAdminAccess = useMutation({ mutationFn: () => requestAdminAccess(true) })
const unreadQuery = useQuery({ ...unreadMessageCountOptions(), enabled: Boolean(session) })
const unreadCount = unreadQuery.data ?? 0
const logoutMember = async () => {
try {
await logout()
} catch {
} finally {
clearSession()
}
}
const openAdmin = (access: AdminAccess) => {
if (!access.token) return
localStorage.setItem('kiwi.token', access.token)
const adminUrl = new URL(import.meta.env.VITE_ADMIN_URL)
adminUrl.hash = new URLSearchParams({ sso_token: access.token }).toString()
window.open(adminUrl.toString(), '_blank')
}
return (
<Layout className='learning-layout'>
<Layout.Header className='learning-layout__header'>
......@@ -40,7 +65,7 @@ export function LearningLayout() {
key={item.key}
type='button'
className='learning-layout__nav-item'
onClick={showMobileDownload}
onClick={() => setMobileDownloadOpen(true)}
>
{item.label}
</button>
......@@ -54,7 +79,16 @@ export function LearningLayout() {
</Link>
),
)}
<WorkbenchEntry />
{session && adminAccessQuery.data?.has_access ? (
<button
type='button'
className='learning-layout__nav-item'
disabled={issueAdminAccess.isPending}
onClick={() => issueAdminAccess.mutate(undefined, { onSuccess: openAdmin })}
>
管理后台
</button>
) : null}
</nav>
<div className='learning-layout__session' aria-label='用户会话区'>
{session ? (
......@@ -68,7 +102,7 @@ export function LearningLayout() {
<BellOutlined style={{ fontSize: 18 }} />
</Badge>
</Link>
<AccountMenu session={session} onLogout={clearSession} />
<AccountMenu session={session} onLogout={logoutMember} />
</>
) : (
<Button
......@@ -107,7 +141,7 @@ export function LearningLayout() {
<Link to='/contact'>联系我们</Link>
</nav>
</Layout.Footer>
<MobileDownloadModal />
<MobileDownloadModal open={mobileDownloadOpen} onClose={() => setMobileDownloadOpen(false)} />
</Layout>
)
}
......@@ -7,12 +7,12 @@ import {
import { Avatar, Button, Divider, Popover, Typography } from 'antd'
import { useState } from 'react'
import { useNavigate } from 'react-router'
import type { MemberSession } from '@/modules/auth/types'
import type { MemberSession } from '@/stores/session'
import './AccountMenu.css'
interface AccountMenuProps {
session: MemberSession
onLogout: () => void
onLogout: () => Promise<void>
}
function avatarText(session: MemberSession) {
......@@ -30,9 +30,9 @@ export function AccountMenu({ session, onLogout }: AccountMenuProps) {
navigate(path)
}
const logout = () => {
const logout = async () => {
setOpen(false)
onLogout()
await onLogout()
navigate('/library')
}
......@@ -43,7 +43,7 @@ export function AccountMenu({ session, onLogout }: AccountMenuProps) {
className='account-menu__summary'
onClick={() => navigateTo('/my/profile')}
>
<Avatar className='account-menu__avatar' size={48} src={session.head_img}>
<Avatar className='account-menu__avatar' size={48} src={session.head_img || undefined}>
{avatarText(session)}
</Avatar>
<span className='account-menu__summary-copy'>
......@@ -75,7 +75,7 @@ export function AccountMenu({ session, onLogout }: AccountMenuProps) {
<Divider />
<div className='account-menu__actions'>
<Button type='text' icon={<LogoutOutlined />} onClick={logout}>
<Button type='text' icon={<LogoutOutlined />} onClick={() => void logout()}>
退出登录
</Button>
</div>
......@@ -93,7 +93,7 @@ export function AccountMenu({ session, onLogout }: AccountMenuProps) {
onOpenChange={setOpen}
>
<Button type='text' className='account-menu__trigger' aria-label='打开账户菜单'>
<Avatar className='account-menu__avatar' size={34} src={session.head_img}>
<Avatar className='account-menu__avatar' size={34} src={session.head_img || undefined}>
{avatarText(session)}
</Avatar>
<span className='account-menu__trigger-copy'>
......
import { enterWorkbench } from '@/modules/admin-access/admin-access'
import { useAdminAccess, useIssueAdminAccess } from '@/modules/admin-access/query'
import { useSessionStore } from '@/modules/auth/session'
export function WorkbenchEntry() {
const session = useSessionStore((state) => state.session)
const accessQuery = useAdminAccess(Boolean(session))
const issueAccess = useIssueAdminAccess()
if (!session || !accessQuery.data?.has_access) return null
return (
<button
type='button'
className='learning-layout__nav-item'
disabled={issueAccess.isPending}
onClick={() => issueAccess.mutate(undefined, { onSuccess: enterWorkbench })}
>
{issueAccess.isPending ? '返回管理后台' : '管理后台'}
</button>
)
}
......@@ -8,22 +8,11 @@ import { RouterProvider } from 'react-router'
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7'
import { router } from '@/router/routes'
import { LoadingFallback } from '@/router/LoadingFallback'
import { shouldRetryQuery } from '@/api/errors'
import { configureDevelopmentRequestAuth } from '@/api/http-auth'
import { configureSessionExpiry, getMemberToken } from '@/modules/auth/session'
import '@/styles/global.css'
configureDevelopmentRequestAuth({
getBearerToken: getMemberToken,
})
configureSessionExpiry(() => {
if (router.state.location.pathname !== '/login') void router.navigate('/login', { replace: true })
})
export const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 10 * 60 * 1000, retry: shouldRetryQuery },
queries: { staleTime: 10 * 60 * 1000, retry: false },
},
})
......
import { describe, expect, it } from 'vitest'
import { canOpenWorkbench } from './admin-access'
describe('canOpenWorkbench', () => {
it('only enables the workbench for an eligible matching admin account', () => {
expect(canOpenWorkbench({ has_access: true, token: 'admin-token' })).toBe(true)
expect(canOpenWorkbench({ has_access: false })).toBe(false)
expect(canOpenWorkbench({ has_access: true })).toBe(false)
})
})
export interface AdminAccess {
has_access: boolean
token?: string
}
export function canOpenWorkbench(access: AdminAccess | undefined) {
return access?.has_access === true && Boolean(access.token)
}
export function enterWorkbench(access: AdminAccess) {
if (!canOpenWorkbench(access)) return false
localStorage.setItem('kiwi.token', access.token!)
const adminUrl = new URL(import.meta.env.VITE_ADMIN_URL)
adminUrl.hash = new URLSearchParams({ sso_token: access.token! }).toString()
window.open(adminUrl.toString(), '_blank')
return true
}
import axios from 'axios'
import { unwrapApiData } from '@/api/unwrap'
import type { ApiEnvelope } from '@/api/types'
import { expireMemberSession, getMemberToken } from '@/modules/auth/session'
import type { AdminAccess } from './admin-access'
async function requestAdminAccess(issue: boolean) {
try {
const response = await axios.post<ApiEnvelope<AdminAccess>>(
'/api/admin/user/memberSsoLogin',
{ issue: issue ? 1 : 0 },
{
withCredentials: true,
headers: { Authorization: getMemberToken() ?? '' },
},
)
return unwrapApiData(response)
} catch (error) {
if (axios.isAxiosError(error) && [401, 403].includes(error.response?.status ?? 0)) {
expireMemberSession()
}
throw error
}
}
export function getAdminAccess() {
return requestAdminAccess(false)
}
export function issueAdminAccess() {
return requestAdminAccess(true)
}
import { useMutation, useQuery } from '@tanstack/react-query'
import { getAdminAccess, issueAdminAccess } from './api'
export const adminAccessKeys = {
current: ['admin-access'] as const,
}
export function useAdminAccess(enabled: boolean) {
return useQuery({
queryKey: adminAccessKeys.current,
queryFn: getAdminAccess,
enabled,
staleTime: 5 * 60 * 1000,
})
}
export function useIssueAdminAccess() {
return useMutation({ mutationFn: issueAdminAccess })
}
import { md5 } from 'js-md5'
import { unwrapApiData } from '@/api/unwrap'
import type { ApiEnvelope } from '@/api/types'
import { http } from '@/utils/http'
import type { LoginInput, MemberSession } from './types'
import type { MemberSession } from '@/stores/session'
import { post } from '@/utils/http'
export function hashMemberPassword(password: string) {
return md5(md5(password))
interface LoginInput {
phone: string
type: '1' | '2'
password?: string
code?: string
uuid?: string
}
export async function login(input: LoginInput) {
return unwrapApiData(
await http.post<ApiEnvelope<MemberSession>>('/api/app/v1/members/login/login', input),
)
return post<MemberSession>('/api/app/v1/members/login/login', input)
}
export async function sendLoginCode(phone: string, uuid: string) {
return unwrapApiData(
await http.post<ApiEnvelope<{ is_success: number }>>('/api/app/v1/members/login/sendCode', {
return post<{ is_success: number }>('/api/app/v1/members/login/sendCode', {
phone,
types: 'login',
uuid,
}),
)
})
}
export async function logout() {
return unwrapApiData(
await http.post<ApiEnvelope<{ is_success: number }>>('/api/app/v1/members/login/logout', {}),
)
return post('/api/app/v1/members/login/logout')
}
export interface MemberSession {
members_id: number
name?: string
head_img?: string
phone: string
grade_name?: string
token: string
access_token: string
}
export interface LoginInput {
phone: string
type: '1' | '2'
password?: string
code?: string
uuid?: string
}
import { useState } from 'react'
import { Button, Card, Checkbox, Form, Input, Segmented, Space, Typography, message } from 'antd'
import { useNavigate } from 'react-router'
import { login, hashMemberPassword, sendLoginCode } from '../api'
import { getDeviceUuid } from '../device'
import { useSessionStore } from '../session'
import { login, sendLoginCode } from '../api'
import { getDeviceUuid } from '@/utils/device'
import { hashPassword } from '@/utils/password'
import { useSessionStore } from '@/stores/session'
import './LoginView.css'
type LoginMode = 'password' | 'code'
......@@ -33,8 +34,7 @@ export function LoginView() {
try {
await sendLoginCode(phone, getDeviceUuid())
message.success('验证码已发送')
} catch (error) {
message.error(error instanceof Error ? error.message : '验证码发送失败')
} catch {
} finally {
setSending(false)
}
......@@ -46,15 +46,14 @@ export function LoginView() {
const session = await login({
phone: values.phone,
type: mode === 'password' ? '1' : '2',
password: mode === 'password' ? hashMemberPassword(values.password ?? '') : undefined,
password: mode === 'password' ? hashPassword(values.password ?? '') : undefined,
code: mode === 'code' ? values.code : undefined,
uuid: mode === 'code' ? getDeviceUuid() : undefined,
})
setSession(session)
message.success('登录成功')
navigate('/library', { replace: true })
} catch (error) {
message.error(error instanceof Error ? error.message : '登录失败')
} catch {
} finally {
setSubmitting(false)
}
......
import { unwrapApiData } from '@/api/unwrap'
import type { ApiEnvelope } from '@/api/types'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
export interface BookDetail {
bookId: string
......@@ -83,11 +81,6 @@ function list(value: unknown): unknown[] {
return []
}
async function post<T>(url: string, data: Record<string, unknown>): Promise<T> {
const response = await http.post<ApiEnvelope<T>>(url, data)
return unwrapApiData(response)
}
function toDetail(value: unknown): BookDetail {
const data = record(value)
return {
......
......@@ -10,7 +10,9 @@ export function BookComments({ comments }: { comments: BookComment[] }) {
renderItem={(comment) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar src={comment.userImage}>{comment.user.slice(0, 1)}</Avatar>}
avatar={
<Avatar src={comment.userImage || undefined}>{comment.user.slice(0, 1)}</Avatar>
}
title={
<>
<Typography.Text>{comment.user || '匿名读者'}</Typography.Text>{' '}
......
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
Button,
Card,
Descriptions,
......@@ -17,15 +16,11 @@ import {
Typography,
message,
} from 'antd'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router'
import {
addBookToShelf,
getBookStudyReport,
setBookCollection,
type BookChapter,
type BookDetail,
} from '../api'
import { useSessionStore } from '@/stores/session'
import { createBookAlipayOrder, getPayScene } from '@/modules/purchase/api'
import { getBookStudyReport, setBookCollection, type BookChapter, type BookDetail } from '../api'
import { findFirstChapterId } from '../chapter-navigation'
import { BookChapters } from '../components/BookChapters'
import { BookComments } from '../components/BookComments'
......@@ -62,7 +57,9 @@ export function BookDetailView() {
const { bookId = '' } = useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
const session = useSessionStore((state) => state.session)
const [messageApi, contextHolder] = message.useMessage()
const [purchaseOpen, setPurchaseOpen] = useState(false)
const detailQuery = useQuery(bookDetailOptions(bookId))
const chaptersQuery = useQuery(bookChaptersOptions(bookId))
const commentsQuery = useQuery(bookCommentsOptions(bookId))
......@@ -83,21 +80,44 @@ export function BookDetailView() {
)
},
})
const shelfMutation = useMutation({
const reportMutation = useMutation({ mutationFn: () => getBookStudyReport(bookId) })
const purchaseMutation = useMutation({
mutationFn: async () => {
if (!(await addBookToShelf(bookId))) throw new Error('加入购物车失败')
if (!book) throw new Error('图书信息尚未加载完成')
const payment = await createBookAlipayOrder({
bookId,
totalPrice: book.vipPrice || book.price,
payScene: getPayScene(),
})
if (payment.is_moneys !== 0 && !payment.pay_url) {
throw new Error('未获取到支付宝支付地址')
}
return payment
},
onSuccess: () => {
messageApi.success('已加入购物车')
onSuccess: (payment) => {
setPurchaseOpen(false)
if (payment.is_moneys === 0) {
messageApi.success('订单已完成')
void queryClient.invalidateQueries({ queryKey: bookKeys.detail(bookId) })
return
}
if (payment.pay_url) window.location.assign(payment.pay_url)
},
})
const reportMutation = useMutation({ mutationFn: () => getBookStudyReport(bookId) })
const chapterAccess = useMemo(
() => (chapter: BookChapter) => Boolean(book?.isFree || book?.isHave || chapter.isReading),
[book],
)
const openReader = (chapterId: string) => navigate(`/reader/${bookId}/${chapterId}`)
const openPurchase = () => {
if (!session) {
messageApi.info('请先登录')
navigate('/login')
return
}
setPurchaseOpen(true)
}
const startReading = () => {
if (!book) return
if (book.isFree || book.isHave) {
......@@ -108,11 +128,11 @@ export function BookDetailView() {
)
}
if (isTrialAvailable(book)) return openReader(book.readChapterId)
messageApi.info('请使用 APP 购买')
openPurchase()
}
const selectChapter = (chapter: BookChapter) => {
if (chapterAccess(chapter)) return openReader(chapter.id)
messageApi.info('请使用 APP 购买')
openPurchase()
}
if (detailQuery.isLoading) {
......@@ -124,29 +144,18 @@ export function BookDetailView() {
</main>
)
}
if (detailQuery.error || !book) {
if (!book) {
return (
<main className='book-detail'>
<Typography.Title level={1}>图书详情</Typography.Title>
<Typography.Text>图书编号:{bookId}</Typography.Text>
<Alert
type='error'
showIcon
title='图书详情加载失败'
description={(detailQuery.error as Error | undefined)?.message ?? '未找到图书'}
action={
<Button size='small' onClick={() => void detailQuery.refetch()}>
重试
</Button>
}
/>
<Empty description='未找到图书' />
</main>
)
}
const canRead = book.isFree || book.isHave
const primaryAction = canRead ? '阅读' : isTrialAvailable(book) ? '试读' : '购买'
return (
<main className='book-detail'>
{contextHolder}
......@@ -185,26 +194,6 @@ export function BookDetailView() {
</Flex>
</Card>
{collectionMutation.error || shelfMutation.error || reportMutation.error ? (
<Alert
type='error'
showIcon
title='操作失败'
description={
(collectionMutation.error ?? shelfMutation.error ?? (reportMutation.error as Error))
.message
}
/>
) : null}
{chaptersQuery.error ? (
<Alert
type='warning'
showIcon
title='目录加载失败'
description={(chaptersQuery.error as Error).message}
/>
) : null}
<Card>
<Tabs
items={[
......@@ -265,13 +254,6 @@ export function BookDetailView() {
label: `书评 (${book.commentNum || book.ratingCount})`,
children: commentsQuery.isLoading ? (
<Skeleton active paragraph={{ rows: 4 }} />
) : commentsQuery.error ? (
<Alert
type='warning'
showIcon
title='书评加载失败'
description={(commentsQuery.error as Error).message}
/>
) : (
<BookComments comments={commentsQuery.data ?? []} />
),
......@@ -303,11 +285,35 @@ export function BookDetailView() {
学习报告
</Button>
) : null}
{isTrialAvailable(book) ? (
<>
<Button type='primary' onClick={startReading}>
{primaryAction}
</Button>
<Button onClick={openPurchase} loading={purchaseMutation.isPending}>
购买
</Button>
</>
) : (
<Button type='primary' onClick={startReading} loading={purchaseMutation.isPending}>
{primaryAction}
</Button>
)}
</Space>
</Card>
<Modal
title='确认购买'
open={purchaseOpen}
okText='支付宝支付'
cancelText='取消'
confirmLoading={purchaseMutation.isPending}
onOk={() => purchaseMutation.mutate()}
onCancel={() => setPurchaseOpen(false)}
>
<Typography.Paragraph>{book.bookName}</Typography.Paragraph>
<Typography.Paragraph>支付金额:¥{book.vipPrice || book.price}</Typography.Paragraph>
<Typography.Text type='secondary'>支付完成后即可在浏览器或 APP 中阅读。</Typography.Text>
</Modal>
<Modal
title='学习报告'
open={Boolean(reportMutation.data)}
......
import { create } from 'zustand'
interface MobileDownloadState {
open: boolean
show: () => void
hide: () => void
}
export const useMobileDownloadStore = create<MobileDownloadState>((set) => ({
open: false,
show: () => set({ open: true }),
hide: () => set({ open: false }),
}))
import { unwrapApiData } from '@/api/unwrap'
import type { ApiEnvelope } from '@/api/types'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
export interface HelpArticleSummary {
id: string
......@@ -29,12 +27,10 @@ function text(value: unknown) {
}
export async function getHelpArticles(): Promise<HelpArticleSummary[]> {
const data = unwrapApiData(
await http.post<ApiEnvelope<unknown>>('/api/app/v1/common/Help/getList', {
const data = await post<unknown>('/api/app/v1/common/Help/getList', {
page: 1,
page_size: 10,
}),
)
})
return list(data)
.map((value) => {
const item = record(value)
......@@ -44,11 +40,7 @@ export async function getHelpArticles(): Promise<HelpArticleSummary[]> {
}
export async function getHelpArticle(id: string): Promise<HelpArticle> {
const item = record(
unwrapApiData(
await http.post<ApiEnvelope<unknown>>('/api/app/v1/common/Help/getInfoById', { id }),
),
)
const item = record(await post<unknown>('/api/app/v1/common/Help/getInfoById', { id }))
return {
id: text(item.id) || id,
title: text(item.help_title ?? item.helpTitle),
......
......@@ -56,7 +56,15 @@ function FlowerShape({ offsetX, offsetY, petal }: FlowerShapeProps) {
return (
<g transform={`translate(${offsetX} ${offsetY})`}>
{PETAL_ANGLES.map((angle) => (
<ellipse key={angle} cx='0' cy='-6' rx='4' ry='5.6' fill={petal} transform={`rotate(${angle})`} />
<ellipse
key={angle}
cx='0'
cy='-6'
rx='4'
ry='5.6'
fill={petal}
transform={`rotate(${angle})`}
/>
))}
<circle r='3.2' fill='#c2456a' />
</g>
......@@ -103,7 +111,12 @@ export function RedbudBranch({ className, style }: RedbudBranchProps) {
strokeWidth='3'
strokeLinecap='round'
/>
<path d='M96 60 C 88 44, 84 34, 90 22' stroke='#ffc9d6' strokeWidth='2.4' strokeLinecap='round' />
<path
d='M96 60 C 88 44, 84 34, 90 22'
stroke='#ffc9d6'
strokeWidth='2.4'
strokeLinecap='round'
/>
{CLUSTERS.map((cluster, index) => (
<FlowerCluster key={index} x={cluster.x} y={cluster.y} scale={cluster.scale} />
))}
......
......@@ -95,7 +95,8 @@ export function AboutView() {
<section className='info-section'>
<h2 className='info-section__title'>平台能力</h2>
<p className='info-section__lede'>
平台打通「学校端 — 出版端 — 教学端 — 学生端」的完整流程,基于大数据、多模态的 AI 辅助教学,配套高品质专业出版服务。
平台打通「学校端 — 出版端 — 教学端 — 学生端」的完整流程,基于大数据、多模态的 AI
辅助教学,配套高品质专业出版服务。
</p>
<div className='info-highlights'>
{highlights.map((item) => (
......@@ -135,10 +136,18 @@ export function AboutView() {
<div className='info-meta__row'>
<dt>相关协议</dt>
<dd>
<a href={`${agreementBaseUrl}/ser_agreement.html?v=3`} target='_blank' rel='noreferrer'>
<a
href={`${agreementBaseUrl}/ser_agreement.html?v=3`}
target='_blank'
rel='noreferrer'
>
《用户协议》
</a>
<a href={`${agreementBaseUrl}/pri_agreement.html?v=3`} target='_blank' rel='noreferrer'>
<a
href={`${agreementBaseUrl}/pri_agreement.html?v=3`}
target='_blank'
rel='noreferrer'
>
《隐私政策》
</a>
</dd>
......
......@@ -9,7 +9,13 @@ const contactChannels = [
label: '公司地址',
value: '北京市海淀区北四环西路52号方正国际大厦5层501',
},
{ key: 'phone', icon: PhoneOutlined, label: '联系电话', value: '010-62793909', href: 'tel:010-62793909' },
{
key: 'phone',
icon: PhoneOutlined,
label: '联系电话',
value: '010-62793909',
href: 'tel:010-62793909',
},
{
key: 'email',
icon: MailOutlined,
......
import { useQuery } from '@tanstack/react-query'
import { Alert, Button, Card, Skeleton } from 'antd'
import { Card, Skeleton } from 'antd'
import { Link, useParams } from 'react-router'
import { helpArticleOptions } from '../query'
import { PageHero } from '../components/PageHero'
......@@ -21,23 +21,6 @@ export function HelpDetailView() {
</main>
)
if (articleQuery.isError)
return (
<main className='info-page'>
<PageHero title='帮助详情' intro='文章加载失败,请稍后重试。' />
<section className='info-card'>
<Alert
style={{ margin: '16px 0' }}
type='error'
showIcon
title='帮助详情加载失败'
description={articleQuery.error.message}
action={<Button onClick={() => void articleQuery.refetch()}>重试</Button>}
/>
</section>
</main>
)
const article = articleQuery.data
if (!article) return null
return (
......
import { useQuery } from '@tanstack/react-query'
import { RightOutlined } from '@ant-design/icons'
import { Alert, Button, Empty, Skeleton } from 'antd'
import { Empty, Skeleton } from 'antd'
import { Link } from 'react-router'
import { helpArticlesOptions } from '../query'
import { PageHero } from '../components/PageHero'
......@@ -17,16 +17,6 @@ export function HelpListView() {
{articlesQuery.isPending ? (
<Skeleton active paragraph={{ rows: 8 }} style={{ padding: '16px 0' }} />
) : null}
{articlesQuery.isError ? (
<Alert
style={{ margin: '16px 0' }}
type='error'
showIcon
title='帮助内容加载失败'
description={articlesQuery.error.message}
action={<Button onClick={() => void articlesQuery.refetch()}>重试</Button>}
/>
) : null}
{articlesQuery.data && articles.length === 0 ? (
<Empty description='暂无帮助内容' style={{ padding: '32px 0' }} />
) : null}
......
import { unwrapApiData } from '@/api/unwrap'
import type { ApiEnvelope } from '@/api/types'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
import type { LibrarySearchState } from './query-state'
export interface LibraryBrowseApiParams {
......@@ -97,11 +95,6 @@ function toFilter(value: unknown, idKey: 'category_id' | 'label_id'): LibraryFil
return { id: asNumber(item[idKey]), name: asString(item.name) }
}
async function post<T>(url: string, data: Record<string, unknown>): Promise<T> {
const response = await http.post<ApiEnvelope<T>>(url, data)
return unwrapApiData(response)
}
export function toLibraryApiParams(state: LibrarySearchState): LibraryApiParams {
const keyword = state.keyword.trim()
if (keyword) {
......
import { HeartFilled, HeartOutlined, LoadingOutlined } from '@ant-design/icons'
import { Button, Card, Image, Tag, Tooltip, Typography } from 'antd'
import { Link } from 'react-router'
import { useNavigate } from 'react-router'
import defaultBookCover from '@/assets/default-book-cover.jpg'
import type { LibraryBook } from '../api'
......@@ -21,12 +22,23 @@ export function LibraryBookCard({
isUpdatingCollection,
onCollectionChange,
}: LibraryBookCardProps) {
const navigate = useNavigate()
const favourite = book.is_collection === 1
const openDetail = () => navigate(`/book/${book.book_id}`)
return (
<Card className={`library-book-card library-book-card--${view}`} hoverable>
<Link
<Card
className={`library-book-card library-book-card--${view}`}
hoverable
role='link'
tabIndex={0}
onClick={openDetail}
onKeyDown={(event) => {
if (event.key === 'Enter' && event.currentTarget === event.target) openDetail()
}}
>
<div
className='library-book-card__cover'
to={`/book/${book.book_id}`}
aria-label={`查看《${book.book_name}》详情`}
>
<Image
......@@ -35,11 +47,11 @@ export function LibraryBookCard({
alt={`${book.book_name}封面`}
fallback={defaultBookCover}
/>
</Link>
</div>
<div className='library-book-card__content'>
<div>
<Typography.Title level={4} ellipsis={{ rows: 2 }}>
<Link to={`/book/${book.book_id}`}>{book.book_name}</Link>
{book.book_name}
</Typography.Title>
<Typography.Text type='secondary'>{book.authors || '作者未知'}</Typography.Text>
</div>
......@@ -60,11 +72,21 @@ export function LibraryBookCard({
aria-label={favourite ? `取消收藏《${book.book_name}》` : `收藏《${book.book_name}》`}
type='text'
danger={favourite}
loading={isUpdatingCollection}
onClick={() => onCollectionChange(book)}
>
{favourite ? '♥' : '♡'}
</Button>
disabled={isUpdatingCollection}
icon={
isUpdatingCollection ? (
<LoadingOutlined spin />
) : favourite ? (
<HeartFilled />
) : (
<HeartOutlined />
)
}
onClick={(event) => {
event.stopPropagation()
onCollectionChange(book)
}}
/>
</Tooltip>
</div>
</div>
......
......@@ -152,7 +152,7 @@
box-shadow: 0 12px 28px rgb(31 41 55 / 12%);
}
.library-book-card.ant-card:active {
.library-book-card.ant-card:active:not(:has(.ant-btn:active)) {
transform: scale(0.985);
transition-duration: 100ms;
}
......
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Empty, Pagination, Skeleton, Space } from 'antd'
import { Empty, Pagination, Skeleton, Space } from 'antd'
import { useState } from 'react'
import { setBookCollection, type LibraryBook } from '../api'
import { LibraryBanner } from '../components/LibraryBanner'
......@@ -45,21 +45,10 @@ export function LibraryView() {
onSettled: () => setChangingBookId(undefined),
})
const filterError = categoriesQuery.error ?? labelsQuery.error
const bookError = booksQuery.error
return (
<main className='library-view'>
<LibraryBanner />
{filterError ? (
<Alert
type='warning'
showIcon
title='筛选项加载失败'
description={(filterError as Error).message}
/>
) : null}
<div className='library-toolbar'>
<LibraryToolbar
state={state}
......@@ -73,23 +62,7 @@ export function LibraryView() {
/>
</div>
{collectionMutation.error ? (
<Alert
type='error'
showIcon
title='收藏操作失败'
description={(collectionMutation.error as Error).message}
/>
) : null}
{bookError ? (
<Alert
type='error'
showIcon
title='图书加载失败'
description={(bookError as Error).message}
action={<a onClick={() => void booksQuery.refetch()}>重试</a>}
/>
) : booksQuery.isLoading ? (
{booksQuery.isLoading ? (
<Skeleton active paragraph={{ rows: 8 }} />
) : booksQuery.data?.list.length ? (
<div className={`library-books library-books--${state.view}`}>
......
import { http } from '@/utils/http'
import { unwrapApiData } from '@/api/unwrap'
import { post } from '@/utils/http'
export interface MyStatistics {
name?: string
......@@ -85,8 +84,7 @@ function toMyBookListResult(data: unknown): MyBookListResult {
}
export async function getMyStatistics(): Promise<MyStatistics> {
const response = await http.post('/api/app/v1/members/Information/getMyStatistics', {})
const data = asRecord(unwrapApiData(response))
const data = asRecord(await post('/api/app/v1/members/Information/getMyStatistics'))
return {
name: asOptionalText(data.name),
......@@ -105,8 +103,7 @@ export async function getMyStatistics(): Promise<MyStatistics> {
}
async function getMyBookList(path: string, params: MyListParams): Promise<MyBookListResult> {
const response = await http.post(path, { page: params.page, page_size: params.pageSize })
return toMyBookListResult(unwrapApiData(response))
return toMyBookListResult(await post(path, { page: params.page, page_size: params.pageSize }))
}
export function getMyCourses(params: MyListParams) {
......@@ -118,7 +115,6 @@ export function getBookshelf(params: MyListParams) {
}
export async function deleteBookshelfItem(cartId: string) {
const response = await http.post('/api/app/v1/orders/Orders/delCart', { cart_id: cartId })
const data = unwrapApiData<ApiRecord>(response)
const data = await post<ApiRecord>('/api/app/v1/orders/Orders/delCart', { cart_id: cartId })
return data.is_success === 1 || data.is_success === '1' || data.is_success === true
}
import { Alert, Button, Card, Empty, Image, Pagination, Skeleton, Tag, Typography } from 'antd'
import { Card, Empty, Image, Pagination, Skeleton, Tag, Typography } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router'
import defaultBookCover from '@/assets/default-book-cover.jpg'
......@@ -23,15 +23,6 @@ export function MyBooksView() {
</div>
{coursesQuery.isPending ? <Skeleton active paragraph={{ rows: 12 }} /> : null}
{coursesQuery.isError ? (
<Alert
showIcon
type='error'
title='书架加载失败'
description={coursesQuery.error.message}
action={<Button onClick={() => void coursesQuery.refetch()}>重试</Button>}
/>
) : null}
{coursesQuery.data && coursesQuery.data.items.length === 0 ? (
<Empty description='暂无书籍' />
) : null}
......
import {
Alert,
Button,
Card,
Checkbox,
......@@ -71,23 +70,6 @@ export function MyBookshelfView() {
</div>
{bookshelfQuery.isPending ? <Skeleton active paragraph={{ rows: 10 }} /> : null}
{bookshelfQuery.isError ? (
<Alert
showIcon
type='error'
title='购物车加载失败'
description={bookshelfQuery.error.message}
action={<Button onClick={() => void bookshelfQuery.refetch()}>重试</Button>}
/>
) : null}
{deleteMutation.isError ? (
<Alert
showIcon
type='error'
title='删除失败'
description={deleteMutation.error.message}
/>
) : null}
{bookshelfQuery.data && items.length === 0 ? <Empty description='购物车暂无书籍' /> : null}
{items.length ? (
<>
......
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
export type CouponStatus = 0 | 1
......@@ -10,56 +9,23 @@ export interface CouponListParams {
}
export interface CouponItem {
id: string
name: string
threshold: string
discount: string
startTime: string
endTime: string
useStatus: number
coupon_rec_id: number
coupon_name: string
norm_price: string
reduced_price: string
end_time: string
use_status: number
}
export interface CouponListResult {
items: CouponItem[]
list: CouponItem[]
total?: number
}
type ApiRecord = Record<string, unknown>
function record(value: unknown): ApiRecord {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as ApiRecord)
: {}
}
function text(value: unknown) {
return value === null || value === undefined ? '' : String(value)
}
function toCoupon(value: unknown): CouponItem {
const data = record(value)
return {
id: text(data.coupon_rec_id),
name: text(data.coupon_name),
threshold: text(data.norm_price),
discount: text(data.reduced_price),
startTime: text(data.start_time),
endTime: text(data.end_time),
useStatus: Number(data.use_status) || 0,
}
}
export async function getCoupons(params: CouponListParams): Promise<CouponListResult> {
const response = await http.post('/api/app/v1/coupon/Coupon/getCouponRecordListSAll', {
return post('/api/app/v1/coupon/Coupon/getCouponRecordListSAll', {
page: params.page,
page_size: params.pageSize,
type: params.status,
type: params.status === 0 ? 2 : 1,
})
const data = unwrapApiData<unknown>(response)
const payload = record(data)
const source = Array.isArray(payload.list) ? payload.list : []
return {
items: source.map(toCoupon),
total: Number.isFinite(Number(payload.total)) ? Number(payload.total) : undefined,
}
}
......@@ -6,14 +6,14 @@ export function CouponList({ items, status }: { items: CouponItem[]; status: num
<Space orientation='vertical' size='middle' style={{ width: '100%' }}>
{items.map((coupon) => (
<Card
key={coupon.id}
key={coupon.coupon_rec_id}
size='small'
style={{ borderLeft: `4px solid ${status === 0 ? '#1677ff' : '#bfbfbf'}` }}
>
<Space orientation='vertical' size={4} style={{ width: '100%' }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
{coupon.name || '优惠券'}
{coupon.coupon_name || '优惠券'}
</Typography.Title>
<Tag color={status === 0 ? 'blue' : 'default'}>
{status === 0 ? '可使用' : '已使用/已失效'}
......@@ -21,12 +21,12 @@ export function CouponList({ items, status }: { items: CouponItem[]; status: num
</Space>
<Typography.Text>
<Typography.Text strong type='danger' style={{ fontSize: 22 }}>
¥{coupon.discount || '0.00'}
¥{coupon.reduced_price || '0.00'}
</Typography.Text>
 满 {coupon.threshold || '0'} 元可用
 满 {coupon.norm_price || '0'} 元可用
</Typography.Text>
<Typography.Text type='secondary'>
有效期:{coupon.startTime || '—'} 至 {coupon.endTime || '—'}
有效期至:{coupon.end_time || '—'}
</Typography.Text>
</Space>
</Card>
......
import { useQuery } from '@tanstack/react-query'
import { Alert, Button, Empty, Pagination, Segmented, Skeleton, Space, Typography } from 'antd'
import { Empty, Pagination, Segmented, Skeleton, Space, Typography } from 'antd'
import type { CouponStatus } from '../api'
import { CouponList } from '../components/CouponList'
import { couponsOptions } from '../query'
......@@ -12,7 +12,7 @@ function totalFor(page: number, pageSize: number, count: number, total?: number)
export function MyCouponsView() {
const queryState = useCouponsQueryState()
const couponsQuery = useQuery(couponsOptions(queryState.state))
const items = couponsQuery.data?.items ?? []
const items = couponsQuery.data?.list ?? []
return (
<main>
......@@ -33,15 +33,6 @@ export function MyCouponsView() {
onChange={(status) => void queryState.setStatus(status)}
/>
{couponsQuery.isPending ? <Skeleton active paragraph={{ rows: 8 }} /> : null}
{couponsQuery.isError ? (
<Alert
type='error'
showIcon
title='优惠券加载失败'
description={couponsQuery.error.message}
action={<Button onClick={() => void couponsQuery.refetch()}>重试</Button>}
/>
) : null}
{couponsQuery.data && items.length === 0 ? <Empty description='暂无优惠券' /> : null}
{items.length ? (
<>
......
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
import type { MyListParams } from '../api'
export interface DiscussedBook {
......@@ -28,11 +27,12 @@ function text(value: unknown) {
}
export async function getDiscussedBooks(params: MyListParams): Promise<DiscussedBookList> {
const response = await http.post('/api/app/v1/members/Information/myComment', {
const data = record(
await post('/api/app/v1/members/Information/myComment', {
page: params.page,
page_size: params.pageSize,
})
const data = record(unwrapApiData(response))
}),
)
const source = Array.isArray(data.list) ? data.list : []
return {
......
import { useQuery } from '@tanstack/react-query'
import {
Alert,
Button,
Empty,
Image,
List,
Pagination,
Statistic,
Skeleton,
Typography,
} from 'antd'
import { Empty, Image, List, Pagination, Statistic, Skeleton, Typography } from 'antd'
import { Link } from 'react-router'
import { useMyListQueryState } from '../../query-state'
import { discussedBooksOptions } from '../query'
......@@ -28,15 +18,6 @@ export function MyDiscussionsView() {
<Typography.Title level={1}>我的评论</Typography.Title>
<Typography.Paragraph type='secondary'>按图书汇总你发表主题和回复。</Typography.Paragraph>
{discussionsQuery.isPending ? <Skeleton active paragraph={{ rows: 10 }} /> : null}
{discussionsQuery.isError ? (
<Alert
type='error'
showIcon
title='讨论加载失败'
description={discussionsQuery.error.message}
action={<Button onClick={() => void discussionsQuery.refetch()}>重试</Button>}
/>
) : null}
{discussionsQuery.data && books.length === 0 ? <Empty description='暂无讨论' /> : null}
{books.length ? (
<>
......
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
import type { MyListParams } from '../api'
export interface FavoriteBook {
......@@ -34,11 +33,12 @@ function number(value: unknown) {
}
export async function getFavorites(params: MyListParams): Promise<FavoriteList> {
const response = await http.post('/api/app/v1/members/Information/myCollection', {
const data = record(
await post('/api/app/v1/members/Information/myCollection', {
page: params.page,
page_size: params.pageSize,
})
const data = record(unwrapApiData(response))
}),
)
const source = Array.isArray(data.list) ? data.list : []
return {
......@@ -58,11 +58,10 @@ export async function getFavorites(params: MyListParams): Promise<FavoriteList>
}
export async function removeFavorite(bookId: string) {
const response = await http.post('/api/app/v1/book/Information/collection', {
const data = await post<unknown>('/api/app/v1/book/Information/collection', {
book_id: bookId,
is_select: '0',
})
const data = unwrapApiData<unknown>(response)
const payload = record(data)
return (
data === 1 ||
......
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
Button,
Empty,
Image,
......@@ -36,23 +35,6 @@ export function MyFavoritesView() {
<main>
<Typography.Title level={1}>我的收藏</Typography.Title>
{favoritesQuery.isPending ? <Skeleton active paragraph={{ rows: 10 }} /> : null}
{favoritesQuery.isError ? (
<Alert
type='error'
showIcon
title='收藏加载失败'
description={favoritesQuery.error.message}
action={<Button onClick={() => void favoritesQuery.refetch()}>重试</Button>}
/>
) : null}
{removeMutation.isError ? (
<Alert
type='error'
showIcon
title='取消收藏失败'
description={removeMutation.error.message}
/>
) : null}
{favoritesQuery.data && favorites.length === 0 ? <Empty description='暂无收藏' /> : null}
{favorites.length ? (
<>
......
import { unwrapApiData } from '@/api/unwrap'
import type { ApiEnvelope } from '@/api/types'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
export async function submitFeedback(proposal: string) {
const data = unwrapApiData(
await http.post<ApiEnvelope<Record<string, unknown>>>(
'/api/app/v1/members/Information/proposal',
{
const data = await post<Record<string, unknown>>('/api/app/v1/members/Information/proposal', {
proposal,
},
),
)
})
if (data.is_success !== 1 && data.is_success !== '1' && data.is_success !== true) {
throw new Error('反馈提交失败')
}
......
import { useMutation } from '@tanstack/react-query'
import { Alert, Button, Card, Form, Input, Typography, message } from 'antd'
import { Button, Card, Form, Input, Typography, message } from 'antd'
import { useNavigate } from 'react-router'
import { submitFeedback } from '../api'
......@@ -21,15 +21,6 @@ export function FeedbackView() {
return (
<main>
<Typography.Title level={1}>意见反馈</Typography.Title>
{mutation.isError ? (
<Alert
style={{ marginBottom: 16 }}
type='error'
showIcon
title='反馈提交失败'
description={mutation.error.message}
/>
) : null}
<Card style={{ maxWidth: 680 }}>
<Form
form={form}
......
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
import type { MyListParams } from '../api'
export interface ReadRecordBook {
......@@ -34,11 +33,12 @@ function list(value: unknown) {
}
export async function getReadRecords(params: MyListParams): Promise<ReadRecordGroup[]> {
const response = await http.post('/api/app/v1/members/Information/myReadRecord', {
const data = record(
await post('/api/app/v1/members/Information/myReadRecord', {
page: params.page,
page_size: params.pageSize,
})
const data = record(unwrapApiData(response))
}),
)
return list(data.list).map((group) => {
const item = record(group)
......
import { useQuery } from '@tanstack/react-query'
import { Alert, Button, Empty, Image, List, Skeleton, Space, Tag, Typography } from 'antd'
import { Empty, Image, List, Skeleton, Space, Tag, Typography } from 'antd'
import { Link } from 'react-router'
import { useMyListQueryState } from '../../query-state'
import { readRecordsOptions } from '../query'
......@@ -13,15 +13,6 @@ export function MyHistoryView() {
<main>
<Typography.Title level={1}>学习记录</Typography.Title>
{recordsQuery.isPending ? <Skeleton active paragraph={{ rows: 12 }} /> : null}
{recordsQuery.isError ? (
<Alert
type='error'
showIcon
title='学习记录加载失败'
description={recordsQuery.error.message}
action={<Button onClick={() => void recordsQuery.refetch()}>重试</Button>}
/>
) : null}
{recordsQuery.data && groups.length === 0 ? <Empty description='暂无学习记录' /> : null}
<Space orientation='vertical' size={20} style={{ display: 'flex' }}>
{groups.map((group) => (
......
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
export interface MessageItem {
id: string
......@@ -62,11 +61,10 @@ function toMessage(value: unknown): MessageItem {
}
export async function getMessages(params: MessageListParams): Promise<MessageListResult> {
const response = await http.post('/api/app/v1/message/Message/getList', {
const data = await post<unknown>('/api/app/v1/message/Message/getList', {
page: params.page,
page_size: params.pageSize,
})
const data = unwrapApiData<unknown>(response)
const payload = record(data)
return {
items: list(data).map(toMessage),
......@@ -75,12 +73,10 @@ export async function getMessages(params: MessageListParams): Promise<MessageLis
}
export async function getUnreadMessageCount() {
const response = await http.post('/api/app/v1/message/Message/getMessageNums', {})
return number(record(unwrapApiData<unknown>(response)).num)
return number(record(await post('/api/app/v1/message/Message/getMessageNums')).num)
}
export async function markMessageRead(id: string) {
const response = await http.post('/api/app/v1/message/Message/editOneStatus', { id })
const data = record(unwrapApiData<unknown>(response))
const data = record(await post('/api/app/v1/message/Message/editOneStatus', { id }))
return data.is_success === 1 || data.is_success === '1' || data.is_success === true
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Empty, Pagination, Skeleton, Space, Typography } from 'antd'
import { Empty, Pagination, Skeleton, Space, Typography } from 'antd'
import { useNavigate } from 'react-router'
import { markMessageRead, type MessageItem } from '../api'
import { MessageList } from '../components/MessageList'
......@@ -57,18 +57,6 @@ export function MyMessagesView() {
</Typography.Text>
</Space>
{messagesQuery.isPending ? <Skeleton active paragraph={{ rows: 10 }} /> : null}
{messagesQuery.isError ? (
<Alert
type='error'
showIcon
title='消息加载失败'
description={messagesQuery.error.message}
action={<Button onClick={() => void messagesQuery.refetch()}>重试</Button>}
/>
) : null}
{readMutation.isError ? (
<Alert type='error' showIcon title='操作失败' description={readMutation.error.message} />
) : null}
{messagesQuery.data && items.length === 0 ? <Empty description='暂无消息' /> : null}
{items.length ? (
<>
......
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
import type { MyListParams } from '../api'
export interface NotedBook {
......@@ -36,11 +35,12 @@ function number(value: unknown) {
}
export async function getNotedBooks(params: MyListParams): Promise<NotedBookList> {
const response = await http.post('/api/app/v1/members/Information/myNotes', {
const data = record(
await post('/api/app/v1/members/Information/myNotes', {
page: params.page,
page_size: params.pageSize,
})
const data = record(unwrapApiData(response))
}),
)
const source = Array.isArray(data.list) ? data.list : []
return {
......
import { useQuery } from '@tanstack/react-query'
import {
Alert,
Button,
Empty,
Image,
List,
Pagination,
Space,
Statistic,
Skeleton,
Typography,
} from 'antd'
import { Empty, Image, List, Pagination, Space, Statistic, Skeleton, Typography } from 'antd'
import { Link } from 'react-router'
import { useMyListQueryState } from '../../query-state'
import { notedBooksOptions } from '../query'
......@@ -29,15 +18,6 @@ export function MyNotesView() {
<Typography.Title level={1}>我的笔记</Typography.Title>
<Typography.Paragraph type='secondary'>按书籍汇总笔记、高亮和划线。</Typography.Paragraph>
{notesQuery.isPending ? <Skeleton active paragraph={{ rows: 10 }} /> : null}
{notesQuery.isError ? (
<Alert
type='error'
showIcon
title='笔记加载失败'
description={notesQuery.error.message}
action={<Button onClick={() => void notesQuery.refetch()}>重试</Button>}
/>
) : null}
{notesQuery.data && books.length === 0 ? <Empty description='暂无笔记' /> : null}
{books.length ? (
<>
......
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
export type OrderStatus = 0 | 1 | 2 | 3 | 4
......@@ -115,8 +114,7 @@ export async function getOrders(params: OrderListParams): Promise<OrderListResul
status: params.status,
}
if (params.keyword.trim()) request.keys = params.keyword.trim()
const response = await http.post('/api/app/v1/orders/Orders/getOrdersList', request)
const data = unwrapApiData<unknown>(response)
const data = await post<unknown>('/api/app/v1/orders/Orders/getOrdersList', request)
const payload = record(data)
return {
items: list(data).map(toOrder),
......@@ -125,10 +123,11 @@ export async function getOrders(params: OrderListParams): Promise<OrderListResul
}
export async function getOrderDetail(orderNumber: string): Promise<OrderDetail> {
const response = await http.post('/api/app/v1/orders/Orders/getInfoOrdersnum', {
const data = record(
await post('/api/app/v1/orders/Orders/getInfoOrdersnum', {
ordersnum: orderNumber,
})
const data = record(unwrapApiData<unknown>(response))
}),
)
const base = toOrder(data)
return {
...base,
......
......@@ -47,14 +47,9 @@ export function OrderList({
<Space wrap size='middle' style={{ marginBottom: 10 }}>
{order.books.map((book) => (
<Space key={book.recordId || book.bookId}>
<Image
width={38}
height={52}
src={book.image}
alt=''
preview={false}
fallback=''
/>
{book.image ? (
<Image width={38} height={52} src={book.image} alt='' preview={false} />
) : null}
<Typography.Text>{book.name || '图书'}</Typography.Text>
</Space>
))}
......
import { useQuery } from '@tanstack/react-query'
import {
Alert,
Button,
Empty,
Input,
Pagination,
Segmented,
Skeleton,
Space,
Typography,
} from 'antd'
import { Empty, Input, Pagination, Segmented, Skeleton, Space, Typography } from 'antd'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import type { OrderStatus } from '../api'
......@@ -62,15 +52,6 @@ export function MyOrdersView() {
onChange={(status) => void queryState.setStatus(status)}
/>
{ordersQuery.isPending ? <Skeleton active paragraph={{ rows: 10 }} /> : null}
{ordersQuery.isError ? (
<Alert
type='error'
showIcon
title='订单加载失败'
description={ordersQuery.error.message}
action={<Button onClick={() => void ordersQuery.refetch()}>重试</Button>}
/>
) : null}
{ordersQuery.data && items.length === 0 ? <Empty description='暂无订单' /> : null}
{items.length ? (
<>
......
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
Button,
......@@ -13,7 +13,8 @@ import {
Typography,
} from 'antd'
import { Link, useParams } from 'react-router'
import { orderDetailOptions } from '../query'
import { getPayScene, payOrderAgain } from '@/modules/purchase/api'
import { orderDetailOptions, orderKeys } from '../query'
const statuses: Record<number, { label: string; color: string }> = {
1: { label: '待支付', color: 'orange' },
......@@ -24,8 +25,25 @@ const statuses: Record<number, { label: string; color: string }> = {
export function OrderDetailView() {
const { orderNumber = '' } = useParams()
const queryClient = useQueryClient()
const orderQuery = useQuery(orderDetailOptions(orderNumber))
const order = orderQuery.data
const payMutation = useMutation({
mutationFn: async () => {
const payment = await payOrderAgain(orderNumber, getPayScene())
if (payment.is_moneys !== 0 && !payment.pay_url) {
throw new Error('未获取到支付宝支付地址')
}
return payment
},
onSuccess: (payment) => {
if (payment.is_moneys === 0) {
void queryClient.invalidateQueries({ queryKey: orderKeys.detail(orderNumber) })
return
}
if (payment.pay_url) window.location.assign(payment.pay_url)
},
})
if (orderQuery.isPending)
return (
......@@ -33,18 +51,6 @@ export function OrderDetailView() {
<Skeleton active paragraph={{ rows: 12 }} />
</main>
)
if (orderQuery.isError)
return (
<main>
<Alert
type='error'
showIcon
title='订单详情加载失败'
description={orderQuery.error.message}
action={<Button onClick={() => void orderQuery.refetch()}>重试</Button>}
/>
</main>
)
if (!order)
return (
<main>
......@@ -53,6 +59,7 @@ export function OrderDetailView() {
)
const status = statuses[order.status] ?? { label: '订单状态未知', color: 'default' }
const canPayInBrowser = order.status === 1 && order.payType === 2
return (
<main>
<Space orientation='vertical' size='middle' style={{ width: '100%' }}>
......@@ -66,8 +73,21 @@ export function OrderDetailView() {
<Alert
type='info'
showIcon
title='待支付'
description='请使用移动端完成支付。'
message='待支付'
description={
canPayInBrowser ? '可以直接使用支付宝网页完成支付。' : '请使用 APP 完成支付。'
}
action={
canPayInBrowser ? (
<Button
type='primary'
loading={payMutation.isPending}
onClick={() => payMutation.mutate()}
>
支付宝支付
</Button>
) : undefined
}
/>
) : null}
<Card title='订单信息'>
......
import { Alert, Avatar, Button, Card, Skeleton, Statistic, Typography } from 'antd'
import { Avatar, Card, Skeleton, Statistic, Typography } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import { myStatisticsOptions } from '../../query'
......@@ -31,25 +31,14 @@ export function MyOverviewView() {
return <Skeleton active paragraph={{ rows: 10 }} />
}
if (statisticsQuery.isError) {
return (
<Alert
showIcon
type='error'
title='个人数据加载失败'
description={statisticsQuery.error.message}
action={<Button onClick={() => void statisticsQuery.refetch()}>重试</Button>}
/>
)
}
const statistics = statisticsQuery.data
if (!statistics) return null
return (
<main className='my-overview'>
<section aria-label='用户信息'>
<div className='my-overview__profile'>
<Avatar size={48} src={statistics.headImg}>
<Avatar size={48} src={statistics.headImg || undefined}>
{statistics.name?.slice(0, 1)}
</Avatar>
<div className='my-overview__profile-copy'>
......
import { unwrapApiData } from '@/api/unwrap'
import type { ApiEnvelope } from '@/api/types'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
export interface MemberProfile {
name: string
......@@ -31,11 +29,7 @@ function isSuccessful(data: ApiRecord) {
}
export async function getMemberProfile(): Promise<MemberProfile> {
const data = record(
unwrapApiData(
await http.post<ApiEnvelope<unknown>>('/api/app/v1/members/Information/getMyStatistics', {}),
),
)
const data = record(await post('/api/app/v1/members/Information/getMyStatistics'))
return {
name: text(data.name),
phone: text(data.phone),
......@@ -52,13 +46,11 @@ export interface UpdateMemberProfileInput {
export async function updateMemberProfile(input: UpdateMemberProfileInput) {
const data = record(
unwrapApiData(
await http.post<ApiEnvelope<unknown>>('/api/app/v1/members/Information/editInformation', {
await post('/api/app/v1/members/Information/editInformation', {
name: input.name,
sex: input.sex,
...(input.headImg ? { head_img: input.headImg } : {}),
}),
),
)
if (!isSuccessful(data)) throw new Error('个人资料保存失败')
}
import { unwrapApiData } from '@/api/unwrap'
import type { ApiEnvelope } from '@/api/types'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
const bucket = 'zxts-user-file'
const publicDomain = 'https://zxts-user-file.zijingebook.com'
......@@ -19,9 +17,7 @@ function createObjectKey(file: File) {
}
export async function uploadAvatar(file: File) {
const token = unwrapApiData(
await http.post<ApiEnvelope<UploadToken>>('/api/app/v1/common/Alioss/getUploadToken', {}),
)
const token = await post<UploadToken>('/api/app/v1/common/Alioss/getUploadToken')
const objectKey = createObjectKey(file)
const { default: OSS } = await import('ali-oss')
const client = new OSS({
......
......@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { UploadOutlined } from '@ant-design/icons'
import {
Alert,
Avatar,
Button,
Card,
......@@ -15,7 +14,7 @@ import {
message,
} from 'antd'
import type { UploadProps } from 'antd'
import { useSessionStore } from '@/modules/auth/session'
import { useSessionStore } from '@/stores/session'
import { myKeys } from '../../query'
import { updateMemberProfile, type UpdateMemberProfileInput } from '../api'
import { uploadAvatar } from '../avatar-upload'
......@@ -69,17 +68,7 @@ export function ProfileView() {
}, [form, profileQuery.data])
if (profileQuery.isPending) return <Skeleton active paragraph={{ rows: 8 }} />
if (profileQuery.isError) {
return (
<Alert
type='error'
showIcon
title='个人资料加载失败'
description={profileQuery.error.message}
action={<Button onClick={() => void profileQuery.refetch()}>重试</Button>}
/>
)
}
if (!profileQuery.data) return null
const avatarUploadProps: UploadProps = {
accept: 'image/jpeg,image/png,image/webp',
......@@ -104,7 +93,6 @@ export function ProfileView() {
message.success('头像上传成功')
} catch (error) {
options.onError?.(error as Error)
message.error(error instanceof Error ? error.message : '头像上传失败')
} finally {
setUploading(false)
}
......@@ -114,15 +102,6 @@ export function ProfileView() {
return (
<main>
<Typography.Title level={1}>个人资料</Typography.Title>
{saveMutation.isError ? (
<Alert
style={{ marginBottom: 16 }}
type='error'
showIcon
title='个人资料保存失败'
description={saveMutation.error.message}
/>
) : null}
<Card style={{ maxWidth: 680 }}>
<Form
form={form}
......@@ -141,7 +120,7 @@ export function ProfileView() {
>
{() => (
<Form.Item label='头像' extra='支持 JPG、PNG、WebP,大小不超过 5MB。'>
<Avatar size={72} src={form.getFieldValue('headImg')}>
<Avatar size={72} src={form.getFieldValue('headImg') || undefined}>
{form.getFieldValue('name')?.slice(0, 1)}
</Avatar>
<Upload {...avatarUploadProps}>
......
import type { RouteObject } from 'react-router'
import { RequireSession } from '@/modules/auth/RequireSession'
import { RequireSession } from '@/router/RequireSession'
import { lazyRoute } from '@/router/lazy-route'
import { routes as couponRoutes } from './coupons/routes'
import { routes as feedbackRoutes } from './feedback/routes'
......
import { unwrapApiData } from '@/api/unwrap'
import type { ApiEnvelope } from '@/api/types'
import { getDeviceUuid } from '@/modules/auth/device'
import { hashMemberPassword } from '@/modules/auth/api'
import { http } from '@/utils/http'
import { getDeviceUuid } from '@/utils/device'
import { post } from '@/utils/http'
import { hashPassword } from '@/utils/password'
type ApiRecord = Record<string, unknown>
......@@ -21,7 +19,7 @@ async function successfulPost(
payload: Record<string, unknown>,
failureMessage: string,
) {
const data = record(unwrapApiData(await http.post<ApiEnvelope<unknown>>(path, payload)))
const data = record(await post(path, payload))
if (!isSuccessful(data)) throw new Error(failureMessage)
}
......@@ -29,8 +27,8 @@ export async function changeMemberPassword(password: string, repassword: string)
await successfulPost(
'/api/app/v1/members/Information/changePasswd',
{
password: hashMemberPassword(password),
repassword: hashMemberPassword(repassword),
password: hashPassword(password),
repassword: hashPassword(repassword),
},
'密码修改失败',
)
......
import { useMutation } from '@tanstack/react-query'
import { Alert, Button, Card, Form, Input, Typography, message } from 'antd'
import { Button, Card, Form, Input, Typography, message } from 'antd'
import { useNavigate } from 'react-router'
import { useSessionStore } from '@/modules/auth/session'
import { useSessionStore } from '@/stores/session'
import { changeMemberPassword } from '../api'
interface PasswordValues {
......@@ -28,15 +28,6 @@ export function ChangePasswordView() {
return (
<main>
<Typography.Title level={1}>修改密码</Typography.Title>
{mutation.isError ? (
<Alert
style={{ marginBottom: 16 }}
type='error'
showIcon
title='密码修改失败'
description={mutation.error.message}
/>
) : null}
<Card style={{ maxWidth: 520 }}>
<Form
form={form}
......
import { useEffect, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Card, Form, Input, Space, Typography, message } from 'antd'
import { Button, Card, Form, Input, Space, Typography, message } from 'antd'
import { useNavigate } from 'react-router'
import { profileKeys } from '../../profile/query'
import { changeMemberPhone, sendChangePhoneCode } from '../api'
......@@ -49,24 +49,6 @@ export function ChangePhoneView() {
return (
<main>
<Typography.Title level={1}>修改手机号</Typography.Title>
{sendMutation.isError ? (
<Alert
style={{ marginBottom: 16 }}
type='error'
showIcon
title='验证码发送失败'
description={sendMutation.error.message}
/>
) : null}
{changeMutation.isError ? (
<Alert
style={{ marginBottom: 16 }}
type='error'
showIcon
title='手机号修改失败'
description={changeMutation.error.message}
/>
) : null}
<Card style={{ maxWidth: 520 }}>
<Form
form={form}
......
import { useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { Alert, Button, Card, Descriptions, Modal, Space, Typography, message } from 'antd'
import { Button, Card, Descriptions, Modal, Space, Typography, message } from 'antd'
import { useNavigate } from 'react-router'
import { useSessionStore } from '@/modules/auth/session'
import { useSessionStore } from '@/stores/session'
import { getMemberProfile } from '../../profile/api'
import { profileKeys } from '../../profile/query'
import { deleteMemberAccount } from '../api'
......@@ -24,25 +24,6 @@ export function SecurityView() {
return (
<main>
<Typography.Title level={1}>账号安全</Typography.Title>
{profileQuery.isError ? (
<Alert
style={{ marginBottom: 16 }}
type='error'
showIcon
title='账号信息加载失败'
description={profileQuery.error.message}
action={<Button onClick={() => void profileQuery.refetch()}>重试</Button>}
/>
) : null}
{deleteMutation.isError ? (
<Alert
style={{ marginBottom: 16 }}
type='error'
showIcon
title='账号注销失败'
description={deleteMutation.error.message}
/>
) : null}
<Card
style={{ maxWidth: 680, marginBottom: 16 }}
loading={profileQuery.isPending}
......
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
export type WalletTab = 'points' | 'coins'
......@@ -52,11 +51,10 @@ function source(data: unknown) {
}
export async function getPointRecords(params: WalletListParams): Promise<WalletListResult> {
const response = await http.post('/api/app/v1/members/Information/getIntegralList', {
const data = await post<unknown>('/api/app/v1/members/Information/getIntegralList', {
page: params.page,
page_size: params.pageSize,
})
const data = unwrapApiData<unknown>(response)
const payload = record(data)
return {
items: source(data).map(toWalletRecord),
......@@ -65,11 +63,10 @@ export async function getPointRecords(params: WalletListParams): Promise<WalletL
}
export async function getCoinRecords(params: WalletListParams): Promise<WalletListResult> {
const response = await http.post('/api/app/v1/members/Information/getBeanList', {
const data = await post<unknown>('/api/app/v1/members/Information/getBeanList', {
page: params.page,
page_size: params.pageSize,
})
const data = unwrapApiData<unknown>(response)
const payload = record(data)
return {
items: source(data).map(toWalletRecord),
......
import { useQuery } from '@tanstack/react-query'
import { Alert, Button, Empty, Pagination, Skeleton, Space, Tabs, Typography } from 'antd'
import { Empty, Pagination, Skeleton, Space, Tabs, Typography } from 'antd'
import { WalletRecordList } from '../components/WalletRecordList'
import { coinRecordsOptions, pointRecordsOptions } from '../query'
import { useWalletQueryState } from '../query-state'
......@@ -40,15 +40,6 @@ export function MyWalletView() {
]}
/>
{query.isPending ? <Skeleton active paragraph={{ rows: 10 }} /> : null}
{query.isError ? (
<Alert
type='error'
showIcon
title={`${kind}流水加载失败`}
description={query.error.message}
action={<Button onClick={() => void query.refetch()}>重试</Button>}
/>
) : null}
{query.data && records.length === 0 ? <Empty description={`暂无${kind}流水`} /> : null}
{records.length ? (
<>
......
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
import type { MyListParams } from '../api'
export interface WrongQuestionBook {
......@@ -28,11 +27,12 @@ function text(value: unknown) {
}
export async function getWrongQuestionBooks(params: MyListParams): Promise<WrongQuestionBookList> {
const response = await http.post('/api/app/v1/members/Information/myWrongQuestion', {
const data = record(
await post('/api/app/v1/members/Information/myWrongQuestion', {
page: params.page,
page_size: params.pageSize,
})
const data = record(unwrapApiData(response))
}),
)
const source = Array.isArray(data.list) ? data.list : []
return {
......
import { useQuery } from '@tanstack/react-query'
import {
Alert,
Button,
Empty,
Image,
List,
Pagination,
Statistic,
Skeleton,
Typography,
} from 'antd'
import { Empty, Image, List, Pagination, Statistic, Skeleton, Typography } from 'antd'
import { Link } from 'react-router'
import { useMyListQueryState } from '../../query-state'
import { wrongQuestionBooksOptions } from '../query'
......@@ -28,15 +18,6 @@ export function MyWrongQuestionsView() {
<Typography.Title level={1}>错题本</Typography.Title>
<Typography.Paragraph type='secondary'>按图书汇总答错的题目。</Typography.Paragraph>
{wrongQuestionsQuery.isPending ? <Skeleton active paragraph={{ rows: 10 }} /> : null}
{wrongQuestionsQuery.isError ? (
<Alert
type='error'
showIcon
title='错题加载失败'
description={wrongQuestionsQuery.error.message}
action={<Button onClick={() => void wrongQuestionsQuery.refetch()}>重试</Button>}
/>
) : null}
{wrongQuestionsQuery.data && books.length === 0 ? <Empty description='暂无错题' /> : null}
{books.length ? (
<>
......
import { post } from '@/utils/http'
export type PayScene = 'web' | 'wap'
export interface PaymentOrder {
ordersnum: string
pay_url?: string
is_moneys: number
pay_type?: number
}
export interface PaymentStatus {
ordersnum: string
pay_success: number
}
export function getPayScene(): PayScene {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
? 'wap'
: 'web'
}
function getReturnUrl() {
return typeof window === 'undefined' ? '' : `${window.location.origin}/payment/alipay/return`
}
export async function createBookAlipayOrder(input: {
bookId: string
totalPrice: string
payScene: PayScene
}) {
return post<PaymentOrder>('/api/app/v1/orders/Orders/createOrders', {
book_ids_list: JSON.stringify([{ book_id: input.bookId, price: input.totalPrice, cart_id: 0 }]),
total_price: input.totalPrice,
coupon_rec_id: 0,
integral: 0,
type: 2,
pay_scene: input.payScene,
return_url: getReturnUrl(),
})
}
export async function payOrderAgain(orderNumber: string, payScene: PayScene) {
return post<PaymentOrder>('/api/app/v1/orders/Orders/payAgain', {
ordersNum: orderNumber,
pay_scene: payScene,
return_url: getReturnUrl(),
})
}
export async function getPaymentStatus(orderNumber: string) {
return post<PaymentStatus>('/api/app/v1/orders/Orders/getOrdersStatus', {
ordersnum: orderNumber,
})
}
import type { RouteObject } from 'react-router'
import { RequireSession } from '@/router/RequireSession'
import { AlipayReturnView } from './views/AlipayReturnView'
export const routes: RouteObject[] = [
{
path: 'payment/alipay/return',
element: (
<RequireSession>
<AlipayReturnView />
</RequireSession>
),
},
]
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Button, Result, Skeleton, Space } from 'antd'
import { useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router'
import { getPaymentStatus } from '../api'
function orderNumberFromSearch(searchParams: URLSearchParams) {
return searchParams.get('ordersnum') || searchParams.get('out_trade_no') || ''
}
export function AlipayReturnView() {
const [searchParams] = useSearchParams()
const queryClient = useQueryClient()
const pollingStartedAt = useRef(Date.now())
const orderNumber = orderNumberFromSearch(searchParams)
const statusQuery = useQuery({
queryKey: ['purchase', 'payment-status', orderNumber],
queryFn: () => getPaymentStatus(orderNumber),
enabled: Boolean(orderNumber),
refetchInterval: (query) =>
query.state.data?.pay_success === 1 || Date.now() - pollingStartedAt.current > 60_000
? false
: 2000,
refetchIntervalInBackground: true,
})
useEffect(() => {
if (statusQuery.data?.pay_success !== 1) return
void queryClient.invalidateQueries({ queryKey: ['book'] })
void queryClient.invalidateQueries({ queryKey: ['my'] })
}, [queryClient, statusQuery.data?.pay_success])
if (!orderNumber) {
return <Result status='warning' title='缺少订单号' subTitle='请从订单列表重新确认支付结果。' />
}
if (statusQuery.isPending) return <Skeleton active paragraph={{ rows: 6 }} />
if (statusQuery.data?.pay_success !== 1) {
return (
<Result
status='info'
title='支付结果确认中'
subTitle='支付宝已返回,订单状态还在同步,请稍候。'
extra={
<Space>
<Button onClick={() => void statusQuery.refetch()}>重新确认</Button>
<Button type='primary' href={`/my/orders/${orderNumber}`}>
查看订单
</Button>
</Space>
}
/>
)
}
return (
<Result
status='success'
title='支付成功'
subTitle='订单已完成,现在可以阅读已购买的图书。'
extra={
<Button type='primary' href={`/my/orders/${orderNumber}`}>
查看订单
</Button>
}
/>
)
}
import { ApiError } from '@/api/errors'
import { unwrapApiData } from '@/api/unwrap'
import type { ApiEnvelope } from '@/api/types'
import { http } from '@/utils/http'
import { post } from '@/utils/http'
export interface ReaderChapter {
id: string
......@@ -214,11 +211,6 @@ export class ReaderChapterLockedError extends Error {
}
}
async function post<T>(url: string, data: Record<string, unknown>): Promise<T> {
const response = await http.post<ApiEnvelope<T>>(url, data)
return unwrapApiData(response)
}
export async function getReaderChapters(bookId: string): Promise<ReaderChapter[]> {
const data = await post<unknown>('/api/app/v1/book/Information/getBookChapter', {
book_id: bookId,
......@@ -227,22 +219,20 @@ export async function getReaderChapters(bookId: string): Promise<ReaderChapter[]
}
export async function getSectionContent(bookId: string, chapterId: string): Promise<ReaderSection> {
const response = await http.post<ApiEnvelope<unknown>>(
'/api/app/v1/book/Information/getSectionContent',
{
let body: Record<string, unknown>
try {
body = await post('/api/app/v1/book/Information/getSectionContent', {
book_id: bookId,
chapter_id: chapterId,
},
)
const body = response.data
const payload = record(body.data)
if (body.code === 3001 || Number(payload.code) === 3001) throw new ReaderChapterLockedError()
if (body.code !== 200) {
throw new ApiError({
kind: 'business',
message: body.message ?? body.msg ?? '章节内容加载失败',
})
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 3001) {
throw new ReaderChapterLockedError()
}
throw error
}
const payload = record(body)
if (Number(payload.code) === 3001) throw new ReaderChapterLockedError()
return {
content: string(payload.content),
previousChapter: toSectionLink(payload.up_chapter),
......
......@@ -29,7 +29,6 @@ interface ReaderContentProps {
error: Error | null
restoreAnchor: string
onAnchorChange: (anchor: string, progress: number) => void
onRetry: () => void
hasPreviousChapter: boolean
hasNextChapter: boolean
onPreviousChapter: () => void
......@@ -203,7 +202,6 @@ export function ReaderContent({
error,
restoreAnchor,
onAnchorChange,
onRetry,
hasPreviousChapter,
hasNextChapter,
onPreviousChapter,
......@@ -366,19 +364,6 @@ export function ReaderContent({
/>
)
}
if (error)
return (
<Result
status='error'
title='章节加载失败'
subTitle={error.message}
extra={
<Button type='primary' onClick={onRetry}>
重试
</Button>
}
/>
)
if (!section?.content)
return <Empty className='reader-content__state' description='本章节暂无内容' />
......
import { CloseOutlined } from '@ant-design/icons'
import { useMutation, useQuery } from '@tanstack/react-query'
import {
Alert,
Button,
Card,
Checkbox,
......@@ -231,7 +230,6 @@ export function ReaderInteractionLayer({
message.success('提交成功')
await practiceQuery.refetch()
},
onError: (error) => message.warning(error.message),
})
const gallery = useMemo(() => {
......@@ -257,7 +255,6 @@ export function ReaderInteractionLayer({
extra={<Button type='text' icon={<CloseOutlined />} onClick={onClose} />}
>
{infoQuery.isLoading ? <Spin size='small' /> : null}
{infoQuery.error ? <Alert type='error' showIcon title={infoQuery.error.message} /> : null}
{content ? <Html>{content}</Html> : !infoQuery.isLoading ? <Empty /> : null}
{link ? (
<Typography.Link onClick={() => openReaderLink(link)}>查看详情</Typography.Link>
......@@ -299,15 +296,13 @@ export function ReaderInteractionLayer({
}
onCancel={onClose}
>
{modalType === 'image' && interaction?.type === 'image' ? (
{modalType === 'image' && interaction?.type === 'image' && interaction.src ? (
<Image className='reader-preview-image' src={interaction.src} alt={interaction.title} />
) : null}
{modalType === 'expand' ? (
infoQuery.isLoading ? (
<Spin />
) : infoQuery.error ? (
<Alert type='error' showIcon title={infoQuery.error.message} />
) : infoQuery.data?.content ? (
<div
className='reader-rich-dialog'
......@@ -333,12 +328,12 @@ export function ReaderInteractionLayer({
{modalType === 'gallery' ? (
infoQuery.isLoading ? (
<Spin />
) : infoQuery.error ? (
<Alert type='error' showIcon title={infoQuery.error.message} />
) : gallery.length ? (
<Image.PreviewGroup>
<div className='reader-gallery'>
{gallery.map((item) => (
{gallery
.filter((item) => item.url)
.map((item) => (
<figure key={`${item.url}-${item.title}`}>
<Image src={item.url} alt={item.title} />
<figcaption>
......@@ -357,8 +352,6 @@ export function ReaderInteractionLayer({
{modalType === 'practice' ? (
practiceQuery.isLoading ? (
<Spin />
) : practiceQuery.error ? (
<Alert type='error' showIcon title={practiceQuery.error.message} />
) : practiceQuery.data?.questions.length ? (
<Space className='reader-practice' orientation='vertical' size='middle'>
{practiceQuery.data.questions.map((question, index) => (
......
......@@ -10,7 +10,6 @@ import {
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
Avatar,
Button,
Empty,
......@@ -68,12 +67,6 @@ interface CreateNoteValues extends NoteFormValues {
quote?: ReaderTextQuote
}
function MutationError({ error }: { error: Error | null }) {
return error ? (
<Alert type='error' showIcon title='操作未完成' description={error.message} />
) : null
}
function NoteList({
notes,
onEdit,
......@@ -424,14 +417,6 @@ export function ReaderSidePanel({
{panel === 'notes' ? (
<>
{notesQuery.isPending ? <Skeleton active paragraph={{ rows: 7 }} /> : null}
{notesQuery.isError ? (
<Alert
type='error'
showIcon
title='笔记加载失败'
description={notesQuery.error.message}
/>
) : null}
{notesQuery.data ? (
<NoteList
notes={notesQuery.data}
......@@ -442,9 +427,6 @@ export function ReaderSidePanel({
}
/>
) : null}
<MutationError
error={createNoteMutation.error ?? editNoteMutation.error ?? deleteNoteMutation.error}
/>
</>
) : (
<>
......@@ -464,14 +446,6 @@ export function ReaderSidePanel({
</Tooltip>
</div>
{commentsQuery.isPending ? <Skeleton active avatar paragraph={{ rows: 8 }} /> : null}
{commentsQuery.isError ? (
<Alert
type='error'
showIcon
title='讨论加载失败'
description={commentsQuery.error.message}
/>
) : null}
{commentsQuery.data?.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description='还没有章节讨论' />
) : null}
......@@ -487,11 +461,6 @@ export function ReaderSidePanel({
}
/>
))}
<MutationError
error={
addCommentMutation.error ?? praiseMutation.error ?? deleteCommentMutation.error
}
/>
</>
)}
</div>
......
import type { RouteObject } from 'react-router'
import { Outlet } from 'react-router'
import { RequireSession } from '@/modules/auth/RequireSession'
import { RequireSession } from '@/router/RequireSession'
import { lazyRoute } from '@/router/lazy-route'
export const routes: RouteObject[] = [
......
import { useMutation, useQuery } from '@tanstack/react-query'
import { LeftOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons'
import { Alert, Button, Skeleton, Tooltip, Typography } from 'antd'
import { Button, Skeleton, Tooltip, Typography } from 'antd'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router'
import {
......@@ -152,13 +152,7 @@ export function ReaderView() {
[progressMutation, setAnchor],
)
if (!bookId || !chapterId) {
return (
<main className='reader-view'>
<Alert type='error' showIcon title='阅读地址无效' description='缺少图书或章节编号。' />
</main>
)
}
if (!bookId || !chapterId) return null
return (
<main className='reader-view'>
......@@ -201,14 +195,6 @@ export function ReaderView() {
{!readerState.catalogueCollapsed ? (
<>
{chaptersQuery.isLoading ? <Skeleton active paragraph={{ rows: 10 }} /> : null}
{chaptersQuery.error ? (
<Alert
type='warning'
showIcon
title='目录加载失败'
description={chaptersQuery.error.message}
/>
) : null}
{chaptersQuery.data ? (
<ReaderChapterTree
chapters={chaptersQuery.data}
......@@ -230,7 +216,6 @@ export function ReaderView() {
error={sectionQuery.error}
restoreAnchor={readerState.anchor}
onAnchorChange={handleAnchorChange}
onRetry={() => void sectionQuery.refetch()}
hasPreviousChapter={Boolean(adjacentChapters.previous)}
hasNextChapter={Boolean(adjacentChapters.next)}
onPreviousChapter={() => changeChapter(adjacentChapters.previous?.id ?? '')}
......@@ -240,15 +225,6 @@ export function ReaderView() {
onTextQuoteAction={handleTextQuoteAction}
onAnnotationSelect={handleAnnotationSelect}
/>
{progressMutation.error || readRecordMutation.error || readTimeMutation.error ? (
<Alert
className='reader-view__report-error'
type='warning'
showIcon
title='阅读记录暂未同步'
description='不影响继续阅读,网络恢复后可再次滚动同步。'
/>
) : null}
</section>
<aside className='reader-view__panel' aria-label='笔记与讨论'>
{readerState.panelCollapsed ? (
......
import type { ApiEnvelope } from '@/api/types'
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
import type { SearchState } from './query-state'
export interface SearchBook {
bookId: string
bookName: string
authors: string
img: string
introduction: string
readNum: number
isCollection: boolean
price: string
vipPrice: string
isFree: boolean
}
export interface SearchBooksResult {
list: SearchBook[]
total: number
}
type UnknownRecord = Record<string, unknown>
function record(value: unknown): UnknownRecord {
return typeof value === 'object' && value !== null ? (value as UnknownRecord) : {}
}
function string(value: unknown): string {
return value === null || value === undefined ? '' : String(value)
}
function number(value: unknown): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function list(value: unknown): unknown[] {
if (Array.isArray(value)) return value
const payload = record(value)
for (const key of ['list', 'data', 'rows']) {
if (Array.isArray(payload[key])) return payload[key]
}
return []
}
function total(value: unknown, fallback: number): number {
const payload = record(value)
for (const key of ['total', 'count', 'total_count']) {
if (payload[key] !== undefined) return number(payload[key])
}
return fallback
}
function toBook(value: unknown): SearchBook {
const item = record(value)
return {
bookId: string(item.book_id),
bookName: string(item.book_name),
authors: string(item.authors),
img: string(item.img),
introduction: string(item.introduction),
readNum: number(item.read_num),
isCollection: number(item.is_collection) === 1,
price: string(item.price),
vipPrice: string(item.vip_price),
isFree: number(item.is_free) === 1,
}
}
async function post<T>(url: string, data: Record<string, unknown>): Promise<T> {
const response = await http.post<ApiEnvelope<T>>(url, data)
return unwrapApiData(response)
}
export async function getSearchHistory(): Promise<string[]> {
return list(await post<unknown>('/api/app/v1/book/Information/serachRecord', {}))
.map((item) => string(record(item).name).trim())
.filter(Boolean)
}
export async function clearSearchHistory(): Promise<boolean> {
const data = record(await post<unknown>('/api/app/v1/book/Information/serachDel', {}))
return number(data.is_success) === 1
}
export async function getSearchBooks(state: SearchState): Promise<SearchBooksResult> {
const data = await post<unknown>('/api/app/v1/book/Information/searchBook', {
page: state.page,
page_size: state.pageSize,
keyword: state.q,
})
const books = list(data).map(toBook)
const fallbackTotal =
books.length === state.pageSize
? state.page * state.pageSize + 1
: (state.page - 1) * state.pageSize + books.length
return { list: books, total: total(data, fallbackTotal) }
}
export async function setSearchBookCollection(bookId: string, selected: boolean): Promise<boolean> {
const data = record(
await post<unknown>('/api/app/v1/book/Information/collection', {
book_id: bookId,
is_select: selected ? '1' : '0',
}),
)
return number(data.is_success) === 1
}
import { Button, Card, Image, Tag, Tooltip, Typography } from 'antd'
import { Link } from 'react-router'
import defaultBookCover from '@/assets/default-book-cover.jpg'
import type { SearchBook } from '../api'
interface SearchBookCardProps {
book: SearchBook
view: 'grid' | 'list'
isUpdatingCollection: boolean
onCollectionChange: (book: SearchBook) => void
}
function priceLabel(book: SearchBook) {
if (book.isFree) return '免费'
if (book.vipPrice) return `¥${book.vipPrice}`
return book.price ? `¥${book.price}` : '收费'
}
export function SearchBookCard({
book,
view,
isUpdatingCollection,
onCollectionChange,
}: SearchBookCardProps) {
return (
<Card className={`search-book-card search-book-card--${view}`} hoverable>
<Link
className='search-book-card__cover'
to={`/book/${book.bookId}`}
aria-label={`查看《${book.bookName}》详情`}
>
<Image
preview={false}
src={book.img || defaultBookCover}
alt={`${book.bookName}封面`}
fallback={defaultBookCover}
/>
</Link>
<div className='search-book-card__content'>
<div>
<Typography.Title level={4} ellipsis={{ rows: 2 }}>
<Link to={`/book/${book.bookId}`}>{book.bookName}</Link>
</Typography.Title>
<Typography.Text type='secondary'>{book.authors || '作者未知'}</Typography.Text>
</div>
{view === 'list' && book.introduction ? (
<Typography.Paragraph ellipsis={{ rows: 2 }} type='secondary'>
{book.introduction}
</Typography.Paragraph>
) : null}
<div className='search-book-card__meta'>
<Tag color={book.isFree ? 'green' : 'magenta'}>{priceLabel(book)}</Tag>
<Typography.Text type='secondary'>{book.readNum} 人读过</Typography.Text>
<Tooltip title={book.isCollection ? '取消收藏' : '收藏'}>
<Button
aria-label={
book.isCollection ? `取消收藏《${book.bookName}》` : `收藏《${book.bookName}》`
}
type='text'
danger={book.isCollection}
loading={isUpdatingCollection}
onClick={() => onCollectionChange(book)}
>
{book.isCollection ? '♥' : '♡'}
</Button>
</Tooltip>
</div>
</div>
</Card>
)
}
import { Button, Empty, Flex, Tag, Typography } from 'antd'
interface SearchHistoryProps {
items: string[]
isClearing: boolean
onClear: () => void
onSelect: (keyword: string) => void
}
export function SearchHistory({ items, isClearing, onClear, onSelect }: SearchHistoryProps) {
return (
<section className='search-history' aria-labelledby='search-history-title'>
<Flex justify='space-between' align='center' gap={16}>
<Typography.Title id='search-history-title' level={4}>
搜索历史
</Typography.Title>
{items.length ? (
<Button type='link' danger loading={isClearing} onClick={onClear}>
清空历史
</Button>
) : null}
</Flex>
{items.length ? (
<Flex wrap gap='small'>
{items.map((item) => (
<Tag.CheckableTag key={item} checked={false} onChange={() => onSelect(item)}>
{item}
</Tag.CheckableTag>
))}
</Flex>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description='暂无搜索历史' />
)}
</section>
)
}
import { createParser, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
const views = ['grid', 'list'] as const
function parseAsMinimumInteger(minimum: number) {
return createParser({
parse: (value) => {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed >= minimum ? parsed : null
},
serialize: (value) => String(value),
})
}
export const searchParams = {
q: parseAsString.withDefault(''),
page: parseAsMinimumInteger(1).withDefault(1),
pageSize: parseAsMinimumInteger(1).withDefault(20),
view: parseAsStringLiteral(views).withDefault('list'),
}
export interface SearchState {
q: string
page: number
pageSize: number
view: (typeof views)[number]
}
export function useSearchQueryState() {
const [state, setState] = useQueryStates(searchParams, {
clearOnDefault: true,
history: 'replace',
})
return {
state,
search: (q: string) => setState({ q, page: 1 }, { history: 'push' }),
setView: (view: SearchState['view']) => setState({ view }, { history: 'replace' }),
setPagination: (page: number, pageSize: number) =>
setState({ page, pageSize }, { history: 'push' }),
}
}
import { queryOptions } from '@tanstack/react-query'
import { getSearchBooks, getSearchHistory } from './api'
import type { SearchState } from './query-state'
export const searchKeys = {
all: ['search'] as const,
history: () => ['search', 'history'] as const,
books: (state: SearchState) => ['search', 'books', state.q, state.page, state.pageSize] as const,
}
export function searchHistoryOptions() {
return queryOptions({ queryKey: searchKeys.history(), queryFn: getSearchHistory })
}
export function searchBooksOptions(state: SearchState) {
return queryOptions({
queryKey: searchKeys.books(state),
queryFn: () => getSearchBooks(state),
enabled: Boolean(state.q.trim()),
})
}
import type { RouteObject } from 'react-router'
import { lazyRoute } from '@/router/lazy-route'
export const routes: RouteObject[] = [
{
path: 'search',
lazy: lazyRoute(() => import('./views/SearchView'), 'SearchView'),
},
]
.search-view {
display: grid;
width: 100%;
box-sizing: border-box;
margin: 0 auto;
padding: 0;
gap: 24px;
}
.search-view h1 {
margin-bottom: 4px;
}
.search-history h4,
.search-results h3 {
margin: 0;
}
.search-history .ant-empty {
margin-block: 28px 0;
}
.search-results {
display: grid;
gap: 20px;
}
.search-results__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.search-books {
display: grid;
gap: 14px;
}
.search-books--grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.search-books--list {
grid-template-columns: 1fr;
}
.search-book-card .ant-card-body {
display: flex;
height: 100%;
gap: 14px;
}
.search-book-card--grid .ant-card-body {
display: flex;
padding: 12px;
}
.search-book-card.ant-card {
border-color: transparent;
box-shadow: 0 1px 2px rgb(31 41 55 / 4%);
transition:
transform 160ms ease-out,
box-shadow 160ms ease-out;
}
.search-book-card.ant-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgb(31 41 55 / 12%);
}
.search-book-card.ant-card:active {
transform: scale(0.985);
}
.search-book-card__cover {
display: block;
flex: 0 0 112px;
width: 112px;
overflow: hidden;
border-radius: 4px;
background: #f5f5f5;
}
.search-book-card--grid .search-book-card__cover {
flex: 0 0 92px;
width: 92px;
height: 124px;
margin-bottom: 0;
}
.search-book-card__cover .ant-image,
.search-book-card__cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.search-book-card__content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
justify-content: space-between;
gap: 12px;
}
.search-book-card__content h4,
.search-book-card__content p {
margin: 0;
}
.search-book-card__meta {
display: flex;
align-items: center;
gap: 8px;
}
.search-book-card__meta .ant-btn {
margin-left: auto;
}
.search-view__pagination {
justify-content: center;
}
@media (max-width: 640px) {
.search-view {
padding: 0;
}
.search-results__toolbar {
align-items: flex-start;
flex-direction: column;
}
.search-books--grid {
grid-template-columns: 1fr;
}
.search-book-card--list .ant-card-body {
gap: 12px;
}
.search-book-card__cover {
flex-basis: 88px;
width: 88px;
}
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Empty, Input, Pagination, Segmented, Skeleton, Space, Typography } from 'antd'
import { useEffect, useState } from 'react'
import { clearSearchHistory, setSearchBookCollection, type SearchBook } from '../api'
import { SearchBookCard } from '../components/SearchBookCard'
import { SearchHistory } from '../components/SearchHistory'
import { searchBooksOptions, searchHistoryOptions, searchKeys } from '../query'
import { useSearchQueryState } from '../query-state'
import './SearchView.css'
export function SearchView() {
const queryState = useSearchQueryState()
const { state } = queryState
const [draft, setDraft] = useState(state.q)
const [changingBookId, setChangingBookId] = useState<string>()
const queryClient = useQueryClient()
const historyQuery = useQuery(searchHistoryOptions())
const booksQuery = useQuery(searchBooksOptions(state))
useEffect(() => setDraft(state.q), [state.q])
const clearMutation = useMutation({
mutationFn: clearSearchHistory,
onSuccess: (succeeded) => {
if (!succeeded) throw new Error('搜索历史清空失败')
queryClient.setQueryData(searchKeys.history(), [])
},
})
const collectionMutation = useMutation({
mutationFn: async ({ book, selected }: { book: SearchBook; selected: boolean }) => {
setChangingBookId(book.bookId)
const succeeded = await setSearchBookCollection(book.bookId, selected)
if (!succeeded) throw new Error('收藏状态更新失败')
return { book, selected }
},
onSuccess: ({ book, selected }) => {
queryClient.setQueryData(searchKeys.books(state), (previous: typeof booksQuery.data) =>
previous
? {
...previous,
list: previous.list.map((item) =>
item.bookId === book.bookId ? { ...item, isCollection: selected } : item,
),
}
: previous,
)
},
onSettled: () => setChangingBookId(undefined),
})
function submit(keyword = draft) {
const q = keyword.trim()
if (!q) return
setDraft(q)
void queryState
.search(q)
.then(() => queryClient.invalidateQueries({ queryKey: searchKeys.history() }))
}
return (
<main className='search-view'>
<div>
<Typography.Title level={1}>搜索图书</Typography.Title>
<Typography.Paragraph type='secondary'>
按书名或作者查找你想学习的内容。
</Typography.Paragraph>
</div>
<Input.Search
size='large'
value={draft}
placeholder='请输入书籍名称或作者'
enterButton='搜索'
allowClear
onChange={(event) => setDraft(event.target.value)}
onSearch={() => submit()}
/>
{clearMutation.error ? (
<Alert
type='error'
showIcon
title='清空搜索历史失败'
description={(clearMutation.error as Error).message}
/>
) : null}
{collectionMutation.error ? (
<Alert
type='error'
showIcon
title='收藏操作失败'
description={(collectionMutation.error as Error).message}
/>
) : null}
{!state.q.trim() ? (
historyQuery.error ? (
<Alert
type='error'
showIcon
title='搜索历史加载失败'
description={(historyQuery.error as Error).message}
/>
) : historyQuery.isLoading ? (
<Skeleton active paragraph={{ rows: 2 }} />
) : (
<SearchHistory
items={historyQuery.data ?? []}
isClearing={clearMutation.isPending}
onClear={() => clearMutation.mutate()}
onSelect={submit}
/>
)
) : (
<section className='search-results' aria-live='polite'>
<div className='search-results__toolbar'>
<Typography.Title level={3}>“{state.q}” 的搜索结果</Typography.Title>
<Segmented<SearchBook['isCollection'] extends boolean ? 'grid' | 'list' : never>
value={state.view}
options={[
{ label: '列表', value: 'list' },
{ label: '网格', value: 'grid' },
]}
onChange={(value) => void queryState.setView(value)}
/>
</div>
{booksQuery.error ? (
<Alert
type='error'
showIcon
title='图书加载失败'
description={(booksQuery.error as Error).message}
action={<a onClick={() => void booksQuery.refetch()}>重试</a>}
/>
) : booksQuery.isLoading ? (
<Skeleton active paragraph={{ rows: 8 }} />
) : booksQuery.data?.list.length ? (
<>
<div className={`search-books search-books--${state.view}`}>
{booksQuery.data.list.map((book) => (
<SearchBookCard
key={book.bookId}
book={book}
view={state.view}
isUpdatingCollection={changingBookId === book.bookId}
onCollectionChange={(item) =>
collectionMutation.mutate({ book: item, selected: !item.isCollection })
}
/>
))}
</div>
<Space className='search-view__pagination'>
<Pagination
current={state.page}
pageSize={state.pageSize}
total={booksQuery.data.total}
hideOnSinglePage
showSizeChanger={false}
showTotal={(total) => `共 ${total} 本`}
onChange={(page, pageSize) => void queryState.setPagination(page, pageSize)}
/>
</Space>
</>
) : (
<Empty description='暂无图书,请检查输入内容' />
)}
</section>
)}
</main>
)
}
import type { ReactNode } from 'react'
import { Navigate, Outlet } from 'react-router'
import { useSessionStore } from './session'
import { useSessionStore } from '@/stores/session'
interface RequireSessionProps {
children?: ReactNode
......
import { LearningLayout } from '@/layouts/LearningLayout'
export function RootLayout() {
return <LearningLayout />
}
export const paths = {
login: '/login',
library: '/library',
my: '/my',
book: (bookId: string) => `/book/${bookId}`,
reader: (bookId: string, chapterId: string) => `/reader/${bookId}/${chapterId}`,
} as const
......@@ -6,7 +6,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { appRoutes } from './routes'
import { useSessionStore } from '@/modules/auth/session'
import { useSessionStore } from '@/stores/session'
const testSession = {
members_id: 1,
......
import { createBrowserRouter, Navigate, type RouteObject } from 'react-router'
import { RootLayout } from './RootLayout'
import { LoadingFallback } from './LoadingFallback'
import { lazyRoute } from './lazy-route'
import { LearningLayout } from '@/layouts/LearningLayout'
import { routes as authRoutes } from '@/modules/auth/routes'
import { routes as bookRoutes } from '@/modules/book/routes'
import { routes as libraryRoutes } from '@/modules/library/routes'
import { routes as myRoutes } from '@/modules/my/routes'
import { routes as purchaseRoutes } from '@/modules/purchase/routes'
import { routes as readerRoutes } from '@/modules/reader/routes'
import { routes as helpRoutes } from '@/modules/my/help/routes'
import { routes as helpRoutes } from '@/modules/help/routes'
export const appChildRoutes: RouteObject[] = [
{ index: true, element: <Navigate to='/library' replace /> },
{
path: 'about',
lazy: lazyRoute(() => import('@/modules/my/help/views/AboutView'), 'AboutView'),
lazy: lazyRoute(() => import('@/modules/help/views/AboutView'), 'AboutView'),
},
{
path: 'contact',
lazy: lazyRoute(() => import('@/modules/my/help/views/ContactView'), 'ContactView'),
lazy: lazyRoute(() => import('@/modules/help/views/ContactView'), 'ContactView'),
},
...helpRoutes,
...libraryRoutes,
...bookRoutes,
...myRoutes,
...purchaseRoutes,
]
export const appRoutes: RouteObject[] = [
......@@ -32,7 +34,7 @@ export const appRoutes: RouteObject[] = [
...authRoutes,
{
path: '/',
element: <RootLayout />,
element: <LearningLayout />,
children: appChildRoutes,
},
...readerRoutes,
......
import { create } from 'zustand'
import type { MemberSession } from './types'
export interface MemberSession {
members_id: number
name?: string
head_img?: string
phone: string
grade_name?: string
token: string
access_token: string
}
const sessionKey = 'ebook-learning-session'
let onSessionExpired: (() => void) | undefined
function readSession(): MemberSession | null {
try {
......@@ -30,20 +38,3 @@ export const useSessionStore = create<SessionState>((set) => ({
set({ session: null })
},
}))
export function getMemberToken() {
return useSessionStore.getState().session?.token
}
export function getMemberSession() {
return useSessionStore.getState().session
}
export function configureSessionExpiry(handler: () => void) {
onSessionExpired = handler
}
export function expireMemberSession() {
useSessionStore.getState().clearSession()
onSessionExpired?.()
}
import { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AxiosHeaders, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { describe, expect, it } from 'vitest'
import { useSessionStore } from '@/stores/session'
import { http } from './http'
const authMocks = vi.hoisted(() => ({
expireMemberSession: vi.fn(),
}))
describe('HTTP client', () => {
it('adds the stored member token to requests', async () => {
useSessionStore.getState().setSession({
members_id: 1,
phone: '13800000000',
token: 'member-token',
access_token: 'access-token',
})
const requestHandler = http.interceptors.request.handlers?.[0]?.fulfilled
const config = { headers: new AxiosHeaders() } as InternalAxiosRequestConfig
vi.mock('@/modules/auth/session', () => ({ expireMemberSession: authMocks.expireMemberSession }))
const result = await requestHandler?.(config)
import { http } from './http'
expect(result?.headers?.get('Authorization')).toBe('member-token')
})
function unauthorizedError() {
const config = { headers: new AxiosHeaders() }
it('rejects business errors', async () => {
const response = {
data: { code: 401, message: '会话已失效' },
status: 401,
statusText: 'Unauthorized',
headers: new AxiosHeaders(),
config,
} as AxiosResponse
return new AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, response)
}
function unauthorizedResponse() {
const config = { headers: new AxiosHeaders() }
return {
data: { code: 401, message: '会话已失效' },
data: { code: 422, message: '不可购买' },
status: 200,
statusText: 'OK',
headers: new AxiosHeaders(),
config,
config: { headers: new AxiosHeaders() },
} as AxiosResponse
}
const responseHandler = http.interceptors.response.handlers?.[0]?.fulfilled
describe('HTTP authentication expiry', () => {
beforeEach(() => authMocks.expireMemberSession.mockClear())
it('clears the session directly when the server returns an unauthorized HTTP status', async () => {
const rejectionHandler = http.interceptors.response.handlers?.[0]?.rejected
await expect(rejectionHandler?.(unauthorizedError())).rejects.toMatchObject({
kind: 'auth',
status: 401,
})
expect(authMocks.expireMemberSession).toHaveBeenCalledOnce()
await expect(Promise.resolve(responseHandler?.(response))).rejects.toMatchObject({
code: 422,
message: '不可购买',
})
it('clears the session when the APP envelope returns an unauthorized code', async () => {
const fulfillmentHandler = http.interceptors.response.handlers?.[0]?.fulfilled
await expect(
Promise.resolve(fulfillmentHandler?.(unauthorizedResponse())),
).rejects.toMatchObject({ kind: 'auth', status: 401 })
expect(authMocks.expireMemberSession).toHaveBeenCalledOnce()
})
})
import axios from 'axios'
import { ApiError, normalizeApiError } from '@/api/errors'
import { applyRequestAuthentication, getDevelopmentRequestAuth } from '@/api/http-auth'
import { expireMemberSession } from '@/modules/auth/session'
import { message } from 'antd'
import { useSessionStore } from '@/stores/session'
interface ApiEnvelope<T> {
code: number
message?: string
msg?: string
data: T
}
export const http = axios.create({
withCredentials: true,
headers: { 'Content-Type': 'application/json', 'X-Skip-Sig': '1' },
headers: {
'Content-Type': 'application/json',
...(import.meta.env.DEV ? { 'X-Skip-Sig': '1' } : {}),
},
})
http.interceptors.request.use(async (config) => {
const request = await applyRequestAuthentication(
{
baseURL: config.baseURL,
url: config.url,
method: config.method,
params: config.params,
data: config.data,
headers: (config.headers?.toJSON() ?? {}) as Record<string, string>,
},
getDevelopmentRequestAuth(),
)
for (const [key, value] of Object.entries(request.headers)) {
config.headers.set(key, value)
}
http.interceptors.request.use((config) => {
const token = useSessionStore.getState().session?.token
if (token) config.headers.set('Authorization', token)
return config
})
http.interceptors.response.use(
(response) => {
if (isUnauthorized(response.status, response.data)) {
expireMemberSession()
return Promise.reject(new ApiError({ kind: 'auth', status: unauthorizedStatus(response.status, response.data) }))
const { data } = response
if (data?.code !== 200) {
const errorMessage = data?.message || data?.msg || '请求失败'
message.error(errorMessage)
if (data?.code === 401 || data?.code === 403) {
useSessionStore.getState().clearSession()
if (location.pathname !== '/login') location.href = '/login'
}
const error = new Error(errorMessage) as Error & { code: number }
error.code = data.code
return Promise.reject(error)
}
return response
},
(error) => {
if (axios.isAxiosError(error) && isUnauthorized(error.response?.status, error.response?.data)) expireMemberSession()
return Promise.reject(normalizeApiError(error))
const response = error.response
const data = response?.data
if (!axios.isCancel(error)) {
message.error(data?.message || data?.msg || error.message || '网络请求失败')
if (response?.status === 401 || response?.status === 403) {
useSessionStore.getState().clearSession()
if (location.pathname !== '/login') location.href = '/login'
}
}
return Promise.reject(error)
},
)
function unauthorizedStatus(status: number | undefined, data: unknown) {
if (status === 401 || status === 403) return status
const code = getResponseCode(data)
return code === 401 || code === 403 ? code : undefined
}
function isUnauthorized(status: number | undefined, data: unknown) {
return unauthorizedStatus(status, data) !== undefined
}
function getResponseCode(data: unknown) {
if (typeof data !== 'object' || data === null) return undefined
const value = (data as Record<string, unknown>).code
const code = Number(value)
return Number.isFinite(code) ? code : undefined
export async function post<T>(url: string, data?: unknown): Promise<T> {
const response = await http.post<ApiEnvelope<T>>(url, data)
return response.data.data
}
import { md5 } from 'js-md5'
export function hashPassword(password: string) {
return md5(md5(password))
}
......@@ -13,17 +13,17 @@ export default defineConfig(({ mode }) => {
port: 5173,
strictPort: true,
proxy: {
'/api/app': {
target: 'http://127.0.0.1:7421',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/app/, ''),
},
'/api/admin': {
target: 'http://127.0.0.1:7419',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/admin/, ''),
},
'/api': 'https://ebook.ezijing.com',
// '/api/app': {
// target: 'http://127.0.0.1:7421',
// changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api\/app/, ''),
// },
// '/api/admin': {
// target: 'http://127.0.0.1:7419',
// changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api\/admin/, ''),
// },
},
},
build: {
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论