提交 139b2384 authored 作者: 王鹏飞's avatar 王鹏飞

chore: update

上级 eb9c7e99
...@@ -5,9 +5,9 @@ ...@@ -5,9 +5,9 @@
This repository is a pnpm workspace. Use Node >=22.13 and pnpm 11.13.1 from the repository root. This repository is a pnpm workspace. Use Node >=22.13 and pnpm 11.13.1 from the repository root.
- `apps/admin` is the legacy JavaScript/JSX administration app. It remains on React 18 and Ant Design 5 in Stage 0; Redux, Redux Toolkit, React Redux, and Redux Persist are allowed there. Do not make broad dependency upgrades unless a later task explicitly authorizes them. - `apps/admin` is the legacy JavaScript/JSX administration app. It remains on React 18 and Ant Design 5 in Stage 0; Redux, Redux Toolkit, React Redux, and Redux Persist are allowed there. Do not make broad dependency upgrades unless a later task explicitly authorizes them.
- `apps/learning` is the TypeScript learning app. It uses React/ReactDOM 19, TanStack React Query for server state, nuqs for URL-restorable page state, and Zustand only for transient cross-component client state. The Redux family is forbidden: `redux`, Redux Toolkit, React Redux, and Redux Persist. - `apps/learning` is the TypeScript learning app. It uses React/ReactDOM 19, Ant Design 6, TanStack React Query for server state, nuqs for URL-restorable list controls, and Zustand only for transient cross-component client state. The Redux family is forbidden: `redux`, Redux Toolkit, React Redux, and Redux Persist.
- Apps own their routes, state, API clients, dependencies, and build outputs. Never import another app's `src` directory; extract a deliberate shared package only when a task calls for it. - Apps own their routes, state, API clients, dependencies, and build outputs. Never import another app's `src` directory; extract a deliberate shared package only when a task calls for it.
- Do not add Ant Design to the learning placeholder. If learning later adopts it, use the current stable v6 baseline (`^6.5.1` as of this plan), never admin's v5 dependency. - Learning uses Ant Design `^6.5.1`; standard forms, controls, navigation, dialogs, and feedback primitives must come from it. Admin's Ant Design 5 remains isolated: never import or copy admin UI source into learning.
## Commands ## Commands
...@@ -30,6 +30,10 @@ Currently available root commands: ...@@ -30,6 +30,10 @@ Currently available root commands:
Keep admin's existing JavaScript/JSX conventions: two-space indentation, single quotes, no semicolons, functional components, `PascalCase` components, `camelCase` utilities, and `useXxx` hooks. Keep learning code strict TypeScript and follow its state boundaries above. Keep admin's existing JavaScript/JSX conventions: two-space indentation, single quotes, no semicolons, functional components, `PascalCase` components, `camelCase` utilities, and `useXxx` hooks. Keep learning code strict TypeScript and follow its state boundaries above.
For learning modules, route views orchestrate state hooks, semantic handlers, and layout composition; domain UI belongs in module `components/`. Pass explicit values and semantic callbacks rather than whole state objects or URL/query setters. Components must not read URL or global state directly: URL ownership stays in `query-state` hooks and server-query ownership stays in `query` hooks. Use official/library primitives and utilities directly; create wrappers only for meaningful domain composition or behavior, never trivial pass-through wrappers. Split by independent business responsibility or change reason, not element count. List controls belong in nuqs query state; standalone resource pages use route path parameters, never list-detail modals.
The learning shell is `LearningLayout` with top navigation for `/home`, `/library`, and `/my`; `/` and unknown ordinary routes redirect to `/library`. Library URL state is `keyword`, `category`, `label`, `price`, `sortField`, `sortOrder`, `page`, `pageSize`, and `view`; map it to the APP request fields only in its `api.ts`. A nonempty `keyword` uses the APP `searchBook` API and does not combine with category filters. The book detail is the standalone `/book/:bookId` route, while `/reader/:bookId/:chapterId` owns its independent full-screen layout. Complex top-level domains use nested ownership folders under `modules/my`; each submodule may own its API, query, state, components, and views. Keep simple one-page modules shallow. Browser requests retain `/api/web`; learning's Vite proxy targets `https://ebook-app.ezijing.com` and removes `/api/web`. For current local integration only, the shared HTTP client adds `X-Skip-Sig: 1`; module code must not repeat it, and production builds must not send it. See `apps/learning/AGENTS.md` for local ownership details.
Root `docs/` contains implementation plans and repository documentation. Root `scripts/` is reserved for workspace-wide automation; do not place application code there. Keep app-specific code and assets inside the owning app. Root `docs/` contains implementation plans and repository documentation. Root `scripts/` is reserved for workspace-wide automation; do not place application code there. Keep app-specific code and assets inside the owning app.
Use concise Conventional Commit subjects (`feat:`, `fix:`, `refactor:`, `chore:`, or `docs:`). Preserve user changes, do not modify sibling repositories, and do not push or deploy unless explicitly asked. Use concise Conventional Commit subjects (`feat:`, `fix:`, `refactor:`, `chore:`, or `docs:`). Preserve user changes, do not modify sibling repositories, and do not push or deploy unless explicitly asked.
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
"build:dev": "vite build --mode development", "build:dev": "vite build --mode development",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint --ext .js,.jsx,.ts,.tsx --fix --ignore-path .gitignore ./src", "lint": "eslint --ext .js,.jsx,.ts,.tsx --fix --ignore-path .gitignore ./src",
"test": "node --test src/store/storage-import.test.mjs src/utils/*.test.js" "test": "node --test src/store/storage-import.test.mjs src/utils/*.test.js src/layout/components/*.test.js"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.4.0", "@ant-design/icons": "^5.4.0",
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
"@ezijing/ai-react": "^1.0.40", "@ezijing/ai-react": "^1.0.40",
"@fortaine/fetch-event-source": "^3.0.6", "@fortaine/fetch-event-source": "^3.0.6",
"@reduxjs/toolkit": "^1.9.7", "@reduxjs/toolkit": "^1.9.7",
"@remixicon/react": "^4.9.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-react": "^1.0.6", "@wangeditor/editor-for-react": "^1.0.6",
"ahooks": "^3.8.0", "ahooks": "^3.8.0",
......
html, :root {
--main-color: #aa1941;
}
html,
body, body,
a, a,
h1, h1,
......
...@@ -18,7 +18,7 @@ export default class BaseModalMenu { ...@@ -18,7 +18,7 @@ export default class BaseModalMenu {
} }
exec(editor, value) { exec(editor, value) {
this.$root.render( this.$root.render(
<ConfigProvider locale={zhCN} theme={{ token: { colorPrimary: '#ab1941' } }}> <ConfigProvider locale={zhCN} theme={{ token: { colorPrimary: '#aa1941' } }}>
<App>{value}</App> <App>{value}</App>
</ConfigProvider> </ConfigProvider>
) )
......
import { Menu, Button } from 'antd' import { Button } from 'antd'
import { LeftOutlined } from '@ant-design/icons' import { LeftOutlined } from '@ant-design/icons'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
export default function Breadcrumb({ menuList }) { export default function Breadcrumb() {
const { pathname } = useLocation() const { pathname } = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const currentMenusList =
menuList.find(item => {
const regExp = new RegExp(`^${item.path}`)
return regExp.test(pathname)
})?.children || []
const selectedKeys = currentMenusList
.filter(item => {
const regExp = new RegExp(`^${item.path}`)
return regExp.test(pathname)
})
.map(item => item.key)
// const hasBack = currentMenusList.find(item => { // const hasBack = currentMenusList.find(item => {
// const regExp = new RegExp(`^${item.path}`) // const regExp = new RegExp(`^${item.path}`)
// return regExp.test(pathname) && item.path !== pathname // return regExp.test(pathname) && item.path !== pathname
...@@ -33,16 +21,13 @@ export default function Breadcrumb({ menuList }) { ...@@ -33,16 +21,13 @@ export default function Breadcrumb({ menuList }) {
'/books/sale/detail' '/books/sale/detail'
].includes(pathname) ].includes(pathname)
if (!hasBack) return null
return ( return (
<div className="layout-breadcrumb"> <div className="layout-breadcrumb">
<div> <Button type="text" icon={<LeftOutlined />} onClick={() => navigate(-1)}>
{hasBack && ( 返回
<Button type="text" icon={<LeftOutlined />} onClick={() => navigate(-1)}> </Button>
返回
</Button>
)}
</div>
<Menu className="menu" mode="horizontal" items={currentMenusList} selectedKeys={selectedKeys} style={{ border: 'none' }} />
</div> </div>
) )
} }
...@@ -12,5 +12,5 @@ export default function Logout() { ...@@ -12,5 +12,5 @@ export default function Logout() {
localStorage.clear() localStorage.clear()
navigator('/login') navigator('/login')
} }
return <Button style={{ border: 'none', background: '#f6f6f6' }} icon={<LoginOutlined />} onClick={handleLogout}></Button> return <Button type="text" className="layout-action-button" icon={<LoginOutlined />} onClick={handleLogout}></Button>
} }
import { Menu } from 'antd' import { useEffect, useState } from 'react'
import { useLocation } from 'react-router-dom' import { Tooltip } from 'antd'
import { RiArrowDownSLine } from '@remixicon/react'
import { Link, useLocation } from 'react-router-dom'
import { getOpenMenuKeys, getSelectedMenuKeys } from './menu.utils'
function MenuItem({ item, level, collapsed, openKeys, selectedKeys, onToggle }) {
const hasChildren = Boolean(item.children?.length)
const isOpen = openKeys.includes(item.key)
const isSelected = selectedKeys.includes(item.key)
const entryClassName = [
'layout-menu__entry',
isSelected ? 'layout-menu__entry--selected' : '',
hasChildren && isOpen ? 'layout-menu__entry--open' : '',
].filter(Boolean).join(' ')
const content = (
<>
{item.icon}
<span className="layout-menu__label">{item.title}</span>
{hasChildren ? <RiArrowDownSLine className="layout-menu__arrow" aria-hidden="true" /> : null}
</>
)
return (
<li className={`layout-menu__item layout-menu__item--level-${level}`}>
{hasChildren ? (
<button type="button" className={entryClassName} onClick={() => onToggle(item.key)} title={collapsed ? item.title : undefined} aria-expanded={isOpen}>
{content}
</button>
) : (
<Link className={entryClassName} to={item.path} title={collapsed ? item.title : undefined} aria-current={isSelected ? 'page' : undefined}>
{content}
</Link>
)}
{hasChildren && !collapsed && isOpen ? (
<ul className="layout-menu__child-list">
{item.children.map(child => (
<MenuItem
key={child.key}
item={child}
level={level + 1}
collapsed={collapsed}
openKeys={openKeys}
selectedKeys={selectedKeys}
onToggle={onToggle}
/>
))}
</ul>
) : null}
</li>
)
}
function CollapsedMenuEntry({ item, selectedKeys }) {
const isSelected = selectedKeys.includes(item.key)
const className = `layout-menu__collapsed-entry${isSelected ? ' layout-menu__collapsed-entry--selected' : ''}`
const content = <>{item.icon}<span className="layout-menu__sr-label">{item.title}</span></>
const entry = item.path ? (
<Link className={className} to={item.path} aria-label={item.title} aria-current={isSelected ? 'page' : undefined}>
{content}
</Link>
) : (
<span className={className} aria-label={item.title}>{content}</span>
)
return <Tooltip title={item.title} placement="right">{entry}</Tooltip>
}
function CollapsedMenuGroup({ item, selectedKeys }) {
const hasChildren = Boolean(item.children?.length)
const entries = hasChildren ? item.children : [item]
return (
<li className="layout-menu__collapsed-group">
<div className="layout-menu__collapsed-children">
{entries.map(entry => <CollapsedMenuEntry key={entry.key} item={entry} selectedKeys={selectedKeys} />)}
</div>
</li>
)
}
export default function AppMenu({ menuList, collapsed }) {
const { pathname } = useLocation()
const selectedKeys = getSelectedMenuKeys(menuList, pathname)
const [openKeys, setOpenKeys] = useState([])
useEffect(() => {
setOpenKeys(collapsed ? [] : getOpenMenuKeys(menuList, pathname))
}, [collapsed, menuList, pathname])
export default function AppMenu({ menuList }) { const toggleMenu = key => {
const { pathname } = useLocation() setOpenKeys(keys => keys.includes(key) ? keys.filter(item => item !== key) : [...keys, key])
const selectedKeys = menuList }
.filter(item => {
const regExp = new RegExp(`^${item.path}`) return (
return regExp.test(pathname) <nav className={`layout-menu${collapsed ? ' layout-menu--collapsed' : ''}`} aria-label="后台管理菜单">
}) {collapsed ? (
.map(item => item.key) <ul className="layout-menu__collapsed-list">
{menuList.map(item => <CollapsedMenuGroup key={item.key} item={item} selectedKeys={selectedKeys} />)}
return ( </ul>
<div className="header-menu"> ) : (
<Menu <ul className="layout-menu__list">
className="menu" {menuList.map(item => (
mode="horizontal" <MenuItem
items={menuList} key={item.key}
selectedKeys={selectedKeys} item={item}
style={{ border: 'none' }} level={1}
builtinPlacements={{ collapsed={collapsed}
bottomLeft: { openKeys={openKeys}
points: ['tc', 'bc'], // 子菜单的 "上中" 和 对应菜单的title "下中" 对齐。 selectedKeys={selectedKeys}
overflow: { adjustX: 10, adjustY: 10 }, onToggle={toggleMenu}
offset: [0, 3] //让下拉菜单位移 />
} ))}
}} </ul>
/> )}
</div> </nav>
) )
} }
function isCurrentPath(pathname, menuPath) {
if (!menuPath) return false
if (menuPath === '/') return pathname === '/'
return pathname === menuPath || pathname.startsWith(`${menuPath}/`)
}
export function getSelectedMenuKeys(menuList, pathname) {
let selectedItem
const visit = items => {
items.forEach(item => {
if (isCurrentPath(pathname, item.path) && (!selectedItem || item.path.length > selectedItem.path.length)) {
selectedItem = item
}
if (item.children?.length) visit(item.children)
})
}
visit(menuList)
return selectedItem ? [selectedItem.key] : []
}
export function getOpenMenuKeys(menuList, pathname) {
const openKeys = []
const visit = items => {
items.forEach(item => {
if (item.children?.length) {
if (isCurrentPath(pathname, item.path) || getSelectedMenuKeys(item.children, pathname).length) openKeys.push(item.key)
visit(item.children)
}
})
}
visit(menuList)
return openKeys
}
import test from 'node:test'
import assert from 'node:assert/strict'
import { getOpenMenuKeys, getSelectedMenuKeys } from './menu.utils.js'
test('highlights the current nested menu item', () => {
const menuList = [
{
key: 'books',
path: '/books',
children: [
{ key: 'management', path: '/books/management' },
{ key: 'audit', path: '/books/audit' }
]
}
]
assert.deepEqual(getSelectedMenuKeys(menuList, '/books/management/chapter'), ['management'])
assert.deepEqual(getOpenMenuKeys(menuList, '/books/management/chapter'), ['books'])
})
test('opens a parent with no route when one of its children matches', () => {
const menuList = [
{
key: 'settings',
children: [{ key: 'roles', path: '/settings/roles' }]
}
]
assert.deepEqual(getOpenMenuKeys(menuList, '/settings/roles'), ['settings'])
})
...@@ -24,7 +24,7 @@ function Message() { ...@@ -24,7 +24,7 @@ function Message() {
} }
return ( return (
<> <>
<Button onClick={() => setOpen(true)} style={{ border: 'none', background: '#f6f6f6' }}> <Button type="text" className="layout-action-button" onClick={() => setOpen(true)}>
<Badge dot={dot}> <Badge dot={dot}>
<BellOutlined /> <BellOutlined />
</Badge> </Badge>
......
...@@ -28,7 +28,7 @@ function User() { ...@@ -28,7 +28,7 @@ function User() {
return ( return (
<Dropdown menu={{ items: menuItems }} arrow> <Dropdown menu={{ items: menuItems }} arrow>
<Button style={{ border: 'none', background: '#f6f6f6' }}> <Button type="text" className="layout-user-button">
<Flex align="center" gap="small"> <Flex align="center" gap="small">
{userInfo.pic ? <img src={userInfo.pic} style={{ width: '28px', height: '28px', borderRadius: '50%', objectFit: 'cover' }} /> : <UserOutlined />} {userInfo.pic ? <img src={userInfo.pic} style={{ width: '28px', height: '28px', borderRadius: '50%', objectFit: 'cover' }} /> : <UserOutlined />}
{userInfo.real_name} {userInfo.real_name}
......
import { useState, useEffect, Suspense } from 'react' import { useState, useEffect, Suspense } from 'react'
import { Layout, Flex } from 'antd' import { Button, Layout, Flex } from 'antd'
import { Outlet, Link } from 'react-router-dom' import { Outlet, Link, useLocation } from 'react-router-dom'
import { useDispatch } from 'react-redux' import { useDispatch } from 'react-redux'
import {
RiAdvertisementLine,
RiAppStoreLine,
RiApps2Line,
RiBook2Line,
RiBookOpenLine,
RiCoinsLine,
RiCoupon3Line,
RiCustomerService2Line,
RiDashboardLine,
RiDiscussLine,
RiFeedbackLine,
RiFileList3Line,
RiFileSettingsLine,
RiFolder2Line,
RiGroupLine,
RiHome5Line,
RiMenuFoldLine,
RiMenuUnfoldLine,
RiMedal2Line,
RiNotification3Line,
RiPriceTag3Line,
RiQuestionnaireLine,
RiQuillPenLine,
RiSearchEyeLine,
RiSettings3Line,
RiShieldCheckLine,
RiShieldKeyholeLine,
RiShoppingBag3Line,
RiSmartphoneLine,
RiStore2Line,
RiUser3Line,
RiUserStarLine,
RiAdminLine,
RiLayoutGridLine,
RiPaletteLine,
} from '@remixicon/react'
import { setMenuRouter, setOperationPermissionsList } from '@/store/modules/user' import { setMenuRouter, setOperationPermissionsList } from '@/store/modules/user'
import Menu from './components/menu' import Menu from './components/menu'
...@@ -15,18 +52,95 @@ import logo from '@/assets/images/logo.png' ...@@ -15,18 +52,95 @@ import logo from '@/assets/images/logo.png'
import { getMenuList, checkSSOLogin } from './request' import { getMenuList, checkSSOLogin } from './request'
import { generateToAntdMenus } from '@/utils/breadCrumb' import { generateToAntdMenus } from '@/utils/breadCrumb'
const { Header, Content } = Layout const { Header, Content, Sider } = Layout
function generateMenus(items = [], maxLevel = 2, level = 1) { function getMenuIcon(item, hasChildren) {
return items.map((item) => { const name = item.name || ''
const children = level < maxLevel && item.childs ? generateMenus(item.childs, maxLevel, level + 1) : null const path = item.front_url || ''
return { key: item.id + '', path: item.front_url, label: <Link to={item.front_url}>{item.name}</Link>, children }
}) if (/\/books\/management/.test(path)) return RiQuillPenLine
} if (/\/books\/audit/.test(path)) return RiShieldCheckLine
if (/\/books\/classify/.test(path)) return RiFolder2Line
export default function AppLayout() { if (/\/books\/label/.test(path)) return RiPriceTag3Line
const dispatch = useDispatch() if (/\/books\/sale/.test(path)) return RiStore2Line
const [menuList, setMenuList] = useState([]) if (/\/books\/question-bank/.test(path)) return RiQuestionnaireLine
if (/\/books\/discussion/.test(path)) return RiDiscussLine
if (/\/books\/order-management/.test(path)) return RiShoppingBag3Line
if (/\/books\/design/.test(path)) return RiPaletteLine
if (/\/member\/userlevel/.test(path)) return RiMedal2Line
if (/\/member\/list/.test(path)) return RiUser3Line
if (/\/advertisement\/adsense/.test(path)) return RiLayoutGridLine
if (/\/advertisement\/advert/.test(path)) return RiAdvertisementLine
if (/\/setting\/help/.test(path)) return RiCustomerService2Line
if (/\/setting\/applied/.test(path)) return RiAppStoreLine
if (/\/setting\/personalized-setting/.test(path)) return RiPaletteLine
if (/\/setting\/point-management/.test(path)) return RiCoinsLine
if (/\/setting\/app-version/.test(path)) return RiSmartphoneLine
if (/\/setting\/coupon/.test(path)) return RiCoupon3Line
if (/\/setting\/sensitive/.test(path)) return RiSearchEyeLine
if (/\/setting\/feedback/.test(path)) return RiFeedbackLine
if (/\/jurisdiction\/admin/.test(path)) return RiAdminLine
if (/\/jurisdiction\/role/.test(path)) return RiShieldKeyholeLine
if (/\/teacher\/list/.test(path)) return RiUserStarLine
if (/首页|工作台|概览/.test(name)) return RiHome5Line
if (/图书|教材|章节|书架/.test(name)) return hasChildren ? RiBook2Line : RiBookOpenLine
if (/题库|题目|试题|练习/.test(name)) return RiQuestionnaireLine
if (/分类|标签/.test(name)) return RiPriceTag3Line
if (/会员|用户/.test(name)) return RiUser3Line
if (/教师|老师/.test(name)) return RiUserStarLine
if (/班级|组织|团队/.test(name)) return RiGroupLine
if (/订单|销售|支付|结算/.test(name)) return RiShoppingBag3Line
if (/优惠券|卡券/.test(name)) return RiCoupon3Line
if (/积分|账单|余额/.test(name)) return RiCoinsLine
if (/广告|轮播/.test(name)) return RiAdvertisementLine
if (/讨论|评论/.test(name)) return RiDiscussLine
if (/审核|认证/.test(name)) return RiShieldCheckLine
if (/权限|角色/.test(name)) return RiShieldKeyholeLine
if (/帮助|客服/.test(name)) return RiCustomerService2Line
if (/反馈/.test(name)) return RiFeedbackLine
if (/消息|通知/.test(name)) return RiNotification3Line
if (/应用/.test(name)) return RiAppStoreLine
if (/设置|配置/.test(name)) return RiSettings3Line
if (/内容|编辑/.test(name)) return RiFileSettingsLine
return hasChildren ? RiApps2Line : RiFileList3Line
}
function generateMenus(items = [], maxLevel = 2, level = 1) {
return items.map((item) => {
const children = level < maxLevel && item.childs ? generateMenus(item.childs, maxLevel, level + 1) : null
const Icon = getMenuIcon(item, Boolean(children?.length))
return {
key: item.id + '',
path: item.front_url,
title: item.name,
icon: <Icon className="layout-menu__icon" aria-hidden="true" />,
label: <Link to={item.front_url}>{item.name}</Link>,
children,
}
})
}
function getCurrentMenuTitle(items, pathname) {
let currentMenu
const visit = menuItems => {
menuItems.forEach(item => {
const isMatch = item.path && (pathname === item.path || (item.path !== '/' && pathname.startsWith(`${item.path}/`)))
if (isMatch && (!currentMenu || item.path.length > currentMenu.path.length)) currentMenu = item
if (item.children?.length) visit(item.children)
})
}
visit(items)
return currentMenu?.title || '工作台'
}
export default function AppLayout() {
const dispatch = useDispatch()
const { pathname } = useLocation()
const [menuList, setMenuList] = useState([])
const [collapsed, setCollapsed] = useState(false)
const pageTitle = getCurrentMenuTitle(menuList, pathname)
const getSSOToken = async () => { const getSSOToken = async () => {
const data = await checkSSOLogin() const data = await checkSSOLogin()
...@@ -56,31 +170,45 @@ export default function AppLayout() { ...@@ -56,31 +170,45 @@ export default function AppLayout() {
})() })()
}, []) }, [])
return ( return (
<Layout className="layout-container"> <Layout className="layout-container">
<div className="layout-sticky"> <Header className="layout-header">
<Header className="layout-header"> <div className="layout-brand">
<div className="header-logo"> <Link className="layout-brand__logo" to="/" aria-label="返回工作台首页">
<Link to="/"> <img src={logo} alt="清控紫荆数智学堂" />
<img src={logo} /> </Link>
</Link> </div>
</div> {menuList.length ? (
<Menu menuList={menuList}></Menu> <Flex align="center" gap="small" className="layout-header__actions">
{menuList.length ? ( <Message></Message>
<Flex align="center" gap="small"> <User></User>
<Message></Message> <Logout></Logout>
<User></User> </Flex>
<Logout></Logout> ) : null}
</Flex> </Header>
) : null} <Layout className={`layout-body${collapsed ? ' layout-body--collapsed' : ''}`}>
</Header> <Sider className="layout-sider" width={200} collapsedWidth={56} collapsed={collapsed} theme="light" trigger={null}>
<Breadcrumb menuList={menuList}></Breadcrumb> <div className="layout-sider__body">
</div> <Menu menuList={menuList} collapsed={collapsed}></Menu>
<Content className="layout-content"> </div>
<Suspense> <Button className="layout-sider-toggle" type="text" onClick={() => setCollapsed(value => !value)} aria-label={collapsed ? '展开侧边菜单' : '收起侧边菜单'}>
<Outlet /> {collapsed ? <RiMenuUnfoldLine aria-hidden="true" /> : <RiMenuFoldLine aria-hidden="true" />}
</Suspense> </Button>
</Content> </Sider>
</Layout> <Layout className="layout-main">
) <Content className="layout-content">
} <div className="layout-page-header">
<div className="layout-page-title">{pageTitle}</div>
<Breadcrumb></Breadcrumb>
</div>
<main className="layout-page-content">
<Suspense>
<Outlet />
</Suspense>
</main>
</Content>
</Layout>
</Layout>
</Layout>
)
}
.layout-header { .layout-container {
display: flex; min-height: 100vh;
align-items: center; background: #f6f7f9;
justify-content: space-between; }
height: 50px;
line-height: 50px; .layout-sider {
background-color: #fff; position: fixed !important;
padding: 0 20px; top: 84px;
bottom: 0;
.header-logo { left: 0;
img { height: auto;
height: 32px; overflow: hidden;
vertical-align: middle; background: #f6f7f9 !important;
} border-inline-end: 0 !important;
} box-shadow: none;
z-index: 100;
.header-menu {
flex: 1; .ant-layout-sider-children {
padding: 0 10px; display: flex;
height: 50px; flex-direction: column;
line-height: 50px; height: 100%;
}
.ant-menu-overflow { }
margin-left: 100px;
font-size: 16px !important; .layout-sider__body {
a { flex: 1;
color: inherit; min-height: 0;
} overflow-y: auto;
}
.ant-menu-overflow-item.ant-menu-submenu.ant-menu-submenu-horizontal {
width: 110px; .layout-body {
display: flex; min-height: calc(100vh - 84px);
justify-content: center; padding-left: 200px;
} }
}
.ant-menu-light.ant-menu-horizontal > .ant-menu-submenu::after { .layout-body--collapsed {
content: ''; padding-left: 56px;
display: none; }
}
.ant-menu-light.ant-menu-horizontal > .ant-menu-item:hover, .layout-brand {
.ant-menu-light.ant-menu-horizontal > .ant-menu-submenu:hover { display: flex;
color: #ab1941 !important; align-items: center;
background-color: #ede2e8 !important; height: 84px;
transition: none !important; padding: 0;
.ant-menu-title-content {
color: #ab1941 !important; .layout-brand__logo {
transition: none !important; display: inline-flex;
} align-items: center;
} width: 176px;
.ant-menu-light.ant-menu-horizontal > .ant-menu-item:hover::after { overflow: hidden;
border: none !important; }
transition: none !important;
} img {
.ant-menu-light.ant-menu-horizontal > .ant-menu-item { display: block;
border: none !important; width: 176px;
transition: none !important; max-width: none;
height: 33px;
&::after { object-fit: contain;
content: ''; }
display: none; }
}
} .layout-sider-toggle {
.ant-menu-light.ant-menu-horizontal > .ant-menu-item-selected { display: inline-flex;
// color: #AB1941 !important; align-items: center;
background: #ede2e8 !important; justify-content: center;
.ant-menu-title-content { width: 28px;
color: #ab1941 !important; height: 28px;
} margin: 0 10px 10px;
&::after { padding: 0;
border: none; color: #6f7886;
} background: #f6f7f9;
} border: 0;
.ant-menu-submenu-selected { border-radius: 8px;
background: #ede2e8 !important; cursor: pointer;
&::before {
content: ''; svg {
display: block; width: 16px;
width: 0; height: 16px;
height: 0; }
clear: both;
z-index: 2; &:hover {
border-left: 10px solid transparent; color: #252b36;
border-right: 10px solid transparent; background: #e9ecf1;
border-top: 8px solid #ede2e8; }
bottom: -8px; }
position: absolute;
} .layout-sider.ant-layout-sider-collapsed {
&::after { .layout-menu {
border: none !important; padding-inline: 10px;
} }
.ant-menu-submenu-title {
color: #ab1941 !important; .layout-sider-toggle {
font-weight: bold; margin-inline: 12px;
} }
} }
}
} .layout-menu {
.ant-menu .ant-menu-item { padding: 10px 16px 24px;
text-align: center; }
}
.layout-menu__list,
.layout-breadcrumb { .layout-menu__child-list,
height: 50px; .layout-menu__collapsed-list {
margin: 10px 0; margin: 0;
padding: 2px 20px; padding: 0;
background-color: #fff; list-style: none;
display: flex; }
align-items: center;
.ant-menu { .layout-menu__item {
flex: 1; margin: 2px 0;
justify-content: flex-end; }
}
.ant-menu-item { .layout-menu__entry {
width: 110px; display: flex;
} align-items: center;
} width: 100%;
height: 36px;
.layout-content { padding: 0 10px;
background-color: #fff; color: #424a57;
border-radius: 5px; font-size: 14px;
height: calc(100vh - 120px); line-height: 36px;
padding: 20px; text-align: left;
} text-decoration: none;
background: transparent;
.layout-sticky { border: 0;
position: sticky; border-radius: 18px;
top: 0; cursor: pointer;
z-index: 1000; transition: background-color 160ms ease, color 160ms ease;
background-color: #f7f8fc;
} &:hover {
color: #252b36;
background: #edf0f4;
}
}
.layout-menu__icon {
flex: 0 0 auto;
width: 16px;
height: 16px;
margin-right: 10px;
color: #7a8594;
}
.layout-menu__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.layout-menu__arrow {
flex: 0 0 auto;
width: 16px;
height: 16px;
margin-left: auto;
color: #8a94a2;
transition: transform 160ms ease;
}
.layout-menu__entry--open .layout-menu__arrow {
transform: rotate(180deg);
}
.layout-menu__entry--selected {
color: #252b36;
font-weight: 500;
background: #e5e9ef;
.layout-menu__icon {
color: currentcolor;
}
}
.layout-menu__child-list {
margin: 2px 0 2px 20px;
padding-left: 6px;
background: transparent;
border-left: 1px solid #e3e6eb;
.layout-menu__entry {
width: calc(100% - 10px);
margin-left: 5px;
}
}
.layout-menu--collapsed {
padding-block-start: 8px;
}
.layout-menu__collapsed-group {
display: flex;
flex-direction: column;
align-items: center;
padding: 7px 0;
+ .layout-menu__collapsed-group {
margin-top: 7px;
padding-top: 14px;
border-top: 1px solid #e1e5ea;
}
}
.layout-menu__collapsed-children {
display: flex;
flex-direction: column;
align-items: center;
}
.layout-menu__collapsed-entry {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin: 2px 0;
color: #7a8594;
text-decoration: none;
border-radius: 10px;
transition: background-color 160ms ease, color 160ms ease;
&:hover {
color: #252b36;
background: #edf0f4;
}
.layout-menu__icon {
width: 17px;
height: 17px;
margin: 0;
}
}
.layout-menu__collapsed-entry--selected {
color: #252b36;
background: #e5e9ef;
}
.layout-menu__sr-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.layout-main {
min-width: 0;
background: #f6f7f9;
}
.layout-header {
position: sticky;
top: 0;
z-index: 101;
display: flex;
align-items: center;
justify-content: space-between;
height: 84px;
padding: 0 32px 0 20px;
line-height: normal;
background: #f6f7f9;
border-bottom: 0;
}
.layout-page-title {
display: flex;
align-items: center;
height: 60px;
color: #252b36;
font-size: 22px;
font-weight: 600;
letter-spacing: -0.35px;
}
.layout-page-header {
min-height: 60px;
}
.layout-header__actions {
gap: 2px !important;
}
.layout-action-button,
.layout-user-button {
height: 34px;
color: #535b68;
border-radius: 8px;
}
.layout-action-button {
width: 34px;
padding-inline: 0;
}
.layout-user-button {
padding-inline: 10px;
}
.layout-breadcrumb {
display: flex;
align-items: center;
height: 40px;
margin-top: -8px;
padding: 0;
}
.layout-content {
min-height: calc(100vh - 84px);
margin: 0 32px 40px 20px;
padding: 0;
background: transparent;
border-radius: 0;
box-shadow: none;
}
.layout-page-content {
min-width: 0;
color: #303641;
> .ant-row:first-child {
min-height: 40px;
margin-bottom: 4px;
}
.ant-btn {
border-radius: 8px;
}
.ant-card {
overflow: hidden;
border-color: #e7eaee;
border-radius: 12px;
box-shadow: 0 1px 2px rgb(31 41 55 / 2%);
}
.ant-card-head {
min-height: 52px;
padding-inline: 20px;
border-bottom-color: #edf0f3;
}
.ant-card-body {
padding: 20px;
}
.ant-table-wrapper {
margin-top: 16px;
overflow: hidden;
background: #fff;
border: 1px solid #e7eaee;
border-radius: 12px;
box-shadow: 0 1px 2px rgb(31 41 55 / 2%);
}
.ant-table,
.ant-table-container,
.ant-table-container table > thead > tr:first-child > *:first-child,
.ant-table-container table > thead > tr:first-child > *:last-child {
border-radius: 0;
}
.ant-table {
border: 0;
}
.ant-table-thead > tr > th {
height: 46px;
color: #667085;
font-size: 13px;
font-weight: 500;
background: #f8f9fb !important;
border-bottom: 1px solid #edf0f3;
}
.ant-table-tbody > tr > td {
height: 52px;
color: #303641;
border-bottom: 1px solid #f0f2f4;
}
.ant-table-tbody > tr:nth-child(2n) {
background: transparent;
}
.ant-table-tbody > tr:hover > td {
background: #fafbfc !important;
}
.pagination {
margin-top: 16px;
color: #667085;
}
}
@media (max-width: 960px) {
.layout-brand {
padding-inline: 14px;
}
.layout-header {
padding-right: 16px;
}
.layout-content {
margin-right: 20px;
}
}
...@@ -6,9 +6,9 @@ import { Provider } from 'react-redux' ...@@ -6,9 +6,9 @@ import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react' import { PersistGate } from 'redux-persist/integration/react'
import store, { persistor } from '@/store' import store, { persistor } from '@/store'
import { ConfigProvider, App } from 'antd' import { ConfigProvider, App } from 'antd'
import 'dayjs/locale/zh-cn' import 'dayjs/locale/zh-cn'
import zhCN from 'antd/locale/zh_CN' import zhCN from 'antd/locale/zh_CN'
import MyApp from './App.jsx' import MyApp from './App.jsx'
...@@ -16,7 +16,7 @@ createRoot(document.getElementById('root')).render( ...@@ -16,7 +16,7 @@ createRoot(document.getElementById('root')).render(
// <StrictMode> // <StrictMode>
<Provider store={store}> <Provider store={store}>
<PersistGate loading={null} persistor={persistor}> <PersistGate loading={null} persistor={persistor}>
<ConfigProvider locale={zhCN} theme={{ token: { colorPrimary: '#ab1941' } }}> <ConfigProvider locale={zhCN} theme={{ token: { colorPrimary: '#aa1941' } }}>
<App> <App>
<BrowserRouter basename="/admin"> <BrowserRouter basename="/admin">
<MyApp /> <MyApp />
......
# Learning application boundaries
Flutter `book-app` is the functional source of truth. Preserve its business behavior and API contracts while adapting interaction to desktop browsers; do not copy Flutter widgets mechanically.
## Module and route ownership
- `modules/home` owns `/home`, its official digital-textbook entry, advertisements, and up to four unfiltered book recommendations fetched through the library API contract.
- `modules/download` owns `/download`, the public APP download page and its official download materials. Do not fabricate a direct installation URL when the official channel only provides a QR code; the APP supports iOS and Android.
- `modules/library` owns `/library` filters and list state only. Its URL contract is `keyword`, `category`, `label`, `price`, `sortField`, `sortOrder`, `page`, `pageSize`, and `view`; API mapping belongs only in `api.ts`. A nonempty `keyword` uses `searchBook` and does not combine with category filters.
- `modules/book` owns standalone `/book/:bookId` details.
- `modules/reader` owns the independent full-screen `/reader/:bookId/:chapterId` layout and domain.
- `modules/my/<domain>` owns nested `/my` areas. Keep the My shell in `modules/my/layout` and add child domains below `modules/my/<domain>` rather than a monolithic My view.
## State and UI rules
Use React Query for server facts, nuqs/path parameters for refreshable URL state, Zustand only for transient cross-component UI, and React state for component-local interactions. Route views coordinate URL state, query hooks, and semantic handlers; components receive explicit values and callbacks.
Unauthenticated Web users are redirected by route guards before protected content renders. HTTP `401`/`403` responses, including APP envelope codes, clear the local member session and use the configured SPA redirect to `/login`; do not refresh tokens or show an expired-session error inside business views.
Use Ant Design controls directly for standard menus, forms, paging, feedback, layouts, and dialogs. Create a component only for meaningful domain behavior; do not add pass-through wrappers or recreate Ant Design primitives.
## Browser API security
Browser requests use `/api/web`. During local development only, the shared Axios client sends `X-Skip-Sig: 1` so the existing APP API can be integrated before the Web session/BFF is ready; never repeat this header in module API files. Member token compatibility is centralized in `modules/auth`, and business modules must not own credentials. Production builds must not send `X-Skip-Sig`, APP secrets, or signing keys. Authentication redirects belong to auth/router code, not individual module requests. Never fabricate protected API responses.
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png?v=20260719" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>清控紫荆数智学堂</title> <title>清控紫荆数智学堂</title>
</head> </head>
......
...@@ -11,8 +11,11 @@ ...@@ -11,8 +11,11 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2",
"@tanstack/react-query": "^5.76.2", "@tanstack/react-query": "^5.76.2",
"antd": "^6.5.1",
"axios": "^1.8.4", "axios": "^1.8.4",
"js-md5": "^0.8.3",
"nuqs": "^2.9.0", "nuqs": "^2.9.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
...@@ -20,10 +23,10 @@ ...@@ -20,10 +23,10 @@
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@eslint/js": "^10.0.1",
"@types/node": "^22.13.9", "@types/node": "^22.13.9",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
......
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?.()
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
}
.learning-layout {
min-height: 100vh;
background: var(--learning-canvas);
}
.learning-layout__header {
position: sticky;
z-index: 20;
top: 0;
height: 84px;
padding-inline: 32px;
line-height: normal;
background: rgb(255 255 255 / 86%);
border-bottom: 1px solid rgb(233 235 239 / 72%);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
}
.learning-layout__header-inner {
display: flex;
width: min(var(--learning-content-max-width), 100%);
height: 100%;
margin: 0 auto;
align-items: center;
justify-content: space-between;
}
.learning-layout__brand {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
white-space: nowrap;
}
.learning-layout__brand img {
display: block;
width: auto;
height: 36px;
}
.learning-layout__header-actions {
display: flex;
align-items: center;
min-width: 0;
gap: 16px;
}
.learning-layout__navigation,
.learning-layout__session {
display: flex;
align-items: center;
gap: 4px;
}
.learning-layout__navigation {
min-width: 0;
}
.learning-layout__nav-item {
display: inline-flex;
align-items: center;
justify-content: center;
height: 34px;
padding: 0 12px;
color: #4b5563;
font-size: 14px;
line-height: 34px;
text-decoration: none;
white-space: nowrap;
background: transparent;
border: 0;
border-radius: 8px;
cursor: pointer;
transition: color 160ms ease-out, background-color 160ms ease-out, transform 100ms ease-out;
}
.learning-layout__nav-item:hover:not(:disabled) {
color: #7b1230;
background: #fff1f4;
}
.learning-layout__nav-item:active:not(:disabled) {
transform: scale(0.97);
}
.learning-layout__nav-item--active {
color: var(--main-color);
font-weight: 600;
}
.learning-layout__nav-item:disabled {
cursor: wait;
opacity: 0.64;
}
.learning-layout__login {
min-width: 76px;
height: 34px;
padding-inline: 20px;
border-radius: 999px;
box-shadow: none;
transition: transform 160ms ease-out, box-shadow 160ms ease-out;
}
.learning-layout__login:hover {
box-shadow: 0 6px 16px rgb(170 25 65 / 20%);
transform: translateY(-1px);
}
.learning-layout__login:active {
transform: scale(0.97);
}
.learning-layout__content {
padding: 36px 32px;
}
.learning-layout__content > * {
box-sizing: border-box;
width: 100%;
max-width: var(--learning-content-max-width);
margin-inline: auto;
}
@media (max-width: 640px) {
.learning-layout__header,
.learning-layout__content {
padding-inline: 16px;
}
.learning-layout__header-actions {
gap: 8px;
}
}
import { Button, Layout } from 'antd'
import { Link, Outlet, useLocation, useNavigate } from 'react-router'
import './LearningLayout.css'
import { getTopNavigationKey, navigationItems } from './navigation'
import { useSessionStore } from '@/modules/auth/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'
export function LearningLayout() {
const location = useLocation()
const navigate = useNavigate()
const session = useSessionStore(state => state.session)
const clearSession = useSessionStore(state => state.clearSession)
const showMobileDownload = useMobileDownloadStore(state => state.show)
return (
<Layout className="learning-layout">
<Layout.Header className="learning-layout__header">
<div className="learning-layout__header-inner">
<Link className="learning-layout__brand" to="/library" aria-label="清控紫荆数智学堂图书馆">
<img src={logo} alt="清控紫荆数智学堂" />
</Link>
<div className="learning-layout__header-actions">
<nav className="learning-layout__navigation" aria-label="主导航">
{navigationItems.map(item => item.key === 'mobile-download' ? (
<button key={item.key} type="button" className="learning-layout__nav-item" onClick={showMobileDownload}>{item.label}</button>
) : (
<Link key={item.key} className={`learning-layout__nav-item${getTopNavigationKey(location.pathname) === item.key ? ' learning-layout__nav-item--active' : ''}`} to={item.key}>
{item.label}
</Link>
))}
<WorkbenchEntry />
</nav>
<div className="learning-layout__session" aria-label="用户会话区">
{session ? (
<AccountMenu session={session} onLogout={clearSession} />
) : (
<Button className="learning-layout__login" type="primary" onClick={() => navigate('/login')}>登录</Button>
)}
</div>
</div>
</div>
</Layout.Header>
<Layout.Content className="learning-layout__content">
<Outlet />
</Layout.Content>
<MobileDownloadModal />
</Layout>
)
}
.account-menu__popover .ant-popover-inner {
width: 292px;
padding: 14px;
overflow: hidden;
color: #273142;
background: #fff;
border: 1px solid #e6eaf0;
border-radius: 18px;
box-shadow: 0 18px 42px rgba(37, 48, 65, 0.16);
backdrop-filter: blur(18px) saturate(150%);
}
.account-menu__panel .ant-divider {
margin: 10px 0;
border-color: #edf0f4;
}
.account-menu__summary,
.account-menu__actions .ant-btn {
width: 100%;
color: #273142;
text-align: left;
}
.account-menu__summary:hover,
.account-menu__actions .ant-btn:hover {
color: #a7193f !important;
background: #fff3f5 !important;
}
.account-menu__summary {
display: flex;
gap: 12px;
height: auto;
padding: 10px;
border-radius: 12px;
}
.account-menu__summary-copy,
.account-menu__trigger-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
}
.account-menu__summary-name,
.account-menu__summary-level,
.account-menu__trigger-name,
.account-menu__trigger-level {
display: block;
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-menu__summary-name {
color: #1f2937;
font-size: 17px;
font-weight: 600;
line-height: 24px;
}
.account-menu__summary-level {
color: #8a94a6;
font-size: 13px;
line-height: 20px;
}
.account-menu__summary-arrow {
align-self: center;
color: #7c8798;
font-size: 30px;
font-weight: 200;
line-height: 1;
}
.account-menu__actions {
display: grid;
gap: 2px;
}
.account-menu__actions .ant-btn {
justify-content: flex-start;
height: 40px;
padding: 0 10px;
font-size: 15px;
border-radius: 10px;
}
.account-menu__actions .ant-btn .anticon {
font-size: 18px;
}
.account-menu__avatar {
color: #fff;
background: #9ba581;
}
.account-menu__trigger {
display: inline-flex;
align-items: center;
min-width: 0;
height: 48px;
padding: 5px 8px;
border-radius: 10px;
transition: transform 160ms ease-out, background-color 160ms ease-out;
}
.account-menu__trigger:hover {
background: #f2f4f7 !important;
}
.account-menu__trigger:active { transform: scale(0.97); }
.account-menu__trigger-copy {
max-width: 140px;
margin: 0 8px;
}
.account-menu__trigger-name {
color: #1f2937;
font-size: 14px;
font-weight: 600;
line-height: 19px;
}
.account-menu__trigger-level {
color: #8a94a6;
font-size: 12px;
line-height: 17px;
}
.account-menu__trigger-icon {
color: #7c8798;
font-size: 11px;
}
@media (max-width: 720px) {
.account-menu__trigger-copy,
.account-menu__trigger-icon {
display: none;
}
}
import { DownOutlined, LogoutOutlined, QuestionCircleOutlined, SafetyCertificateOutlined, UserOutlined } from '@ant-design/icons'
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 './AccountMenu.css'
interface AccountMenuProps {
session: MemberSession
onLogout: () => void
}
function avatarText(session: MemberSession) {
return (session.name || session.phone).trim().slice(0, 1).toUpperCase()
}
export function AccountMenu({ session, onLogout }: AccountMenuProps) {
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const displayName = session.name || session.phone
const accountLevel = session.grade_name
const navigateTo = (path: string) => {
setOpen(false)
navigate(path)
}
const logout = () => {
setOpen(false)
onLogout()
navigate('/library')
}
const content = (
<section className="account-menu__panel" aria-label="账户菜单">
<Button type="text" className="account-menu__summary" onClick={() => navigateTo('/my/profile')}>
<Avatar className="account-menu__avatar" size={48} src={session.head_img}>{avatarText(session)}</Avatar>
<span className="account-menu__summary-copy">
<Typography.Text className="account-menu__summary-name">{displayName}</Typography.Text>
{accountLevel ? <Typography.Text className="account-menu__summary-level">{accountLevel}</Typography.Text> : null}
</span>
<span className="account-menu__summary-arrow">›</span>
</Button>
<Divider />
<div className="account-menu__actions">
<Button type="text" icon={<UserOutlined />} onClick={() => navigateTo('/my/profile')}>个人资料</Button>
<Button type="text" icon={<SafetyCertificateOutlined />} onClick={() => navigateTo('/my/security')}>账号安全</Button>
</div>
<Divider />
<div className="account-menu__actions">
<Button type="text" icon={<QuestionCircleOutlined />} onClick={() => navigateTo('/my/help')}>帮助中心</Button>
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>退出登录</Button>
</div>
</section>
)
return (
<Popover
arrow={false}
content={content}
open={open}
overlayClassName="account-menu__popover"
placement="bottomRight"
trigger="click"
onOpenChange={setOpen}
>
<Button type="text" className="account-menu__trigger" aria-label="打开账户菜单">
<Avatar className="account-menu__avatar" size={34} src={session.head_img}>{avatarText(session)}</Avatar>
<span className="account-menu__trigger-copy">
<Typography.Text className="account-menu__trigger-name">{displayName}</Typography.Text>
{accountLevel ? <Typography.Text className="account-menu__trigger-level">{accountLevel}</Typography.Text> : null}
</span>
<DownOutlined className="account-menu__trigger-icon" />
</Button>
</Popover>
)
}
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>
)
}
const navigationItems = [
{ key: '/library', label: '图书馆' },
{ key: 'mobile-download', label: '手机版' },
]
export function getTopNavigationKey(pathname: string) {
if (pathname === '/book' || pathname.startsWith('/book/')) return '/library'
return (
navigationItems
.filter(item => pathname === item.key || pathname.startsWith(`${item.key}/`))
.sort((left, right) => right.key.length - left.key.length)[0]?.key ?? '/library'
)
}
export { navigationItems }
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ConfigProvider } from 'antd'
import 'antd/dist/reset.css'
import zhCN from 'antd/locale/zh_CN'
import { RouterProvider } from 'react-router' import { RouterProvider } from 'react-router'
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7'
import { router } from '@/router/routes' import { router } from '@/router/routes'
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({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { staleTime: 10 * 60 * 1000, retry: 1 }, queries: { staleTime: 10 * 60 * 1000, retry: shouldRetryQuery },
}, },
}) })
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <ConfigProvider locale={zhCN} theme={{ token: { colorPrimary: '#aa1941' } }}>
<RouterProvider router={router} /> <NuqsAdapter>
</QueryClientProvider> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</NuqsAdapter>
</ConfigProvider>
</StrictMode> </StrictMode>
) )
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!)
window.location.assign('/admin/')
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 type { ReactNode } from 'react'
import { Navigate, Outlet } from 'react-router'
import { useSessionStore } from './session'
interface RequireSessionProps {
children?: ReactNode
}
export function RequireSession({ children }: RequireSessionProps) {
const session = useSessionStore(state => state.session)
if (!session) return <Navigate replace to="/login" />
return children ?? <Outlet />
}
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'
export function hashMemberPassword(password: string) {
return md5(md5(password))
}
export async function login(input: LoginInput) {
return unwrapApiData(
await http.post<ApiEnvelope<MemberSession>>('/v1/members/login/login', input)
)
}
export async function sendLoginCode(phone: string, uuid: string) {
return unwrapApiData(
await http.post<ApiEnvelope<{ is_success: number }>>('/v1/members/login/sendCode', {
phone,
types: 'login',
uuid,
})
)
}
export async function logout() {
return unwrapApiData(
await http.post<ApiEnvelope<{ is_success: number }>>('/v1/members/login/logout', {})
)
}
const deviceKey = 'ebook-learning-device-uuid'
export function getDeviceUuid() {
const existing = localStorage.getItem(deviceKey)
if (existing) return existing
const value = crypto.randomUUID()
localStorage.setItem(deviceKey, value)
return value
}
import type { RouteObject } from 'react-router'
import { LoginView } from './views/LoginView'
export const routes: RouteObject[] = [{ path: '/login', element: <LoginView /> }]
import { create } from 'zustand'
import type { MemberSession } from './types'
const sessionKey = 'ebook-learning-session'
let onSessionExpired: (() => void) | undefined
function readSession(): MemberSession | null {
try {
const value = localStorage.getItem(sessionKey)
return value ? (JSON.parse(value) as MemberSession) : null
} catch {
return null
}
}
interface SessionState {
session: MemberSession | null
setSession: (session: MemberSession) => void
clearSession: () => void
}
export const useSessionStore = create<SessionState>(set => ({
session: typeof window === 'undefined' ? null : readSession(),
setSession: session => {
localStorage.setItem(sessionKey, JSON.stringify(session))
set({ session })
},
clearSession: () => {
localStorage.removeItem(sessionKey)
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?.()
}
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
}
.login-view {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
background:
radial-gradient(circle at 18% 18%, rgb(170 25 65 / 10%), transparent 30%),
linear-gradient(135deg, #fbf7f8 0%, #f5f7fa 55%, #eef3f8 100%);
}
.login-view__card {
width: min(420px, 100%);
overflow: hidden;
border-color: rgb(255 255 255 / 78%);
border-radius: 20px;
box-shadow: 0 24px 60px rgb(31 41 55 / 13%);
animation: login-card-in 280ms ease-out;
}
.login-view__card .ant-card-body { padding: 32px; }
@keyframes login-card-in {
from { opacity: 0; transform: translateY(10px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
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 './LoginView.css'
type LoginMode = 'password' | 'code'
interface LoginValues {
phone: string
password?: string
code?: string
agreement: boolean
}
export function LoginView() {
const [mode, setMode] = useState<LoginMode>('password')
const [submitting, setSubmitting] = useState(false)
const [sending, setSending] = useState(false)
const [form] = Form.useForm<LoginValues>()
const setSession = useSessionStore(state => state.setSession)
const navigate = useNavigate()
async function handleSendCode() {
const phone = form.getFieldValue('phone')
if (!/^1\d{10}$/.test(phone ?? '')) {
message.warning('请输入正确的手机号')
return
}
setSending(true)
try {
await sendLoginCode(phone, getDeviceUuid())
message.success('验证码已发送')
} catch (error) {
message.error(error instanceof Error ? error.message : '验证码发送失败')
} finally {
setSending(false)
}
}
async function handleFinish(values: LoginValues) {
setSubmitting(true)
try {
const session = await login({
phone: values.phone,
type: mode === 'password' ? '1' : '2',
password: mode === 'password' ? hashMemberPassword(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 : '登录失败')
} finally {
setSubmitting(false)
}
}
return (
<main className="login-view">
<Card className="login-view__card">
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title level={2}>清控紫荆数智学堂</Typography.Title>
<Typography.Text type="secondary">使用数字教材 APP 的手机号登录</Typography.Text>
</div>
<Segmented<LoginMode>
block
value={mode}
options={[{ label: '密码登录', value: 'password' }, { label: '验证码登录', value: 'code' }]}
onChange={setMode}
/>
<Form form={form} layout="vertical" onFinish={handleFinish} requiredMark={false}>
<Form.Item name="phone" label="手机号" rules={[{ required: true }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input size="large" autoComplete="tel" />
</Form.Item>
{mode === 'password' ? (
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password size="large" autoComplete="current-password" />
</Form.Item>
) : (
<Form.Item label="验证码" required>
<Space.Compact block>
<Form.Item name="code" noStyle rules={[{ required: true, message: '请输入验证码' }]}>
<Input size="large" />
</Form.Item>
<Button size="large" loading={sending} onClick={handleSendCode}>获取验证码</Button>
</Space.Compact>
</Form.Item>
)}
<Form.Item name="agreement" valuePropName="checked" rules={[{ validator: (_, value) => value ? Promise.resolve() : Promise.reject(new Error('请同意用户协议和隐私政策')) }]}>
<Checkbox>已阅读并同意《用户协议》和《隐私政策》</Checkbox>
</Form.Item>
<Button block size="large" type="primary" htmlType="submit" loading={submitting}>登录</Button>
</Form>
</Space>
</Card>
</main>
)
}
import { unwrapApiData } from '@/api/unwrap'
import type { ApiEnvelope } from '@/api/types'
import { http } from '@/utils/http'
export interface BookDetail {
bookId: string
bookName: string
img: string
price: string
vipPrice: string
rating: number
readNum: number
content: string
isFree: boolean
authors: string
isHave: boolean
isCollection: boolean
ratingCount: number
ratingList: number[]
categoryName: string
pressName: string
producersName: string
onsaleTime: number
chapterId: string
chapterName: string
readChapterId: string
introduction: string
commentNum: number
}
export interface BookChapter {
id: string
name: string
pid: string
isReading: boolean
children: BookChapter[]
}
export interface BookComment {
id: string
comments: string
rating: number
createTime: string
user: string
userImage: string
}
export interface StudyReport {
progress: string
readSecond: string
lastChapter: string
noteNums: number
lineNums: number
commentPostNums: number
commentReplyNums: 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 enabled(value: unknown): boolean {
return number(value) === 1
}
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 []
}
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 {
bookId: string(data.book_id),
bookName: string(data.book_name),
img: string(data.img),
price: string(data.price),
vipPrice: string(data.vip_price),
rating: number(data.rating),
readNum: number(data.read_num),
content: string(data.content),
isFree: enabled(data.is_free),
authors: string(data.authors),
isHave: enabled(data.is_have),
isCollection: enabled(data.is_collection),
ratingCount: number(data.rating_count),
ratingList: list(data.rating_list).map(number),
categoryName: string(data.category_name),
pressName: string(data.press_name),
producersName: string(data.producers_name),
onsaleTime: number(data.onsale_time),
chapterId: string(data.chapter_id),
chapterName: string(data.chapter_name),
readChapterId: string(data.read_chapter_id),
introduction: string(data.introduction),
commentNum: number(data.comment_num),
}
}
function toChapter(value: unknown): BookChapter {
const data = record(value)
return {
id: string(data.id),
name: string(data.name),
pid: string(data.pid),
isReading: enabled(data.is_reading),
children: list(data.children).map(toChapter),
}
}
function toComment(value: unknown): BookComment {
const data = record(value)
return {
id: string(data.book_comment_id),
comments: string(data.comments),
rating: number(data.rating),
createTime: string(data.create_time),
user: string(data.users),
userImage: string(data.users_img),
}
}
export async function getBookDetail(bookId: string): Promise<BookDetail> {
return toDetail(await post<unknown>('/v1/book/Information/getBookInformation', { book_id: bookId }))
}
export async function getBookChapters(bookId: string): Promise<BookChapter[]> {
return list(await post<unknown>('/v1/book/Information/getBookChapter', { book_id: bookId })).map(toChapter)
}
export async function getBookComments(bookId: string): Promise<BookComment[]> {
return list(await post<unknown>('/v1/book/Information/getBookCommentList', { book_id: bookId, page: 1, page_size: 3 })).map(toComment)
}
export async function setBookCollection(bookId: string, selected: boolean): Promise<boolean> {
const data = record(await post<unknown>('/v1/book/Information/collection', {
book_id: bookId,
is_select: selected ? '1' : '0',
}))
return number(data.is_success) === 1
}
export async function addBookToShelf(bookId: string): Promise<boolean> {
const data = record(await post<unknown>('/v1/orders/Orders/addCart', { book_id: bookId }))
return number(data.is_success) === 1
}
export async function getBookStudyReport(bookId: string): Promise<StudyReport> {
const data = record(await post<unknown>('/v1/members/Information/myStudyReport', { book_id: bookId }))
return {
progress: string(data.progress),
readSecond: string(data.read_second),
lastChapter: string(data.last_chapter),
noteNums: number(data.note_nums),
lineNums: number(data.line_nums),
commentPostNums: number(data.comment_post_nums),
commentReplyNums: number(data.comment_reply_nums),
}
}
import { describe, expect, it } from 'vitest'
import { findFirstChapterId } from './chapter-navigation'
describe('findFirstChapterId', () => {
it('uses the first child section before its parent chapter', () => {
expect(findFirstChapterId([
{ id: 'chapter-1', children: [{ id: 'section-1', children: [] }] },
{ id: 'chapter-2', children: [] },
])).toBe('section-1')
})
it('returns no target when the book has no configured chapters', () => {
expect(findFirstChapterId([])).toBeUndefined()
})
})
interface ChapterNode {
id: string
children: ChapterNode[]
}
export function findFirstChapterId(chapters: ChapterNode[]): string | undefined {
const first = chapters[0]
if (!first) return undefined
return findFirstChapterId(first.children) ?? (first.id || undefined)
}
import { Empty, Tree } from 'antd'
import type { DataNode } from 'antd/es/tree'
import type { BookChapter } from '../api'
interface BookChaptersProps {
chapters: BookChapter[]
canRead: (chapter: BookChapter) => boolean
onRead: (chapter: BookChapter) => void
}
function toTreeData(chapters: BookChapter[], canRead: (chapter: BookChapter) => boolean): DataNode[] {
return chapters.map(chapter => ({
key: chapter.id,
title: chapter.name,
disabled: !canRead(chapter),
children: toTreeData(chapter.children, canRead),
}))
}
function findChapter(chapters: BookChapter[], key: string): BookChapter | undefined {
for (const chapter of chapters) {
if (chapter.id === key) return chapter
const child = findChapter(chapter.children, key)
if (child) return child
}
}
export function BookChapters({ chapters, canRead, onRead }: BookChaptersProps) {
if (!chapters.length) return <Empty description="暂无目录" />
return (
<Tree
blockNode
defaultExpandAll
treeData={toTreeData(chapters, canRead)}
onSelect={keys => {
const chapter = findChapter(chapters, String(keys[0] ?? ''))
if (chapter && canRead(chapter)) onRead(chapter)
}}
/>
)
}
import { Avatar, Empty, List, Rate, Typography } from 'antd'
import type { BookComment } from '../api'
export function BookComments({ comments }: { comments: BookComment[] }) {
if (!comments.length) return <Empty description="暂无书评" />
return (
<List
itemLayout="horizontal"
dataSource={comments}
renderItem={comment => (
<List.Item>
<List.Item.Meta
avatar={<Avatar src={comment.userImage}>{comment.user.slice(0, 1)}</Avatar>}
title={<><Typography.Text>{comment.user || '匿名读者'}</Typography.Text> <Rate disabled allowHalf value={comment.rating} /></>}
description={<><Typography.Paragraph>{comment.comments}</Typography.Paragraph><Typography.Text type="secondary">{comment.createTime}</Typography.Text></>}
/>
</List.Item>
)}
/>
)
}
import { Image } from 'antd'
import defaultBookCover from '@/assets/default-book-cover.jpg'
export function BookCover({ src, alt }: { src: string; alt: string }) {
return <Image className="book-cover" preview={false} src={src || defaultBookCover} alt={alt} fallback={defaultBookCover} />
}
import { queryOptions } from '@tanstack/react-query'
import { getBookChapters, getBookComments, getBookDetail } from './api'
export const bookKeys = {
all: ['book'] as const,
detail: (bookId: string) => ['book', bookId, 'detail'] as const,
chapters: (bookId: string) => ['book', bookId, 'chapters'] as const,
comments: (bookId: string) => ['book', bookId, 'comments'] as const,
}
export function bookDetailOptions(bookId: string) {
return queryOptions({ queryKey: bookKeys.detail(bookId), queryFn: () => getBookDetail(bookId), enabled: Boolean(bookId) })
}
export function bookChaptersOptions(bookId: string) {
return queryOptions({ queryKey: bookKeys.chapters(bookId), queryFn: () => getBookChapters(bookId), enabled: Boolean(bookId) })
}
export function bookCommentsOptions(bookId: string) {
return queryOptions({ queryKey: bookKeys.comments(bookId), queryFn: () => getBookComments(bookId), enabled: Boolean(bookId) })
}
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { describe, expect, it } from 'vitest'
import { routes } from './routes'
describe('book routes', () => {
it('renders a standalone book detail page from the route book id', async () => {
const router = createMemoryRouter(routes, { initialEntries: ['/book/100'] })
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(<QueryClientProvider client={queryClient}><RouterProvider router={router} /></QueryClientProvider>)
expect(await screen.findByRole('heading', { name: '图书详情' })).toBeInTheDocument()
expect(screen.getByText('图书编号:100')).toBeInTheDocument()
})
})
import type { RouteObject } from 'react-router'
import { BookDetailView } from './views/BookDetailView'
export const routes: RouteObject[] = [{ path: 'book/:bookId', element: <BookDetailView /> }]
.book-detail {
box-sizing: border-box;
display: grid;
width: 100%;
margin: 0 auto;
padding: 0;
gap: 20px;
}
.book-detail__hero .ant-card-body { padding: 28px; }
.book-detail .ant-card {
border-color: var(--learning-border);
border-radius: 16px;
box-shadow: 0 2px 8px rgb(31 41 55 / 4%);
}
.book-detail__hero h1 { margin: 0 0 20px; font-size: 18px; font-weight: 500; color: rgba(0, 0, 0, 0.45); }
.book-detail__hero h2 { margin: 0 0 8px; }
.book-cover,
.book-cover img {
width: 180px !important;
height: 240px !important;
object-fit: cover;
border-radius: 4px;
background: #f5f5f5;
box-shadow: 0 10px 22px rgb(31 41 55 / 12%);
}
.book-detail__summary { min-width: min(580px, 100%); flex: 1; }
.book-detail__price { font-size: 20px; font-weight: 600; color: #c02d55; }
.book-detail__html { width: 100%; min-height: 420px; border: 0; }
.book-detail__introduction { white-space: pre-wrap; line-height: 2; }
.book-detail__actions {
position: sticky;
bottom: 16px;
background: rgb(255 255 255 / 92%);
box-shadow: var(--learning-shadow) !important;
backdrop-filter: blur(18px) saturate(150%);
}
@media (max-width: 640px) {
.book-detail { padding: 0; }
.book-cover, .book-cover img { width: 132px !important; height: 176px !important; }
.book-detail__hero .ant-card-body { padding: 20px; }
}
.mobile-download-modal .ant-modal-content {
padding: 40px 52px 48px;
border-radius: 24px;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
}
.mobile-download-modal .ant-modal-close {
top: 22px;
right: 26px;
color: #b8bcc3;
font-size: 28px;
}
.mobile-download-modal .ant-modal-close:hover { color: #90959d; background: transparent; }
.mobile-download-modal__content { text-align: center; }
.mobile-download-modal__content h2.ant-typography {
margin: 12px 0 52px;
color: #252b36;
font-family: inherit;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.03em;
line-height: 1.3;
}
.mobile-download-modal__platforms {
display: grid;
grid-template-columns: repeat(2, minmax(0, 208px));
justify-content: center;
gap: 84px;
}
.mobile-download-modal__platform.ant-btn {
display: grid;
justify-items: center;
width: 208px;
height: auto;
padding: 0;
color: #252b36;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.01em;
line-height: 1.35;
}
.mobile-download-modal__platform-icon {
display: grid;
width: 208px;
height: 208px;
margin-bottom: 18px;
place-items: center;
border-radius: 26px;
background: #f5f5f7;
font-size: 68px;
transition: transform 160ms ease-out, background-color 160ms ease-out;
}
.mobile-download-modal__platform:hover { color: var(--main-color) !important; background: transparent !important; }
.mobile-download-modal__platform:active .mobile-download-modal__platform-icon { transform: scale(0.98); }
.mobile-download-modal__qr {
display: grid;
width: 176px;
height: 176px;
justify-items: center;
place-items: center;
overflow: hidden;
border-radius: 18px;
background: #fff;
animation: mobile-download-qr-in 160ms ease-out;
}
.mobile-download-modal__qr .ant-image,
.mobile-download-modal__qr .ant-image img {
display: block;
width: 160px !important;
height: 160px !important;
}
.mobile-download-modal__platform-hint {
min-height: 20px;
margin-top: 6px;
color: #8b94a3;
font-size: 14px;
font-weight: 400;
}
.mobile-download-modal__platform:hover .mobile-download-modal__platform-hint,
.mobile-download-modal__platform:focus-visible .mobile-download-modal__platform-hint { color: #6d7581; }
.mobile-download-modal__platform:hover .mobile-download-modal__platform-icon {
color: #252b36;
}
@keyframes mobile-download-qr-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
@media (max-width: 720px) {
.mobile-download-modal { max-width: calc(100vw - 32px); }
.mobile-download-modal .ant-modal-content { padding: 28px 24px 36px; }
.mobile-download-modal__content h2.ant-typography { margin: 22px 24px 36px 0; font-size: 20px; line-height: 1.4; }
.mobile-download-modal__platforms { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
.mobile-download-modal__platform.ant-btn { width: 100%; font-size: 17px; }
.mobile-download-modal__platform-icon { width: 100%; height: auto; aspect-ratio: 1; margin-bottom: 14px; border-radius: 22px; font-size: 56px; }
.mobile-download-modal__qr { width: min(82%, 160px); height: auto; aspect-ratio: 1; border-radius: 14px; }
.mobile-download-modal__qr .ant-image,
.mobile-download-modal__qr .ant-image img { width: 88% !important; height: auto !important; }
}
@media (prefers-reduced-motion: reduce) {
.mobile-download-modal__platform-icon { transition: none; }
.mobile-download-modal__qr { animation: none; }
}
import { AndroidOutlined, AppleOutlined, CloseCircleFilled } from '@ant-design/icons'
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 = 'https://apps.apple.com/us/app/%E6%B8%85%E6%8E%A7%E7%B4%AB%E8%8D%86%E6%95%B0%E6%99%BA%E5%AD%A6%E5%A0%82/id6476735372'
type Platform = 'ios' | 'android'
export function MobileDownloadModal() {
const open = useMobileDownloadStore(state => state.open)
const hide = useMobileDownloadStore(state => state.hide)
const [hoveredPlatform, setHoveredPlatform] = useState<Platform | null>(null)
const close = () => {
setHoveredPlatform(null)
hide()
}
const platformVisual = (platform: Platform) => {
if (hoveredPlatform !== platform) return platform === 'ios' ? <AppleOutlined /> : <AndroidOutlined />
return (
<span className="mobile-download-modal__qr">
{platform === 'ios' ? <QRCode bordered={false} size={160} value={iosAppStoreUrl} /> : <Image alt="清控紫荆数智学堂 Android 下载二维码" preview={false} src={appDownloadQr} />}
</span>
)
}
return (
<Modal
centered
className="mobile-download-modal"
closeIcon={<CloseCircleFilled />}
footer={null}
open={open}
width={616}
onCancel={close}
>
<section className="mobile-download-modal__content" aria-labelledby="mobile-download-heading">
<Typography.Title id="mobile-download-heading" level={2}>在手机上使用清控紫荆数智学堂,获得更好的学习体验</Typography.Title>
<div className="mobile-download-modal__platforms">
{(['ios', 'android'] as const).map(platform => (
<Button
key={platform}
aria-label={`${platform === 'ios' ? 'iOS' : 'Android'} 下载二维码`}
className="mobile-download-modal__platform"
type="text"
onBlur={() => setHoveredPlatform(null)}
onFocus={() => setHoveredPlatform(platform)}
onMouseEnter={() => setHoveredPlatform(platform)}
onMouseLeave={() => setHoveredPlatform(null)}
>
<span className="mobile-download-modal__platform-icon">{platformVisual(platform)}</span>
<span>{platform === 'ios' ? 'iOS' : 'Android'}</span>
<span className="mobile-download-modal__platform-hint">{hoveredPlatform === platform ? '打开微信扫码下载' : ''}</span>
</Button>
))}
</div>
</section>
</Modal>
)
}
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 type { LibrarySearchState } from './query-state'
export interface LibraryBrowseApiParams {
category_id: number
label_id: number
is_free?: 0 | 1
sort_field: '' | 'read_num' | 'rating'
sort: 'asc' | 'desc'
page: number
page_size: number
}
export interface LibraryKeywordSearchApiParams {
keyword: string
page: number
page_size: number
}
export type LibraryApiParams = LibraryBrowseApiParams | LibraryKeywordSearchApiParams
export interface LibraryFilter {
id: number
name: string
}
export interface LibraryBook {
book_id: string
book_name: string
authors: string
img: string
introduction: string
read_num: number
is_collection: number
price: string
vip_price: string
is_free: number
}
export interface LibraryListResult {
list: LibraryBook[]
total: number
}
type UnknownRecord = Record<string, unknown>
function asRecord(value: unknown): UnknownRecord {
return typeof value === 'object' && value !== null ? (value as UnknownRecord) : {}
}
function asString(value: unknown): string {
return value === null || value === undefined ? '' : String(value)
}
function asNumber(value: unknown): number {
const number = Number(value)
return Number.isFinite(number) ? number : 0
}
function listFromPayload(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload
const record = asRecord(payload)
for (const key of ['list', 'data', 'rows']) {
if (Array.isArray(record[key])) return record[key]
}
return []
}
function totalFromPayload(payload: unknown, fallback: number): number {
const record = asRecord(payload)
for (const key of ['total', 'count', 'total_count']) {
if (record[key] !== undefined) return asNumber(record[key])
}
return fallback
}
function toBook(value: unknown): LibraryBook {
const item = asRecord(value)
return {
book_id: asString(item.book_id),
book_name: asString(item.book_name),
authors: asString(item.authors),
img: asString(item.img),
introduction: asString(item.introduction),
read_num: asNumber(item.read_num),
is_collection: asNumber(item.is_collection),
price: asString(item.price),
vip_price: asString(item.vip_price),
is_free: asNumber(item.is_free),
}
}
function toFilter(value: unknown, idKey: 'category_id' | 'label_id'): LibraryFilter {
const item = asRecord(value)
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) {
return {
keyword,
page: state.page,
page_size: state.pageSize,
}
}
return {
category_id: state.category,
label_id: state.label,
sort_field: state.sortField === 'none' ? '' : state.sortField,
sort: state.sortOrder,
page: state.page,
page_size: state.pageSize,
...(state.price === 'all' ? {} : { is_free: state.price === 'free' ? 1 : 0 }),
}
}
export async function getLibraryCategories(): Promise<LibraryFilter[]> {
const data = await post<unknown>('/v1/book/category/getCategoryListAll', {})
return listFromPayload(data).map(item => toFilter(item, 'category_id'))
}
export async function getLibraryLabels(): Promise<LibraryFilter[]> {
const data = await post<unknown>('/v1/book/category/getLabelListAll', {})
return listFromPayload(data).map(item => toFilter(item, 'label_id'))
}
export async function getLibraryBooks(params: LibraryApiParams): Promise<LibraryListResult> {
const data = 'keyword' in params
? await post<unknown>('/v1/book/Information/searchBook', {
keyword: params.keyword,
page: params.page,
page_size: params.page_size,
})
: await post<unknown>('/v1/book/category/getBookList', {
category_id: params.category_id,
label_id: params.label_id,
...(params.is_free === undefined ? {} : { is_free: params.is_free }),
sort_field: params.sort_field,
sort: params.sort,
page: params.page,
page_size: params.page_size,
})
const list = listFromPayload(data).map(toBook)
// The Flutter endpoint traditionally returns a bare list. Keep pagination usable
// in that form by exposing one speculative next item while a full page is present.
const fallbackTotal = list.length === params.page_size
? params.page * params.page_size + 1
: (params.page - 1) * params.page_size + list.length
return { list, total: totalFromPayload(data, fallbackTotal) }
}
export async function setBookCollection(bookId: string, isSelected: boolean): Promise<boolean> {
const data = await post<unknown>('/v1/book/Information/collection', {
book_id: bookId,
is_select: isSelected ? '1' : '0',
})
return asNumber(asRecord(data).is_success) === 1
}
import { Typography } from 'antd'
import digitalTextbookBanner from '@/assets/digital-textbook-banner.png'
export function LibraryBanner() {
return (
<section className="library-platform-banner" aria-labelledby="digital-textbook-heading">
<img alt="" aria-hidden="true" className="library-platform-banner__image" src={digitalTextbookBanner} />
<div className="library-platform-banner__content">
<Typography.Text className="library-platform-banner__eyebrow">数字教材一站式平台</Typography.Text>
<Typography.Title id="digital-textbook-heading" level={1}>
<span>清控紫荆</span>数智学堂
</Typography.Title>
<div className="library-platform-banner__rule" />
<Typography.Text className="library-platform-banner__features">全链路 · 一站式 · 大模型 · 多模态 · 富媒体</Typography.Text>
<Typography.Paragraph>
在 PC 或 APP 中学习你的数字教材,随时查看学习内容与学习进度。
</Typography.Paragraph>
</div>
</section>
)
}
import { Button, Card, Image, Tag, Tooltip, Typography } from 'antd'
import { Link } from 'react-router'
import defaultBookCover from '@/assets/default-book-cover.jpg'
import type { LibraryBook } from '../api'
interface LibraryBookCardProps {
book: LibraryBook
view: 'grid' | 'list'
isUpdatingCollection: boolean
onCollectionChange: (book: LibraryBook) => void
}
function bookPrice(book: LibraryBook) {
if (book.is_free === 1) return '免费'
return book.vip_price ? `¥${book.vip_price}` : book.price ? `¥${book.price}` : '收费'
}
export function LibraryBookCard({ book, view, isUpdatingCollection, onCollectionChange }: LibraryBookCardProps) {
const favourite = book.is_collection === 1
return (
<Card className={`library-book-card library-book-card--${view}`} hoverable>
<Link className="library-book-card__cover" to={`/book/${book.book_id}`} aria-label={`查看《${book.book_name}》详情`}>
<Image preview={false} src={book.img || defaultBookCover} alt={`${book.book_name}封面`} fallback={defaultBookCover} />
</Link>
<div className="library-book-card__content">
<div>
<Typography.Title level={4} ellipsis={{ rows: 2 }}>
<Link to={`/book/${book.book_id}`}>{book.book_name}</Link>
</Typography.Title>
<Typography.Text type="secondary">{book.authors || '作者未知'}</Typography.Text>
</div>
{view === 'list' && book.introduction ? (
<Typography.Paragraph className="library-book-card__intro" ellipsis={{ rows: 2 }} type="secondary">
{book.introduction}
</Typography.Paragraph>
) : null}
<div className="library-book-card__meta">
<Tag color={book.is_free === 1 ? 'green' : 'magenta'}>{bookPrice(book)}</Tag>
<Typography.Text type="secondary">{book.read_num} 人读过</Typography.Text>
<Tooltip title={favourite ? '取消收藏' : '收藏'}>
<Button
aria-label={favourite ? `取消收藏《${book.book_name}》` : `收藏《${book.book_name}》`}
type="text"
danger={favourite}
loading={isUpdatingCollection}
onClick={() => onCollectionChange(book)}
>
{favourite ? '♥' : '♡'}
</Button>
</Tooltip>
</div>
</div>
</Card>
)
}
import { Button, Form, Input, Segmented, Select, Space } from 'antd'
import { useEffect, useState } from 'react'
import type { LibraryFilter } from '../api'
import type { LibrarySearchState } from '../query-state'
interface LibraryToolbarProps {
state: LibrarySearchState
categories: LibraryFilter[]
labels: LibraryFilter[]
onKeywordSearch: (keyword: string) => void
onFiltersChange: (filters: Partial<Pick<LibrarySearchState, 'category' | 'label' | 'price'>>) => void
onSortChange: (sort: Partial<Pick<LibrarySearchState, 'sortField' | 'sortOrder'>>) => void
onViewChange: (view: LibrarySearchState['view']) => void
onReset: () => void
}
export function LibraryToolbar({
state,
categories,
labels,
onKeywordSearch,
onFiltersChange,
onSortChange,
onViewChange,
onReset,
}: LibraryToolbarProps) {
const [keyword, setKeyword] = useState(state.keyword)
const isSearching = Boolean(state.keyword)
const withAllOption = (filters: LibraryFilter[]) => (filters.some(item => item.id === 0) ? filters : [{ id: 0, name: '全部' }, ...filters])
const categoryOptions = withAllOption(categories).map(item => ({ value: item.id, label: item.name }))
const labelOptions = withAllOption(labels).map(item => ({ value: item.id, label: item.name }))
useEffect(() => setKeyword(state.keyword), [state.keyword])
return (
<Form component={false} aria-label="图书馆筛选">
<div className="library-toolbar__controls">
<Space className="library-toolbar__primary" size="middle">
<Form.Item label="书名">
<Input.Search
aria-label="按书名搜索"
value={keyword}
placeholder="输入书名"
allowClear
enterButton="搜索"
onChange={event => {
const nextKeyword = event.target.value
setKeyword(nextKeyword)
if (!nextKeyword) onKeywordSearch('')
}}
onSearch={onKeywordSearch}
/>
</Form.Item>
<Form.Item label="分类">
<Select aria-label="分类" disabled={isSearching} popupMatchSelectWidth={false} value={state.category} options={categoryOptions} onChange={category => onFiltersChange({ category })} />
</Form.Item>
<Form.Item label="标签">
<Select aria-label="标签" disabled={isSearching} popupMatchSelectWidth={false} value={state.label} options={labelOptions} onChange={label => onFiltersChange({ label })} />
</Form.Item>
</Space>
<Space className="library-toolbar__secondary" size="small" aria-label="显示与排序">
<Form.Item label="价格">
<Select
aria-label="价格"
disabled={isSearching}
popupMatchSelectWidth={false}
value={state.price}
options={[
{ value: 'all', label: '全部' },
{ value: 'paid', label: '收费' },
{ value: 'free', label: '免费' },
]}
onChange={price => onFiltersChange({ price })}
/>
</Form.Item>
<Form.Item label="排序">
<Space.Compact>
<Select
aria-label="排序"
disabled={isSearching}
popupMatchSelectWidth={false}
value={state.sortField}
options={[
{ value: 'none', label: '默认' },
{ value: 'read_num', label: '阅读人数' },
{ value: 'rating', label: '评分' },
]}
onChange={sortField => onSortChange({ sortField })}
/>
<Select
aria-label="排序方向"
disabled={isSearching}
popupMatchSelectWidth={false}
value={state.sortOrder}
options={[{ value: 'desc', label: '降序' }, { value: 'asc', label: '升序' }]}
onChange={sortOrder => onSortChange({ sortOrder })}
/>
</Space.Compact>
</Form.Item>
<Segmented
aria-label="图书馆视图"
value={state.view}
options={[{ value: 'grid', label: '网格' }, { value: 'list', label: '列表' }]}
onChange={value => onViewChange(value as LibrarySearchState['view'])}
/>
<Button onClick={onReset}>重置</Button>
</Space>
</div>
</Form>
)
}
import { act, renderHook } from '@testing-library/react'
import { withNuqsTestingAdapter, type OnUrlUpdateFunction } from 'nuqs/adapters/testing'
import type { ComponentType, PropsWithChildren } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { useLibraryQueryState } from './query-state'
function createNuqsTestingWrapper(
...args: Parameters<typeof withNuqsTestingAdapter>
): ComponentType<PropsWithChildren> {
return withNuqsTestingAdapter(...args) as ComponentType<PropsWithChildren>
}
describe('useLibraryQueryState', () => {
it('parses the audited library protocol and falls back from invalid values', () => {
const { result, rerender } = renderHook(() => useLibraryQueryState(), {
wrapper: createNuqsTestingWrapper({
searchParams:
'?keyword=机器学习&category=12&label=8&price=free&sortField=rating&sortOrder=asc&page=2&pageSize=40&view=list',
}),
})
expect(result.current.state).toEqual({
keyword: '机器学习',
category: 12,
label: 8,
price: 'free',
sortField: 'rating',
sortOrder: 'asc',
page: 2,
pageSize: 40,
view: 'list',
})
rerender()
const { result: invalidResult } = renderHook(() => useLibraryQueryState(), {
wrapper: createNuqsTestingWrapper({
searchParams:
'?category=nope&label=-1&price=discount&sortField=latest&sortOrder=sideways&page=0&pageSize=zero&view=table',
}),
})
expect(invalidResult.current.state).toEqual({
keyword: '',
category: 0,
label: 0,
price: 'all',
sortField: 'none',
sortOrder: 'desc',
page: 1,
pageSize: 20,
view: 'grid',
})
})
it('uses push for keyword, filters, sorting, and pagination, and replace for view changes', async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>()
const { result } = renderHook(() => useLibraryQueryState(), {
wrapper: createNuqsTestingWrapper({ searchParams: '', onUrlUpdate }),
})
await act(async () => result.current.setKeyword('机器学习'))
await act(async () => result.current.setView('list'))
await act(async () => result.current.setFilters({ category: 12 }))
await act(async () => result.current.setSort({ sortField: 'rating', sortOrder: 'asc' }))
await act(async () => result.current.setPage(2))
expect(onUrlUpdate.mock.calls.map(call => call[0].options.history)).toEqual([
'push',
'replace',
'push',
'push',
'push',
])
expect(onUrlUpdate.mock.calls[2][0].searchParams.get('page')).toBeNull()
expect(onUrlUpdate.mock.calls[4][0].searchParams.get('page')).toBe('2')
})
})
import { createParser, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
const prices = ['all', 'paid', 'free'] as const
const sortFields = ['none', 'read_num', 'rating'] as const
const sortOrders = ['asc', 'desc'] as const
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 librarySearchParams = {
keyword: parseAsString.withDefault(''),
category: parseAsMinimumInteger(0).withDefault(0),
label: parseAsMinimumInteger(0).withDefault(0),
price: parseAsStringLiteral(prices).withDefault('all'),
sortField: parseAsStringLiteral(sortFields).withDefault('none'),
sortOrder: parseAsStringLiteral(sortOrders).withDefault('desc'),
page: parseAsMinimumInteger(1).withDefault(1),
pageSize: parseAsMinimumInteger(1).withDefault(20),
view: parseAsStringLiteral(views).withDefault('grid'),
}
export interface LibrarySearchState {
keyword: string
category: number
label: number
price: (typeof prices)[number]
sortField: (typeof sortFields)[number]
sortOrder: (typeof sortOrders)[number]
page: number
pageSize: number
view: (typeof views)[number]
}
type FilterChanges = Pick<LibrarySearchState, 'category' | 'label' | 'price'>
type SortChanges = Pick<LibrarySearchState, 'sortField' | 'sortOrder'>
export function useLibraryQueryState() {
const [state, setState] = useQueryStates(librarySearchParams, {
clearOnDefault: true,
history: 'replace',
})
return {
state,
setKeyword: (keyword: string) =>
setState({ keyword: keyword.trim(), page: 1 }, { history: 'push' }),
setView: (view: LibrarySearchState['view']) => setState({ view }, { history: 'replace' }),
setFilters: (filters: Partial<FilterChanges>) =>
setState({ ...filters, keyword: '', page: 1 }, { history: 'push' }),
setSort: (sort: Partial<SortChanges>) => setState({ ...sort, keyword: '', page: 1 }, { history: 'push' }),
setPage: (page: number) => setState({ page }, { history: 'push' }),
setPagination: (page: number, pageSize: number) =>
setState({ page, pageSize }, { history: 'push' }),
reset: () =>
setState(
{ keyword: '', category: 0, label: 0, price: 'all', sortField: 'none', sortOrder: 'desc', page: 1 },
{ history: 'push' }
),
}
}
import { describe, expect, it } from 'vitest'
import { toLibraryApiParams } from './api'
import { libraryKeys } from './query'
import type { LibrarySearchState } from './query-state'
describe('libraryKeys', () => {
it('omits the free filter for the default all-books request', () => {
expect(toLibraryApiParams({
keyword: '',
category: 0,
label: 0,
price: 'all',
sortField: 'none',
sortOrder: 'desc',
page: 1,
pageSize: 20,
view: 'grid',
})).toEqual({
category_id: 0,
label_id: 0,
sort_field: '',
sort: 'desc',
page: 1,
page_size: 20,
})
})
it('uses exactly the normalized server inputs', () => {
const state = {
keyword: '',
category: 12,
label: 6,
price: 'free',
sortField: 'rating',
sortOrder: 'asc',
page: 2,
pageSize: 40,
view: 'list',
} satisfies LibrarySearchState
expect(libraryKeys.list(state)).toEqual([
'library',
'list',
{
category_id: 12,
label_id: 6,
is_free: 1,
sort_field: 'rating',
sort: 'asc',
page: 2,
page_size: 40,
},
])
})
it.each([
['category', { category: 13 }],
['keyword', { keyword: '机器学习' }],
['label', { label: 7 }],
['price', { price: 'paid' as const }],
['sort field', { sortField: 'read_num' as const }],
['sort order', { sortOrder: 'asc' as const }],
['page', { page: 3 }],
['page size', { pageSize: 60 }],
])('changes the key when %s changes the response', (_, change) => {
const base: LibrarySearchState = {
keyword: '',
category: 0,
label: 0,
price: 'all',
sortField: 'none',
sortOrder: 'desc',
page: 1,
pageSize: 20,
view: 'grid',
}
expect(libraryKeys.list({ ...base, ...change })).not.toEqual(libraryKeys.list(base))
})
it('does not include the UI-only view in the API query key', () => {
const base: LibrarySearchState = {
keyword: '',
category: 0,
label: 0,
price: 'all',
sortField: 'none',
sortOrder: 'desc',
page: 1,
pageSize: 20,
view: 'grid',
}
expect(libraryKeys.list({ ...base, view: 'list' })).toEqual(libraryKeys.list(base))
})
})
import { queryOptions } from '@tanstack/react-query'
import { getLibraryBooks, getLibraryCategories, getLibraryLabels, toLibraryApiParams } from './api'
import type { LibrarySearchState } from './query-state'
export const libraryKeys = {
all: ['library'] as const,
categories: () => ['library', 'categories'] as const,
labels: () => ['library', 'labels'] as const,
list: (state: LibrarySearchState) => ['library', 'list', toLibraryApiParams(state)] as const,
}
export function libraryCategoriesOptions() {
return queryOptions({ queryKey: libraryKeys.categories(), queryFn: getLibraryCategories })
}
export function libraryLabelsOptions() {
return queryOptions({ queryKey: libraryKeys.labels(), queryFn: getLibraryLabels })
}
export function libraryListOptions(state: LibrarySearchState) {
const params = toLibraryApiParams(state)
return queryOptions({ queryKey: libraryKeys.list(state), queryFn: () => getLibraryBooks(params) })
}
.library-view {
box-sizing: border-box;
display: grid;
width: 100%;
margin: 0 auto;
padding: 0;
gap: 18px;
}
.library-platform-banner {
position: relative;
isolation: isolate;
display: flex;
min-height: 300px;
border-radius: 16px;
background: #edf3ff;
}
.library-platform-banner__image {
position: absolute;
z-index: -1;
inset: 0;
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
object-position: center;
}
.library-platform-banner__content {
display: flex;
width: min(56%, 600px);
box-sizing: border-box;
flex-direction: column;
justify-content: center;
padding: 38px 52px;
}
.library-platform-banner__eyebrow,
.library-platform-banner__features {
font-weight: 600;
letter-spacing: 0.08em;
}
.library-platform-banner__eyebrow {
color: #4c5d80;
font-size: 15px;
}
.library-platform-banner__content h1.ant-typography {
margin: 14px 0 0;
color: #222b3c;
font-size: clamp(30px, 3vw, 44px);
line-height: 1.18;
}
.library-platform-banner__content h1 span { color: var(--main-color); }
.library-platform-banner__rule {
width: 44px;
height: 4px;
margin: 22px 0;
border-radius: 999px;
background: var(--main-color);
}
.library-platform-banner__features {
color: #4c5d80;
font-size: 13px;
}
.library-platform-banner__content p.ant-typography {
margin: 12px 0 0;
color: #5b6475;
line-height: 1.7;
}
.library-toolbar__controls {
display: flex;
width: 100%;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.library-toolbar__primary,
.library-toolbar__secondary { flex: 0 0 auto; }
.library-toolbar__secondary {
padding: 4px 6px;
border: 1px solid #edf0f4;
border-radius: 10px;
background: #fafbfc;
}
.library-toolbar__controls .ant-form-item { margin-bottom: 0; }
.library-toolbar__controls .ant-input-search { width: 260px; }
.library-toolbar__controls .ant-space-item { flex: 0 0 auto; }
.library-books {
display: grid;
gap: 14px;
}
.library-books--grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.library-books--list {
grid-template-columns: 1fr;
}
.library-book-card .ant-card-body {
display: flex;
height: 100%;
gap: 14px;
}
.library-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;
}
.library-book-card.ant-card:hover,
.library-book-card.ant-card:focus-within {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgb(31 41 55 / 12%);
}
.library-book-card.ant-card:active {
transform: scale(0.985);
transition-duration: 100ms;
}
.library-book-card--grid .ant-card-body {
display: flex;
padding: 12px;
}
.library-book-card__cover {
display: block;
flex: 0 0 112px;
width: 112px;
overflow: hidden;
border-radius: 4px;
background: #f5f5f5;
}
.library-book-card--grid .library-book-card__cover {
flex: 0 0 92px;
width: 92px;
height: 124px;
margin-bottom: 0;
}
.library-book-card__cover .ant-image,
.library-book-card__cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.library-book-card__content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
justify-content: space-between;
gap: 8px;
}
.library-book-card__content h4 {
margin: 0 0 2px;
font-size: 15px;
line-height: 21px;
}
.library-book-card__intro {
margin: 0;
}
.library-book-card__meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.library-book-card__meta .ant-tag {
margin-inline-end: 0;
}
.library-book-card__meta .ant-btn {
margin-left: auto;
display: grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border-radius: 50%;
font-size: 20px;
line-height: 1;
}
.library-book-card__meta .ant-btn:hover {
background: #fff1f4 !important;
}
.library-view__pagination {
justify-content: center;
}
@media (max-width: 640px) {
.library-view { padding: 0; }
.library-platform-banner { min-height: 360px; align-items: flex-start; }
.library-platform-banner__image { object-position: 61% center; }
.library-platform-banner__content {
width: 100%;
padding: 36px 28px;
background: linear-gradient(90deg, rgb(237 243 255 / 98%) 0%, rgb(237 243 255 / 85%) 72%, rgb(237 243 255 / 10%) 100%);
}
.library-toolbar__controls { flex-wrap: wrap; }
.library-toolbar__secondary { padding: 0; border: 0; background: transparent; }
.library-books--grid { grid-template-columns: 1fr; }
.library-book-card--list .ant-card-body { gap: 12px; }
.library-book-card__cover { flex-basis: 88px; width: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.library-book-card.ant-card { transition: none; }
.library-book-card.ant-card:hover,
.library-book-card.ant-card:focus-within,
.library-book-card.ant-card:active { transform: none; }
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Empty, Pagination, Skeleton, Space } from 'antd'
import { useState } from 'react'
import { setBookCollection, type LibraryBook } from '../api'
import { LibraryBanner } from '../components/LibraryBanner'
import { LibraryBookCard } from '../components/LibraryBookCard'
import { LibraryToolbar } from '../components/LibraryToolbar'
import { libraryCategoriesOptions, libraryKeys, libraryLabelsOptions, libraryListOptions } from '../query'
import { useLibraryQueryState } from '../query-state'
import './LibraryView.css'
export function LibraryView() { export function LibraryView() {
const queryState = useLibraryQueryState()
const { state } = queryState
const queryClient = useQueryClient()
const [changingBookId, setChangingBookId] = useState<string>()
const categoriesQuery = useQuery(libraryCategoriesOptions())
const labelsQuery = useQuery(libraryLabelsOptions())
const booksQuery = useQuery(libraryListOptions(state))
const collectionMutation = useMutation({
mutationFn: async ({ book, selected }: { book: LibraryBook; selected: boolean }) => {
setChangingBookId(book.book_id)
const succeeded = await setBookCollection(book.book_id, selected)
if (!succeeded) throw new Error('收藏状态更新失败')
return { book, selected }
},
onSuccess: ({ book, selected }) => {
queryClient.setQueryData(libraryKeys.list(state), (previous: typeof booksQuery.data) =>
previous
? { ...previous, list: previous.list.map(item => item.book_id === book.book_id ? { ...item, is_collection: selected ? 1 : 0 } : item) }
: previous
)
},
onSettled: () => setChangingBookId(undefined),
})
const filterError = categoriesQuery.error ?? labelsQuery.error
const bookError = booksQuery.error
return ( return (
<main> <main className="library-view">
<h1>图书馆</h1> <LibraryBanner />
<p>学习端架构初始化完成。</p>
{filterError ? <Alert type="warning" showIcon message="筛选项加载失败" description={(filterError as Error).message} /> : null}
<div className="library-toolbar">
<LibraryToolbar
state={state}
categories={categoriesQuery.data ?? []}
labels={labelsQuery.data ?? []}
onKeywordSearch={keyword => void queryState.setKeyword(keyword)}
onFiltersChange={filters => void queryState.setFilters(filters)}
onSortChange={sort => void queryState.setSort(sort)}
onViewChange={view => void queryState.setView(view)}
onReset={() => void queryState.reset()}
/>
</div>
{collectionMutation.error ? <Alert type="error" showIcon message="收藏操作失败" description={(collectionMutation.error as Error).message} /> : null}
{bookError ? (
<Alert type="error" showIcon message="图书加载失败" description={(bookError as Error).message} action={<a onClick={() => void booksQuery.refetch()}>重试</a>} />
) : booksQuery.isLoading ? (
<Skeleton active paragraph={{ rows: 8 }} />
) : booksQuery.data?.list.length ? (
<div className={`library-books library-books--${state.view}`}>
{booksQuery.data.list.map(book => (
<LibraryBookCard
key={book.book_id}
book={book}
view={state.view}
isUpdatingCollection={changingBookId === book.book_id}
onCollectionChange={item => collectionMutation.mutate({ book: item, selected: item.is_collection !== 1 })}
/>
))}
</div>
) : (
<Empty description="没有找到符合条件的图书" />
)}
<Space className="library-view__pagination">
<Pagination
current={state.page}
pageSize={state.pageSize}
total={booksQuery.data?.total ?? 0}
showSizeChanger={false}
showTotal={total => `共 ${total} 本`}
onChange={(page, pageSize) => void queryState.setPagination(page, pageSize)}
/>
</Space>
</main> </main>
) )
} }
import { http } from '@/utils/http'
import { unwrapApiData } from '@/api/unwrap'
export interface MyStatistics {
name?: string
headImg?: string
phone?: string
gradeName?: string
noteNums?: number
commentNums?: number
wrongNums?: number
collectNums?: number
couponNums?: number
integralNums?: number
beanNums?: string | number
ordersNums?: number
}
export interface MyBook {
bookId: string | number
bookName: string
authors?: string
img?: string
progress?: string
lastChapter?: string
createTime?: string
status?: number
cartId?: string | number
vipPrice?: string | number
}
export interface MyListParams {
page: number
pageSize: number
}
export interface MyBookListResult {
items: MyBook[]
total?: number
}
type ApiRecord = Record<string, unknown>
function asRecord(value: unknown): ApiRecord {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as ApiRecord)
: {}
}
function asOptionalText(value: unknown) {
return value === undefined || value === null ? undefined : String(value)
}
function asOptionalNumber(value: unknown) {
const number = Number(value)
return Number.isFinite(number) ? number : undefined
}
function toMyBook(value: unknown): MyBook {
const record = asRecord(value)
const bookId = record.book_id ?? record.bookId ?? ''
const bookName = asOptionalText(record.book_name ?? record.bookName) ?? '—'
return {
bookId: typeof bookId === 'number' || typeof bookId === 'string' ? bookId : '',
bookName,
authors: asOptionalText(record.authors),
img: asOptionalText(record.img),
progress: asOptionalText(record.progress),
lastChapter: asOptionalText(record.last_chapter ?? record.lastChapter),
createTime: asOptionalText(record.create_time ?? record.createTime),
status: asOptionalNumber(record.status),
cartId: asOptionalText(record.cart_id ?? record.cartId),
vipPrice: asOptionalText(record.vip_price ?? record.vipPrice),
}
}
function toMyBookListResult(data: unknown): MyBookListResult {
const record = asRecord(data)
const source = Array.isArray(data) ? data : Array.isArray(record.list) ? record.list : []
return {
items: source.map(toMyBook),
total: asOptionalNumber(record.total),
}
}
export async function getMyStatistics(): Promise<MyStatistics> {
const response = await http.post('/v1/members/Information/getMyStatistics', {})
const data = asRecord(unwrapApiData(response))
return {
name: asOptionalText(data.name),
headImg: asOptionalText(data.head_img ?? data.headImg),
phone: asOptionalText(data.phone),
gradeName: asOptionalText(data.grade_name ?? data.gradeName),
noteNums: asOptionalNumber(data.note_nums ?? data.noteNums),
commentNums: asOptionalNumber(data.comment_nums ?? data.commentNums),
wrongNums: asOptionalNumber(data.wrong_nums ?? data.wrongNums),
collectNums: asOptionalNumber(data.collect_nums ?? data.collectNums),
couponNums: asOptionalNumber(data.coupon_nums ?? data.couponNums),
integralNums: asOptionalNumber(data.integral_nums ?? data.integralNums),
beanNums: asOptionalText(data.bean_nums ?? data.beanNums),
ordersNums: asOptionalNumber(data.orders_nums ?? data.ordersNums),
}
}
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))
}
export function getMyCourses(params: MyListParams) {
return getMyBookList('/v1/members/Information/myCourse', params)
}
export function getBookshelf(params: MyListParams) {
return getMyBookList('/v1/orders/Orders/getCartListAll', params)
}
export async function deleteBookshelfItem(cartId: string) {
const response = await http.post('/v1/orders/Orders/delCart', { cart_id: cartId })
const data = unwrapApiData<ApiRecord>(response)
return data.is_success === 1 || data.is_success === '1' || data.is_success === true
}
.my-books {
display: grid;
gap: 24px;
}
.my-books__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.my-books__header h1 {
margin: 0 !important;
}
.my-books__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.my-books__cover {
display: block;
flex: 0 0 92px;
width: 92px;
height: 124px;
overflow: hidden;
border-radius: 6px;
background: #f5f5f5;
}
.my-books__cover .ant-image,
.my-books__cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.my-books__card .ant-card-body {
padding: 12px;
}
.my-books__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;
}
.my-books__card.ant-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgb(31 41 55 / 12%);
}
.my-books__card.ant-card:active { transform: scale(0.985); }
.my-books__card-main {
display: flex;
min-height: 124px;
gap: 14px;
}
.my-books__card-content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
justify-content: space-between;
gap: 6px;
}
.my-books__card-content .ant-card-meta-title {
white-space: normal;
}
.my-books__card-content .ant-typography {
margin: 0;
}
.my-books__meta {
min-height: 0;
}
.my-books__pagination {
display: flex;
justify-content: flex-end;
}
@media (max-width: 640px) {
.my-books__grid {
grid-template-columns: 1fr;
gap: 12px;
}
}
import { Alert, Button, 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'
import { myCoursesOptions } from '../../query'
import { useMyListQueryState } from '../../query-state'
import './MyBooksView.css'
function paginationTotal(page: number, pageSize: number, itemCount: number, total?: number) {
if (total !== undefined) return total
return itemCount === pageSize ? page * pageSize + 1 : (page - 1) * pageSize + itemCount
}
export function MyBooksView() {
const queryState = useMyListQueryState()
const { state } = queryState
const coursesQuery = useQuery(myCoursesOptions(state))
return (
<main className="my-books">
<div className="my-books__header">
<Typography.Title level={1}>我的书籍</Typography.Title>
</div>
{coursesQuery.isPending ? <Skeleton active paragraph={{ rows: 12 }} /> : null}
{coursesQuery.isError ? (
<Alert
showIcon
type="error"
message="我的书籍加载失败"
description={coursesQuery.error.message}
action={<Button onClick={() => void coursesQuery.refetch()}>重试</Button>}
/>
) : null}
{coursesQuery.data && coursesQuery.data.items.length === 0 ? <Empty description="暂无书籍" /> : null}
{coursesQuery.data?.items.length ? (
<>
<section className="my-books__grid" aria-label="我的书籍列表">
{coursesQuery.data.items.map(book => (
<Card
className="my-books__card"
key={String(book.bookId)}
actions={[<Link key="detail" to={`/book/${book.bookId}`}>查看详情</Link>]}
>
<div className="my-books__card-main">
<Link className="my-books__cover" to={`/book/${book.bookId}`} aria-label={`查看《${book.bookName}》详情`}>
<Image alt={`${book.bookName}封面`} fallback={defaultBookCover} preview={false} src={book.img || defaultBookCover} />
</Link>
<div className="my-books__card-content">
<Card.Meta title={book.bookName} description={<div className="my-books__meta">{book.authors || '—'}</div>} />
{book.progress ? <Tag color="blue">已学习 {book.progress}</Tag> : null}
{book.lastChapter ? <Typography.Paragraph ellipsis={{ rows: 1 }}>最近:{book.lastChapter}</Typography.Paragraph> : null}
</div>
</div>
</Card>
))}
</section>
<div className="my-books__pagination">
<Pagination
current={state.page}
pageSize={state.pageSize}
total={paginationTotal(state.page, state.pageSize, coursesQuery.data.items.length, coursesQuery.data.total)}
showSizeChanger={false}
onChange={(page, pageSize) => void queryState.setPage(page, pageSize)}
/>
</div>
</>
) : null}
</main>
)
}
.my-bookshelf {
display: grid;
gap: 24px;
}
.my-bookshelf__header,
.my-bookshelf__toolbar {
display: flex;
align-items: center;
}
.my-bookshelf__header {
justify-content: space-between;
gap: 16px;
}
.my-bookshelf__header h1 {
margin: 0 !important;
}
.my-bookshelf__toolbar {
justify-content: space-between;
gap: 16px;
}
.my-bookshelf__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.my-bookshelf__card .ant-card-body {
padding: 12px;
}
.my-bookshelf__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;
}
.my-bookshelf__card.ant-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgb(31 41 55 / 12%);
}
.my-bookshelf__card.ant-card:active { transform: scale(0.985); }
.my-bookshelf__card-main {
position: relative;
display: flex;
min-height: 124px;
gap: 14px;
}
.my-bookshelf__select {
position: absolute;
z-index: 1;
top: 4px;
left: 4px;
padding: 3px;
border-radius: 4px;
background: rgb(255 255 255 / 88%);
}
.my-bookshelf__cover {
display: block;
flex: 0 0 92px;
width: 92px;
height: 124px;
overflow: hidden;
border-radius: 6px;
background: #f5f5f5;
}
.my-bookshelf__cover .ant-image,
.my-bookshelf__cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.my-bookshelf__card-content {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
justify-content: space-between;
gap: 6px;
}
.my-bookshelf__card-content h4,
.my-bookshelf__card-content p {
margin: 0 !important;
}
.my-bookshelf__item-price {
margin-top: 12px !important;
color: #c4352b;
}
.my-bookshelf__pagination {
display: flex;
justify-content: flex-end;
}
@media (max-width: 600px) {
.my-bookshelf__grid {
grid-template-columns: 1fr;
gap: 12px;
}
}
import { Alert, Button, Card, Checkbox, Empty, Image, Pagination, Popconfirm, Skeleton, Space, Tag, Typography } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Link } from 'react-router'
import defaultBookCover from '@/assets/default-book-cover.jpg'
import { deleteBookshelfItem } from '../../api'
import { bookshelfOptions, myKeys } from '../../query'
import { useMyListQueryState } from '../../query-state'
import './MyBookshelfView.css'
function paginationTotal(page: number, pageSize: number, itemCount: number, total?: number) {
if (total !== undefined) return total
return itemCount === pageSize ? page * pageSize + 1 : (page - 1) * pageSize + itemCount
}
export function MyBookshelfView() {
const queryClient = useQueryClient()
const queryState = useMyListQueryState()
const { state } = queryState
const bookshelfQuery = useQuery(bookshelfOptions(state))
const [selectedCartIds, setSelectedCartIds] = useState<Set<string>>(new Set())
const items = bookshelfQuery.data?.items ?? []
const selectableCartIds = useMemo(
() => items.filter(book => book.status === 1 && book.cartId).map(book => String(book.cartId)),
[items]
)
const deleteMutation = useMutation({
mutationFn: async (cartIds: string[]) => {
const results = await Promise.all(cartIds.map(deleteBookshelfItem))
if (results.some(result => !result)) throw new Error('删除书籍失败')
},
onSuccess: async () => {
setSelectedCartIds(new Set())
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: myKeys.all })
},
})
const isAllSelected = selectableCartIds.length > 0 && selectableCartIds.every(cartId => selectedCartIds.has(cartId))
const toggleCart = (cartId: string, checked: boolean) => {
setSelectedCartIds(previous => {
const next = new Set(previous)
if (checked) next.add(cartId)
else next.delete(cartId)
return next
})
}
const toggleAll = (checked: boolean) => setSelectedCartIds(new Set(checked ? selectableCartIds : []))
return (
<main className="my-bookshelf">
<div className="my-bookshelf__header">
<Typography.Title level={1}>我的书架</Typography.Title>
</div>
{bookshelfQuery.isPending ? <Skeleton active paragraph={{ rows: 10 }} /> : null}
{bookshelfQuery.isError ? (
<Alert
showIcon
type="error"
message="书架加载失败"
description={bookshelfQuery.error.message}
action={<Button onClick={() => void bookshelfQuery.refetch()}>重试</Button>}
/>
) : null}
{deleteMutation.isError ? (
<Alert showIcon type="error" message="删除失败" description={deleteMutation.error.message} />
) : null}
{bookshelfQuery.data && items.length === 0 ? <Empty description="书架暂无书籍" /> : null}
{items.length ? (
<>
<div className="my-bookshelf__toolbar">
<Checkbox
checked={isAllSelected}
indeterminate={selectedCartIds.size > 0 && !isAllSelected}
disabled={selectableCartIds.length === 0 || deleteMutation.isPending}
onChange={event => toggleAll(event.target.checked)}
>
全选
</Checkbox>
<Space>
<Typography.Text type="secondary">已选 {selectedCartIds.size} 本</Typography.Text>
<Popconfirm
title="删除已选书籍?"
description="删除后将从书架中移除。"
okText="删除"
cancelText="取消"
disabled={selectedCartIds.size === 0 || deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate([...selectedCartIds])}
>
<Button danger disabled={selectedCartIds.size === 0 || deleteMutation.isPending} loading={deleteMutation.isPending}>
删除已选
</Button>
</Popconfirm>
</Space>
</div>
<section className="my-bookshelf__grid" aria-label="书架书籍列表">
{items.map(book => {
const cartId = book.cartId ? String(book.cartId) : undefined
const selectable = book.status === 1 && Boolean(cartId)
return (
<Card
className="my-bookshelf__card"
key={String(book.bookId)}
actions={[
<Popconfirm
key="delete"
title="删除这本书?"
okText="删除"
cancelText="取消"
disabled={!cartId || deleteMutation.isPending}
onConfirm={() => {
if (cartId) deleteMutation.mutate([cartId])
}}
>
<Button type="link" danger disabled={!cartId || deleteMutation.isPending}>删除</Button>
</Popconfirm>,
]}
>
<div className="my-bookshelf__card-main">
<Checkbox
className="my-bookshelf__select"
checked={cartId ? selectedCartIds.has(cartId) : false}
disabled={!selectable || deleteMutation.isPending}
onChange={event => cartId && toggleCart(cartId, event.target.checked)}
/>
<Link className="my-bookshelf__cover" to={`/book/${book.bookId}`} aria-label={`查看《${book.bookName}》详情`}>
<Image alt={`${book.bookName}封面`} fallback={defaultBookCover} preview={false} src={book.img || defaultBookCover} />
</Link>
<div className="my-bookshelf__card-content">
<Typography.Title level={4} ellipsis={{ rows: 2 }}><Link to={`/book/${book.bookId}`}>{book.bookName}</Link></Typography.Title>
<Typography.Paragraph type="secondary" ellipsis>{book.authors || '—'}</Typography.Paragraph>
{book.status !== 1 ? <Tag>暂不可购买</Tag> : null}
{book.vipPrice ? <Typography.Paragraph className="my-bookshelf__item-price">¥{book.vipPrice}</Typography.Paragraph> : null}
</div>
</div>
</Card>
)
})}
</section>
<div className="my-bookshelf__pagination">
<Pagination
current={state.page}
pageSize={state.pageSize}
total={paginationTotal(state.page, state.pageSize, items.length, bookshelfQuery.data?.total)}
showSizeChanger={false}
onChange={(page, pageSize) => {
setSelectedCartIds(new Set())
void queryState.setPage(page, pageSize)
}}
/>
</div>
</>
) : null}
</main>
)
}
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
export type CouponStatus = 0 | 1
export interface CouponListParams {
page: number
pageSize: number
status: CouponStatus
}
export interface CouponItem {
id: string
name: string
threshold: string
discount: string
startTime: string
endTime: string
useStatus: number
}
export interface CouponListResult {
items: 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('/v1/coupon/Coupon/getCouponRecordListSAll', { page: params.page, page_size: params.pageSize, type: params.status })
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,
}
}
import { Card, Space, Tag, Typography } from 'antd'
import type { CouponItem } from '../api'
export function CouponList({ items, status }: { items: CouponItem[]; status: number }) {
return <Space direction="vertical" size="middle" style={{ width: '100%' }}>{items.map(coupon => <Card key={coupon.id} size="small" style={{ borderLeft: `4px solid ${status === 0 ? '#1677ff' : '#bfbfbf'}` }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}><Typography.Title level={4} style={{ margin: 0 }}>{coupon.name || '优惠券'}</Typography.Title><Tag color={status === 0 ? 'blue' : 'default'}>{status === 0 ? '可使用' : '已使用/已失效'}</Tag></Space>
<Typography.Text><Typography.Text strong type="danger" style={{ fontSize: 22 }}>¥{coupon.discount || '0.00'}</Typography.Text> 满 {coupon.threshold || '0'} 元可用</Typography.Text>
<Typography.Text type="secondary">有效期:{coupon.startTime || '—'} 至 {coupon.endTime || '—'}</Typography.Text>
</Space>
</Card>)}</Space>
}
import { createParser, useQueryStates } from 'nuqs'
import type { CouponStatus } from './api'
function positiveInteger(defaultValue: number) {
return createParser({ parse: value => Number.isInteger(Number(value)) && Number(value) >= 1 ? Number(value) : null, serialize: value => String(value) }).withDefault(defaultValue)
}
const couponSearchParams = {
status: createParser<CouponStatus>({ parse: value => value === '0' || value === '1' ? Number(value) as CouponStatus : null, serialize: value => String(value) }).withDefault(0),
page: positiveInteger(1),
pageSize: positiveInteger(10),
}
export function useCouponsQueryState() {
const [state, setState] = useQueryStates(couponSearchParams, { clearOnDefault: true, history: 'push' })
return {
state,
setStatus: (status: CouponStatus) => setState({ status, page: 1 }),
setPagination: (page: number, pageSize: number) => setState({ page, pageSize }),
}
}
import { queryOptions } from '@tanstack/react-query'
import { getCoupons } from './api'
import type { CouponListParams } from './api'
export const couponKeys = {
all: ['my', 'coupons'] as const,
list: (params: CouponListParams) => [...couponKeys.all, params] as const,
}
export function couponsOptions(params: CouponListParams) {
return queryOptions({ queryKey: couponKeys.list(params), queryFn: () => getCoupons(params) })
}
import type { RouteObject } from 'react-router'
import { MyCouponsView } from './views/MyCouponsView'
export const routes: RouteObject[] = [{ path: 'coupons', element: <MyCouponsView /> }]
import { useQuery } from '@tanstack/react-query'
import { Alert, Button, Empty, Pagination, Segmented, Skeleton, Space, Typography } from 'antd'
import type { CouponStatus } from '../api'
import { CouponList } from '../components/CouponList'
import { couponsOptions } from '../query'
import { useCouponsQueryState } from '../query-state'
function totalFor(page: number, pageSize: number, count: number, total?: number) {
return total ?? (count === pageSize ? page * pageSize + 1 : (page - 1) * pageSize + count)
}
export function MyCouponsView() {
const queryState = useCouponsQueryState()
const couponsQuery = useQuery(couponsOptions(queryState.state))
const items = couponsQuery.data?.items ?? []
return <main><Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div><Typography.Title level={1}>优惠券</Typography.Title><Typography.Paragraph type="secondary">按优惠券状态查看可使用与已使用/已失效的记录。</Typography.Paragraph></div>
<Segmented<CouponStatus> block value={queryState.state.status} options={[{ label: '可使用', value: 0 }, { label: '已使用/已失效', value: 1 }]} onChange={status => void queryState.setStatus(status)} />
{couponsQuery.isPending ? <Skeleton active paragraph={{ rows: 8 }} /> : null}
{couponsQuery.isError ? <Alert type="error" showIcon message="优惠券加载失败" description={couponsQuery.error.message} action={<Button onClick={() => void couponsQuery.refetch()}>重试</Button>} /> : null}
{couponsQuery.data && items.length === 0 ? <Empty description="暂无优惠券" /> : null}
{items.length ? <><CouponList items={items} status={queryState.state.status} /><div style={{ textAlign: 'right' }}><Pagination current={queryState.state.page} pageSize={queryState.state.pageSize} total={totalFor(queryState.state.page, queryState.state.pageSize, items.length, couponsQuery.data?.total)} showSizeChanger={false} onChange={(page, pageSize) => void queryState.setPagination(page, pageSize)} /></div></> : null}
</Space></main>
}
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
import type { MyListParams } from '../api'
export interface DiscussedBook {
bookId: string
bookName: string
authors: string
img: string
commentNum: number
}
export interface DiscussedBookList {
items: DiscussedBook[]
total?: number
}
type RecordValue = Record<string, unknown>
function record(value: unknown): RecordValue {
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as RecordValue : {}
}
function text(value: unknown) {
return value === null || value === undefined ? '' : String(value)
}
export async function getDiscussedBooks(params: MyListParams): Promise<DiscussedBookList> {
const response = await http.post('/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 {
items: source.map(value => {
const item = record(value)
return {
bookId: text(item.book_id),
bookName: text(item.book_name) || '—',
authors: text(item.authors),
img: text(item.img),
commentNum: Number(item.comment_num) || 0,
}
}),
total: Number.isFinite(Number(data.total)) ? Number(data.total) : undefined,
}
}
import { queryOptions } from '@tanstack/react-query'
import { getDiscussedBooks } from './api'
import type { MyListSearchState } from '../query-state'
export const discussionKeys = {
all: ['my', 'discussions'] as const,
list: (params: MyListSearchState) => [...discussionKeys.all, params] as const,
}
export function discussedBooksOptions(params: MyListSearchState) {
return queryOptions({ queryKey: discussionKeys.list(params), queryFn: () => getDiscussedBooks(params) })
}
import { useQuery } from '@tanstack/react-query'
import { Alert, Button, Empty, Image, List, Pagination, Space, Statistic, Skeleton, Typography } from 'antd'
import { Link } from 'react-router'
import { useMyListQueryState } from '../../query-state'
import { discussedBooksOptions } from '../query'
function pageTotal(page: number, pageSize: number, count: number, total?: number) {
return total ?? (count === pageSize ? page * pageSize + 1 : (page - 1) * pageSize + count)
}
export function MyDiscussionsView() {
const queryState = useMyListQueryState()
const discussionsQuery = useQuery(discussedBooksOptions(queryState.state))
const books = discussionsQuery.data?.items ?? []
return (
<main>
<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 message="讨论加载失败" description={discussionsQuery.error.message} action={<Button onClick={() => void discussionsQuery.refetch()}>重试</Button>} /> : null}
{discussionsQuery.data && books.length === 0 ? <Empty description="暂无讨论" /> : null}
{books.length ? <>
<List bordered dataSource={books} renderItem={book => (
<List.Item actions={[<Link key="detail" to={`/book/${book.bookId}`}>前往图书讨论</Link>]}>
<List.Item.Meta avatar={book.img ? <Image width={52} height={72} src={book.img} alt="" preview={false} /> : null} title={book.bookName} description={book.authors || '作者未知'} />
<Statistic title="讨论" value={book.commentNum} />
</List.Item>
)} />
<div style={{ marginTop: 20, textAlign: 'right' }}><Pagination current={queryState.state.page} pageSize={queryState.state.pageSize} total={pageTotal(queryState.state.page, queryState.state.pageSize, books.length, discussionsQuery.data?.total)} showSizeChanger={false} onChange={(page, pageSize) => void queryState.setPage(page, pageSize)} /></div>
</> : null}
</main>
)
}
import { unwrapApiData } from '@/api/unwrap'
import { http } from '@/utils/http'
import type { MyListParams } from '../api'
export interface FavoriteBook {
bookId: string
collectionId: string
bookName: string
authors: string
img: string
introduction: string
}
export interface FavoriteList {
items: FavoriteBook[]
total?: number
}
type RecordValue = Record<string, unknown>
function record(value: unknown): RecordValue {
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as RecordValue : {}
}
function text(value: unknown) {
return value === null || value === undefined ? '' : String(value)
}
function number(value: unknown) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
export async function getFavorites(params: MyListParams): Promise<FavoriteList> {
const response = await http.post('/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 {
items: source.map(value => {
const item = record(value)
return {
bookId: text(item.book_id),
collectionId: text(item.collection_id),
bookName: text(item.book_name) || '—',
authors: text(item.authors),
img: text(item.img),
introduction: text(item.introduction),
}
}),
total: number(data.total),
}
}
export async function removeFavorite(bookId: string) {
const response = await http.post('/v1/book/Information/collection', { book_id: bookId, is_select: '0' })
const data = unwrapApiData<unknown>(response)
const payload = record(data)
return data === 1 || data === '1' || payload.is_success === 1 || payload.is_success === '1' || payload.is_success === true
}
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论