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

chore: update

上级 89d5a908
# access/admin migration report
## Status
Migrated `/jurisdiction/admin` from `pages/jurisdiction/admin` to the module-owned
`modules/access/admin` boundary. The active access route now lazy-loads the new
module route and view, and the replaced legacy page/request files were removed
after a successful pre-delete production build.
## Preserved contract
- List API remains `POST /user/user/getList` through `utils/request.js` with a
`qs.stringify` body containing `page`, `page_size`, and active filters.
- Role options remain `POST /user/role/getAllList` with `{ type: 1 }`.
- Add, detail, edit, delete, status, and password endpoints are unchanged.
- Existing success/error messages and false-return behavior are retained in the
module API adapter.
- Filters remain administrator name, phone, role, and validity range. Date
values are sent as local-day `start_time` / `end_time` second timestamps.
- Pagination remains 1/10 initially with 10/20/30/40 page-size choices.
- All eight columns are retained in order, including the status switch and the
edit/password/delete operation column.
- Permissions remain exact, including the existing `/jurisdiction/role/add`
permission for the administrator Add button.
- Add/edit, password, and delete flows remain module-private components with the
original field validation and confirmation copy.
- Successful mutations refresh the current page without filters, matching the
legacy direct `init()` calls; failed mutations do not refresh.
## Architecture
- `api.ts` owns the legacy request protocol and awaits responses before reading
the response body.
- `query.ts` owns filter-to-request mapping.
- `useAdminList.ts` owns controlled list/filter/pagination/loading/error state,
retry, and stale-response protection.
- `views/AdminListView.tsx` owns permissions, operations, role loading, and
AppList composition.
- `components/` owns the add/edit drawer, password drawer, and delete modal.
- `routes.tsx` owns the leaf route without changing the public URL.
## Verification
- Focused oxlint and oxfmt checks pass for `modules/access/admin`.
- `pnpm test` passed all 11 global Node tests.
- Post-delete `pnpm build` passed with only the existing formula-editor and
large-chunk warnings.
- `pnpm exec tsc --noEmit` reports no access/admin diagnostics; unrelated
existing/concurrent diagnostics remain elsewhere in the workspace.
## Concerns
- No API/query/Hook tests were added, per the updated task instruction.
- Deprecated `src/routes/routes.jsx` and `src/config/component.js` still contain
the legacy page path as inactive metadata. They are outside the authorized
scope; the active `src/app/routes.tsx -> modules/access/routes.tsx` chain and
production build use the new module.
# access/role migration report
## Status
Migrated `/jurisdiction/role` into `modules/access/role`. The active access route
now lazy-loads the module route and view. The old role page was removed after a
successful module build; its request file remains as a compatibility dependency
for the separately owned teachers module.
## Preserved contract
- List API remains `POST /user/role/getList` through `utils/request.js` with a
`qs.stringify` body and `page`, `page_size`, `name`, `start_time`, `end_time`.
- Add, detail, edit, delete, status, selected-permission, permission-save, and
all-permissions endpoints are unchanged.
- Status mutation still sends string `"1"` for enabled and `"2"` for disabled.
- Permission save still sends `{ role_id, power_list }`.
- Existing business success/error messages and false-return behavior remain in
the API adapter.
- Filters remain role name and creation time; date values use local-day second
timestamps. Pagination remains 1/10 with 10/20/30/40 choices.
- All seven columns remain in order. The displayed ID remains the page-relative
sequence number, and remarks retain the 300px three-line Popover treatment.
- Permissions remain exact: `changeStatus`, `getInfoById`, `getPowerByRoleId`,
`del`, and `add` under `/jurisdiction/role/*`.
- Add/edit validation, deletion confirmation, role types, and mutation refresh
behavior are retained.
- The authorization tree remains a module-private component with strict parent/
child checking, descendant selection, ancestor selection, and the original
fixed submit footer. It is not part of AppList.
## Architecture
- `api.ts`: legacy request protocol and awaited response handling.
- `query.ts`: role filter mapping.
- `useRoleList.ts`: controlled rows, filters, pagination, loading, error, retry,
and stale-response protection.
- `views/RoleListView.tsx`: permissions, mutations, AppList, and drawer control.
- `components/`: role form, delete confirmation, and permission tree drawer.
- `routes.tsx`: module-owned leaf route with unchanged public URL.
## Verification
- `modules/access/role` oxlint and oxfmt checks pass.
- `pnpm test` passed all 11 global Node tests.
- Post-delete `pnpm build` passed with only the existing formula-editor and
large-chunk warnings.
- `pnpm exec tsc --noEmit` contains no access/role diagnostics; unrelated
existing/concurrent workspace diagnostics remain.
## Concerns
1. No API/query/Hook tests were added, per instruction.
2. `apps/admin/src/modules/teachers/list/views/TeacherListView.jsx` still imports
`pages/jurisdiction/role/request.js` for `getAllList`. That request file is
therefore retained; removing the cross-module dependency requires a teachers
task and is outside this scope.
3. Deprecated `src/routes/routes.jsx` and `src/config/component.js` still contain
the old role page path as inactive metadata. Active routes and builds use the
new module.
# Advert Migration Report
## Status
- The active `/advertisement/advert` module route now renders `AdvertListView`, whose main list shell is the controlled shared `AppList`.
- The proxy to the legacy page was removed; the legacy page remains available for deprecated route/config compatibility.
- The advertisement-position request remains sourced from `pages/advertisement/adsense/request.js`; that dependency was not modified.
## Preserved list behavior
- Columns: ID, advertisement position, three-line advertisement name, 80×80 image with fallback, linked/truncated URL with popover, status, validity start/end, and operations.
- Filters: advertisement position, advertisement name, and validity range; validity is sent as local start-of-day/end-of-day Unix seconds.
- Pagination remains controlled with page-size choices 10, 20, 30, and 40. Page and page-size changes use the current draft filters.
- Search, reset, refresh, retry, and immediate reload when clearing an active filter are wired through `useAdvertList`.
- Successful add, edit, delete, and status changes preserve the legacy unfiltered-current-page refresh behavior.
## Preserved permissions and operations
- `/advertisement/advert/changeStatus`: status switch.
- `/advertisement/advert/getInfoById`: edit action and detail request.
- `/advertisement/advert/del`: delete action and confirmation.
- `/advertisement/advert/add`: add action.
- Local typed request wrappers preserve the six `/ad/ad/*` endpoints, form encoding, server success/error messages, and `数据获取失败!` fallback.
## Private UI and upload
- `AdvertFormDrawer`, `AdvertImageUpload`, and `DeleteAdvertModal` are private to the advert module.
- The add/edit drawer preserves advertisement-position, name, required image, validity, and URL fields with the legacy validation and copy.
- Editing loads the existing detail and maps its validity values into the date range.
- Image upload preserves Ali OSS STS initialization/refresh, the `zxts-ad-file` bucket, dated randomized object paths, JPG/PNG validation, spinner, uploaded-image preview, and upload error handling.
- Delete confirmation preserves the original wording and successful mutation refresh.
## Verification
- `pnpm exec oxlint src/modules/advertisements/advert --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/advertisements/advert`: passed.
- `pnpm exec tsc --noEmit`: no diagnostics under `src/modules/advertisements/advert`; the workspace still has unrelated existing diagnostics.
- `pnpm test`: passed, 11 tests.
- `pnpm build`: passed; only the existing formula-editor and large-chunk warnings were emitted.
## Concerns
- Per task direction, no API/query/Hook tests were added.
- Deprecated root route/config metadata still references the legacy advertisement page, so that old compatibility implementation and its request file remain in place.
- The unchanged legacy advertisement-position request remains a deliberate dependency of this module.
# Books Design Migration Report
## Status
- The active `/books/design` route now delegates to the private `books/design/routes.tsx` module.
- `DesignView.jsx` owns the dedicated Chuangkit editor integration; no list abstraction or artificial query/Hook layer was introduced.
- The public URL and full-size `ckt-design-page` render target remain unchanged.
## Preserved integration behavior
- The existing Chuangkit app ID, app secret, device type, kind ID, version, authorization, taxpayer values, and render target are preserved exactly.
- Signature generation still sorts every `key=value` pair, joins with `&`, hashes with MD5, and uppercases the result.
- Version 2 signing continues to use `app_id`, the current millisecond `expire_time`, Redux `userInfo.id` as `user_flag`, and `app_secret`.
- The SDK opens after the view mounts and closes during cleanup; a user identity change safely recreates the editor with a matching signature.
- `window.chuangkitComplete` remains installed as the completion/save callback and preserves the original callback handling.
- The legacy page had no backend request wrapper, explicit application error UI, or separate stylesheet, so none were invented during migration.
## Legacy compatibility
- The old `pages/books/design/index.jsx` was not deleted because deprecated `src/routes/routes.jsx` still imports it and `src/config/component.js` still names it through the dynamic component path. Both references are outside this task's allowed scope.
## Verification
- `pnpm exec oxlint src/modules/books/design src/modules/books/routes.tsx --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/books/design src/modules/books/routes.tsx`: passed.
- `pnpm exec tsc --noEmit`: no diagnostics under `src/modules/books/design` or `src/modules/books/routes.tsx`; the workspace still has unrelated existing diagnostics.
- `pnpm test`: passed, 11 tests.
- `pnpm build`: passed; only the existing formula-editor and large-chunk warnings were emitted.
## Concerns
- Per task direction, no API/query/Hook tests were added; this page has no local API/query/Hook layer.
- The client-side Chuangkit credentials and taxpayer metadata remain hardcoded because preserving the existing integration exactly was required.
- The legacy page remains until its deprecated route/config references can be migrated in an explicitly authorized scope.
# Books Management Migration Report
## Status
- The active `/books/management` and `/books/management/list` entries now render the controlled shared `AppList` directly.
- The incomplete three-column placeholder was replaced with the complete behavior of the legacy 641-line list page.
- Existing `/books/management/add-edit` and `/books/management/chapter` boundaries remain available and keep their state payloads.
## AppList integration
- `ManagementListView.tsx` renders `AppList<ManagementRow, ManagementFilters>` as the main list shell.
- Data, total, loading, error, retry, page, page size, controlled filters, search, reset, refresh, and toolbar actions are wired through `useManagementList`.
- The legacy duplicate `ManagementListView.jsx` entry was removed so extension resolution cannot bypass the AppList implementation.
- Page and page-size changes use the current draft filters, matching the legacy `PaginationCom` behavior.
- Clearing an active input/select/date filter reloads immediately; mutations retain the legacy unfiltered-current-page refresh behavior.
## Preserved columns and filters
- Columns: book name, 80×80 cover with fallback, author, audit status, release count, creation time, update time, and operations.
- Audit display preserves the four values: 待发布, 审核中, 审核未通过, 审核通过.
- Filters: book name, author, audit status, and creation date range.
- Date range conversion preserves local start-of-day and end-of-day Unix seconds.
- Page-size choices remain 10, 20, 30, and 40.
## Preserved permissions and operations
- `/books/management/getAllList`: creator/admin basic-information action.
- `/books/management/getInfoById`: chapter editing action, including `isAdminOrCreator` navigation state.
- `/books/management/export`: creator/admin Word export confirmation and original HTML removal selectors.
- `/books/management/del`: creator/admin delete confirmation with `确认删除书籍?`.
- `/books/management/release`: creator/admin publish-review action only for `audit_status === 1`.
- `/books/management/import`: import drawer action.
- `/books/management/add`: add-book navigation.
- Creator/admin calculation remains `userInfo.id === user_id || userInfo.type === 1`.
## Preserved requests and private UI
- Main-list requests are now local typed implementations:
- `POST /book/teacher/getList`
- `POST /book/teacher/del`
- `POST /book/teacher/export`
- `POST /book/teacher/release`
- multipart `POST /book/teacher/import`
- Server success/error messages and fallback `数据获取失败!` are retained.
- `DeleteBookModal`, `ExportBookModal`, `ImportBookDrawer`, and `ReleaseBookDrawer` retain their original copy, validation, and actions.
- Import retains DOC/DOCX validation, filename display, five catalog levels, template download, upload spinner, and successful full-page reload.
- Publish review retains the 255-character message limit and successful full-page reload.
## Verification
- `pnpm exec oxlint src/modules/books/management --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/books/management`: passed.
- `pnpm exec tsc --noEmit`: no diagnostics under `src/modules/books/management`; the workspace still has unrelated existing diagnostics.
- `pnpm test`: passed, 11 tests.
- `pnpm build`: passed; only the existing formula-editor and large-chunk warnings were emitted.
## Concerns
- Per task direction, no API/query/Hook feature tests were added.
- Add/edit and chapter screens remain compatibility boundaries over their existing implementations; this change fully replaces only the main-list implementation requested here.
- Deprecated root route/config metadata still references the legacy management pages, so those old pages remain in place.
# Books Orders Migration Report
## Status
- The active `/books/order-management` route now delegates to the private `books/orders/routes.tsx` module.
- The module owns the order-list routes (`index` and `dataset`) and the `sale-statistics` route while preserving all public URLs.
- Order list and sales statistics views are route-lazy, so the ECharts implementation remains isolated from the normal list route.
## Order list
- `OrdersListView.tsx` renders `AppList<OrderRow, OrdersFilters>` as the controlled list shell.
- The 13 legacy columns are preserved: order ID, book, buyer, four amount columns, order/pay timestamps, settlement state, payment icon, rating, and refund action.
- Filters preserve book name, payment status, payment type, and payment-time range.
- Payment-time conversion uses the real backend fields `start_pay` and `end_pay`, with local start-of-day and end-of-day Unix seconds.
- Pagination, page-size choices 10/20/30/40, draft-filter pagination, search, reset, refresh, retry, and immediate reload when clearing a filter are controlled through `useOrdersList`.
## Permissions, requests, and refund
- The tab options preserve `/books/order-management/getOrderList`, the legacy list-only `/books/order-management/getOrderList1`, and `/books/order-management/salesStatistics` checks.
- Refund remains available only with `/books/order-management/refund` and `status === 3`.
- `RefundDrawer` preserves the optional refund reason, original copy, mask/keyboard behavior, and cancel/confirm actions.
- Successful refunds preserve the legacy unfiltered-current-page refresh behavior.
- Local request wrappers preserve form encoding, success/error messages, fallback text, and these endpoints:
- `POST /order/getOrderList`
- `POST /order/salesStatistics`
- `POST /order/refund`
## Sales statistics
- The dedicated sales view preserves the four summary cards: expected income, orders, buyers, and average order value, including formatting and units.
- The monthly sales bar chart preserves alternating month labels, tooltip formatting, and original color.
- `type === 1` renders teacher TOP10 performance; `type === 2` renders book TOP10 performance.
- Both pie charts preserve titles, tooltip fields, ratios, wrapped labels, colors, and layout.
- ECharts instances are initialized from private refs and disposed when the view changes or unmounts.
## Legacy compatibility
- The old `pages/books/order-management` files were not deleted because deprecated `src/routes/routes.jsx` and `src/config/component.js` still reference them and those files are outside this task's allowed scope.
## Verification
- `pnpm exec oxlint src/modules/books/orders src/modules/books/routes.tsx --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/books/orders src/modules/books/routes.tsx`: passed.
- `pnpm exec tsc --noEmit`: no diagnostics under `src/modules/books/orders` or `src/modules/books/routes.tsx`; the workspace still has unrelated existing diagnostics.
- `pnpm test`: passed, 11 tests.
- `pnpm build`: passed; only the existing formula-editor and large-chunk warnings were emitted.
## Concerns
- Per task direction, no API/query/Hook tests were added.
- The dedicated ECharts sales-statistics chunk remains large, matching the legacy implementation's dependency footprint.
- Legacy order-management pages remain until all deprecated route/config references can be migrated in an explicitly authorized scope.
# Admin Legacy Cleanup Audit
## Scope and method
This audit traced static imports, re-exports, literal dynamic imports, CSS imports, and `@/` aliases from the Vite entry. It also checked source references with `rg`, the route-boundary tests, and a production build after the minimal deletion.
The active route chain is:
```text
apps/admin/index.html
-> src/main.jsx
-> src/App.tsx
-> src/app/routes.tsx
-> src/modules/*/routes.tsx
```
`src/app/route-manifest.ts` remains the canonical public-URL inventory used by tests. The static active graph contained 406 local files, including 37 files under `src/pages`.
## Deleted in this cleanup
These files had no inbound edge from the active entry/module graph and were removed as explicitly authorized:
```text
apps/admin/src/routes/index.jsx
apps/admin/src/routes/routes.jsx
apps/admin/src/routes/lazyLoadAndDelay.jsx
apps/admin/src/components/list/index.jsx
apps/admin/src/common/TableCom/index.jsx
```
Evidence for the legacy route deletion:
- `index.html` loads only `/src/main.jsx`.
- `main.jsx` renders `App.tsx`; `App.tsx` imports `@/app/routes`, not `@/routes`.
- The only importer of `src/routes/routes.jsx` was `src/routes/index.jsx`; nothing imported `src/routes/index.jsx`.
- `lazyLoadAndDelay.jsx` was imported only by the deleted legacy route table.
- The route-boundary tests still pass after deletion and confirm that root routing delegates to domain modules.
- The production build succeeds after deletion.
Evidence for the old list-component deletion:
- No active `main/App/app/modules/layout/utils` file imports `@/components/list` or `@/common/TableCom`.
- The current list implementation is `src/shared/components/AppList`.
- `common/TableCom` and `components/list` were absent from the active static graph; tests and production build pass after removal.
## Runtime files that must remain
### Route/menu metadata
```text
main.jsx -> App.tsx -> app/routes.tsx -> layout/index.jsx
-> utils/breadCrumb.js -> config/component.js
```
`apps/admin/src/config/component.js` is therefore runtime code, not an unused route table. `generateToAntdMenus` reads its names, component metadata, `isChild`, and `childThirdRoutes`. Do not delete it until the layout/menu metadata path is replaced.
### Legacy pagination
```text
app/routes.tsx -> modules/members/routes.tsx
-> modules/members/list/routes.tsx
-> modules/members/list/components/MemberDetailView.tsx
-> common/Pagination/index.jsx
```
`apps/admin/src/common/Pagination/` remains active and cannot be removed.
### Legacy pages and requests still in the active graph
These exact files remain reachable and must not be deleted:
```text
pages/advertisement/adsense/request.js
pages/advertisement/index.jsx
pages/books/audit/index.less
pages/books/audit/preview.jsx
pages/books/classify/request.js
pages/books/index.jsx
pages/books/management/addedit/index.jsx
pages/books/management/api.js
pages/books/question-bank/questionEditr/index.jsx
pages/books/question-bank/request.js
pages/books/sale/discussDetail/index.jsx
pages/books/sale/edit.jsx
pages/books/sale/request.js
pages/books/section/api.js
pages/books/section/components/BindKnowledgeGraph.jsx
pages/books/section/components/EditChapterEditors.jsx
pages/books/section/components/form-chapter-title.jsx
pages/books/section/index.jsx
pages/books/section/index.less
pages/books/section/request.js
pages/jurisdiction/index.jsx
pages/jurisdiction/role/request.js
pages/member/index.jsx
pages/setting/help/addedit/requet.js
pages/setting/index.jsx
pages/student/book/index.jsx
pages/student/book/index.less
pages/student/book/request.js
pages/teacher/index.jsx
pages/user-module/index.jsx
pages/user-module/index.less
pages/user-module/login/index.jsx
pages/user-module/login/request/api.js
pages/user-module/login/request/index.js
pages/user-module/request/api.js
pages/user-module/request/request.jsx
pages/user-module/userInfo/index.jsx
```
Important representative chains:
- `modules/books/management/routes.tsx` reaches the legacy add/edit screen and the full `pages/books/section` editor subtree.
- `modules/books/sale/routes.tsx` reaches the legacy sale editor and discussion-detail screen, which still depend on legacy sale/classify requests.
- `modules/books/audit` reaches the legacy preview and audit stylesheet; preview also depends on the section request.
- `modules/advertisements/advert` still deliberately uses the legacy adsense request and help-center OSS token request.
- Auth, account, student-book, and the top-level domain layouts remain compatibility wrappers over legacy pages.
## Inactive pages retained for a later domain cleanup
The audit also found legacy page files outside the active graph. They were not removed in this minimal cleanup because they must be handled by domain and some still contain textual imports of the deleted legacy list helpers.
In particular:
- Fourteen inactive legacy pages still contain `@/common/TableCom` imports.
- `pages/setting/coupon/index.jsx` still contains an `@/components/list` import.
- `config/component.js` still contains legacy component-path strings for menu metadata; these are not dynamic imports, but should be updated only when that metadata layer is redesigned.
Recommended next step: delete inactive pages in domain-sized commits, remove their now-stale helper imports at the same time, and rerun the active-graph audit after each domain. Do not delete the 37 reachable files listed above.
## Post-cleanup verification
- Active-entry/module `rg` check for the deleted route and list helpers: no references.
- Full-page-tree `rg`: only the inactive legacy-page imports documented above remain.
- `pnpm test`: passed, 11 tests.
- `pnpm build`: passed; only the existing formula-editor and large-chunk warnings were emitted.
## Concerns
- The retained inactive pages are not independently buildable until their stale `TableCom`/`components/list` imports are removed or replaced. They are outside the current Vite runtime graph, which is why the admin build remains valid.
- `config/component.js` couples backend menu data to legacy component-path metadata even though current routing is module-owned; it needs a separate migration before removal.
# Members Level Migration Report
## Status
- Migrated the active `/member/userlevel` route to `src/modules/members/level`.
- Kept the public URL unchanged and added a private nested route boundary.
- Replaced the legacy table/filter/pagination shell with the shared `AppList` contract.
- Kept the legacy page and request module because deprecated route metadata still references the page.
## Preserved behavior
- List request: `POST /members/grade/getList` with form-encoded `page`, `page_size`, optional `name`, `start_time`, and `end_time`.
- Detail/add/edit/delete requests:
- `POST /members/grade/getInfoById`
- `POST /members/grade/add`
- `POST /members/grade/edit`
- `POST /members/grade/del`
- Validity requests retain `type: 2`:
- `POST /system/config/getSystem`
- `POST /system/config/systemEdit`
- Server success/error messages and the fallback error text `数据获取失败!` are retained.
- Filters retain level name and local-day creation-time boundaries. Clearing a populated filter reloads immediately.
- Columns retain ID, level name, qualifying amount, creation time, update time, and operations.
- Add/edit validation retains the 30-character name limit and non-negative integer amount pattern.
- Delete confirmation retains `等级将永久删除,是否继续?`.
- Mutations refresh the current page without applying the draft filters, matching the legacy `init()` behavior.
- The source page has no sorting or status-changing operation to migrate.
## Permissions
- `/member/userlevel/getInfoById`: edit action
- `/member/userlevel/del`: delete action
- `/member/userlevel/add`: add action
- `/member/userlevel/getSystem`: load/show validity setting
- `/member/userlevel/systemEdit`: submit/cancel controls in the validity drawer
## Structure
- `api.ts`: form-encoded API calls and server notifications.
- `query.ts`: list filter-to-query conversion.
- `useMemberLevelList.ts`: controlled draft/applied filters, pagination, loading, retry, and stale-request protection.
- `routes.tsx`: private member-level route boundary.
- `views/MemberLevelListView.tsx`: list composition and action coordination.
- `components/MemberLevelFormDrawer.tsx`: add/edit form.
- `components/DeleteMemberLevelModal.tsx`: delete confirmation.
- `components/ValidityDrawer.tsx`: user-level validity setting.
## Verification
- `pnpm exec oxlint src/modules/members/level src/modules/members/routes.tsx --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/members/level src/modules/members/routes.tsx`: passed.
- `pnpm test`: passed, 11 tests.
- `pnpm build`: passed; only the existing formula-editor and large-chunk warnings were emitted.
- `pnpm exec tsc --noEmit`: no diagnostics under `src/modules/members/level` or `src/modules/members/routes.tsx`; the workspace still has unrelated existing diagnostics.
## Concerns
- Per task direction, no API/query/Hook feature tests were added.
- `src/routes/routes.jsx` still imports `@/pages/member/userlevel/index`, and `src/config/component.js` still names `member/userlevel/index`. Both are outside this task's allowed scope. Therefore the legacy `src/pages/member/userlevel/index.jsx` and its sibling `request.js` were intentionally retained instead of deleting a still-referenced page.
# Settings app-version migration
- Moved the version list, publication/edit/delete drawers, upload flow, permissions, request helpers, and route into `modules/settings/app-version`.
- Replaced the legacy table/pagination frame with `AppList` while retaining the existing URL and business UI.
# Settings applied migration
- Moved the application list, add/edit drawer, delete/status operations, filters, permissions, and API helpers into `modules/settings/applied`.
- Preserved `/setting/applied`; replaced the standard table and pagination frame with controlled `AppList`.
- `pnpm build` passed. `pnpm test` was run; Admin tests pass while the known Learning failures remain.
# Settings help migration
## Delivered
- Moved the Help route shell, list, and add/edit editor into `modules/settings/help` while retaining `/setting/help/list` and `/setting/help/addedit`.
- Preserved route state for edit navigation, permissions, list filters, delete confirmation, rich-editor preview, OSS image/video upload, add/edit publishing, and back navigation.
- Extracted the Help API, query construction, and list state; replaced only `TableCom` and `PaginationCom` with controlled `AppList`.
- Removed the old Help page implementation and styles.
## Compatibility exception
`pages/setting/help/addedit/requet.js` remains because multiple un-migrated pages import its OSS-token helper. The migrated Help editor calls it only as a compatibility dependency.
## Verification
- `pnpm build`: passed for both learning and admin.
- `pnpm test`: Admin 11/11 passed. The workspace remains red because of existing Learning failures in `src/modules/my/routes.test.tsx`, `src/api/http-auth.test.ts`, and router tests without a `QueryClientProvider`.
- `git diff --check`: passed.
# Settings Personalized Migration Report
## Status
- Migrated the active `/setting/personalized-setting` route to `src/modules/settings/personalized`.
- Preserved the page as a dedicated configuration form; it is not a table/list page, so `AppList` and `query.ts` were not introduced.
- Kept the legacy page because deprecated route metadata still references it.
## Preserved behavior
- Initial load remains `POST /system/config/getSystem` with an empty form-encoded body.
- Save remains `POST /system/config/systemEdit` with the three form values.
- Successful saves show the server message and reload the current settings.
- Non-200 responses show the server message or the fallback `数据获取失败!`; transport failures remain silent.
- The dedicated form retains:
- `老师分成百分比(%)` / `teacher_divide`
- `用户等级有效期(月)` / `user_grade_validity`
- `评论是否需要审核` / `comments_need_approval`, with string values `"1"` and `"0"`
- the original required-field messages, explanatory copy, widths, reset-to-empty action, and submit action
- The loading spinner and submit loading state remain tied to settings reloads.
- The source page contains no operation-permission checks, so none were added.
## Structure
- `api.ts`: form-encoded load/save requests and server notifications.
- `routes.tsx`: private route boundary that preserves `/setting/personalized-setting`.
- `views/PersonalizedSettingsView.tsx`: initial loading and save-then-reload coordination.
- `components/PersonalizedForm.tsx`: private dedicated form layout, validation, and reset behavior.
## Verification
- `pnpm exec oxlint src/modules/settings/personalized src/modules/settings/routes.tsx --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/settings/personalized src/modules/settings/routes.tsx`: passed.
- `pnpm test`: passed, 11 tests.
- `pnpm build`: passed; only the existing formula-editor and large-chunk warnings were emitted.
- `pnpm exec tsc --noEmit`: no diagnostics under `src/modules/settings/personalized` or `src/modules/settings/routes.tsx`; the workspace still has unrelated existing diagnostics.
## Concerns
- Per task direction, no API/query/Hook feature tests were added; this page does not require query or list Hook files.
- `src/routes/routes.jsx` still imports `@/pages/setting/personalized-setting/index`, and `src/config/component.js` still names `setting/personalized-setting/index`. Both are outside this task's allowed scope. Therefore the legacy `src/pages/setting/personalized-setting/index.jsx` was intentionally retained instead of deleting a still-referenced page.
# Settings Point Management Migration Report
## Status
- Migrated the active `/setting/point-management` route to `src/modules/settings/point-management`.
- Preserved the page as a dedicated settings form with a private recharge drawer; no `AppList`, query, or list Hook was introduced.
- Kept the legacy page and request module because they still have references outside this task's allowed scope.
## Preserved requests and feedback
- `POST /system/config/getIntegral`: loads the integral settings with an empty form-encoded body; non-200 responses show the server message or `数据获取失败!`.
- `POST /system/config/integralSet`: sends string `integral_switch` values `"1"`/`"0"`; success remains silent and reloads settings.
- `POST /system/config/integralEdit`: saves the three settings fields; success shows the server message and reloads settings.
- `POST /members/integral/add`: retains the legacy result handling:
- code `3000` warns `优惠券已过期` and closes the drawer;
- successful phone groups show `<手机号结果>充值成功` for 10 seconds;
- failed phone groups show `<手机号结果>充值失败` for 10 seconds;
- the special blank-phone failure text remains suppressed when a non-zero success group exists.
- Transport failures remain silent and do not close or refresh the current form.
## Preserved form behavior
- Integral switch remains controlled from `integral_switch`; disabling it closes the recharge drawer.
- The recharge button remains hidden without permission and disabled/invisible while the switch is off.
- Settings fields, dimensions, validation expressions, validation copy, reset-to-empty action, and responsive label layout are retained:
- `reward_points_ratio`
- `one_point_deduction_amount`
- `integral_use_limit`
- The recharge drawer retains `phone` and `num`, the comma-separated-phone copy, 100-phone note, positive-integer validation, and cancel/submit actions.
- Recharge form state remains mounted when the drawer closes, matching the legacy Drawer behavior.
## Permissions
- `/setting/point-management/integralSet`: enable the integral switch.
- `/setting/point-management/add`: show the recharge action.
- `/setting/point-management/integralEdit`: show reset and save actions.
## Structure
- `api.ts`: form-encoded settings/switch/recharge requests and notifications.
- `routes.tsx`: private route boundary preserving `/setting/point-management`.
- `views/PointManagementView.tsx`: loading, switch, save, and recharge coordination.
- `components/PointSettingsForm.tsx`: private integral settings form.
- `components/RechargeDrawer.tsx`: private recharge form drawer.
## Verification
- `pnpm exec oxlint src/modules/settings/point-management src/modules/settings/routes.tsx --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/settings/point-management src/modules/settings/routes.tsx`: passed.
- `pnpm test`: passed, 11 tests.
- `pnpm build`: passed after the unrelated concurrent syntax error was resolved; only the existing formula-editor and large-chunk warnings were emitted.
- `pnpm exec tsc --noEmit`: no diagnostics under `src/modules/settings/point-management` or `src/modules/settings/routes.tsx`; the workspace still has unrelated existing diagnostics.
## Concerns
- Per task direction, no API/query/Hook feature tests were added; this page does not require query or list Hook files.
- `src/routes/routes.jsx` and `src/config/component.js` still reference the legacy point-management page. In addition, the retained legacy personalized-setting page imports `point-management/request.js`. These files are outside this task's allowed scope, so `src/pages/setting/point-management/index.jsx` and `request.js` were intentionally retained.
# Settings sensitive migration
- Moved the sensitive-word list, filters, edit/delete/import flows, permissions, requests, and route into `modules/settings/sensitive`.
- AppList now owns the filter, pagination, refresh, retry and table states; `pnpm test` passed 11/11 and `pnpm build` succeeded.
# Task 5 report: settings/feedback migration
## Status
Completed the `settings/feedback` migration from the legacy page directory to
`apps/admin/src/modules/settings/feedback`. The `/setting/feedback` route now
lazy-loads the module-owned route and view. The legacy feedback page and API
files were removed only after the module build and regression checks passed.
## Delivered structure
- `api.ts`: retains the existing axios client, POST endpoint, and object body;
awaits the business envelope before normalizing `data.list` / `data.total` to
`{ rows, total }`.
- `query.ts`: maps `pageSize` to `page_size`, maps the selected time range to
second-based `start_time` / `end_time`, removes the UI-only `time` field, and
preserves meaningful falsy values rather than applying generic cleanup.
- `useFeedbackList.ts`: owns draft/applied filters, controlled pagination,
rows, total, loading, error, retry, and request sequencing.
- `views/FeedbackListView.tsx`: preserves the three filters and the five legacy
columns in their original order, including the 400px two-line feedback cell
with full text in a Popover. The page has no permissions, toolbar, row
selection, or row actions, matching the legacy page.
- `routes.tsx`: owns the feedback leaf route and fallback without changing the
public URL.
- `modules/settings/routes.tsx`: delegates `feedback/*` to the new module.
## Preserved behavior
- API: `POST /api/system/proposal/getList` with an object body.
- Initial pagination: page 1, page size 10; allowed sizes 10/20/30/40.
- Search and reset return to page 1 and issue one explicit request.
- Pagination uses applied filters; refresh keeps the current page and applies
the current filter draft, matching the old form-backed refresh behavior.
- Clearing a filter explicitly reloads the current page, matching the old
`onValuesChange` behavior.
- Failures end loading, retain existing rows when present, surface an error to
AppList, and retry the last request.
- A request sequence prevents an older response from overwriting newer data.
## Verification
- `pnpm test`: passed, 11/11 global Node tests.
- `pnpm test:unit --run`: passed, 10/10 smoke and controlled AppList tests.
- Feedback development guards before their removal per the updated task scope:
passed, 14/14 query/API/Hook tests. These files were not committed.
- `pnpm exec oxlint src/modules/settings/feedback --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/settings/feedback`: passed.
- `pnpm build`: passed; existing formula-editor and large-chunk warnings remain.
- `pnpm exec tsc --noEmit`: feedback introduced no diagnostics. The command
remains nonzero for existing/shared concurrent issues in `App.tsx`,
`RouteErrorBoundary.tsx`, and the shared AppList LESS side-effect import.
## Concerns
1. Per the updated task scope, no feedback API/query/Hook test files are part of
this commit. The development-only guards were removed before staging.
2. Legacy, unused route metadata files still contain imports of the removed old
page path. The active `src/app/routes.tsx` path uses the new domain route and
production build succeeds; updating deprecated route metadata was outside
this task's authorized scope.
# Task 6 — advertisements/adsense
## Delivered
- Added the `advertisements/adsense` module with request normalization, controlled pagination state, AppList view, and a lazy nested route.
- Preserved the legacy list request (`page`/`page_size`, form-encoded POST), the three original columns, controlled status Switch, exact change-status permission, and refresh of the current page after a successful mutation.
- The mutation sends status as the required string (`"1"`/`"0"`). Failed mutations leave the displayed server row unchanged.
- Removed `pages/advertisement/adsense/index.jsx` after routing had switched to the new module.
## Compatibility exception
`pages/advertisement/adsense/request.js` remains intentionally: the un-migrated `pages/advertisement/advert/index.jsx` imports its `getPositionList` helper. Removing it breaks the unrelated advert page and the admin build. The new AdSense module does not import this compatibility file.
## Verification
- Focused state regression test: 2 passed (`useAdsenseList.test.tsx`) using an ephemeral Vitest config because the tracked admin Vitest config only includes `src/test/**` and therefore excludes module-local tests.
- `pnpm build`: passed for learning and admin.
- `pnpm test`: admin's 11 tests passed; the overall workspace command failed in pre-existing learning tests (`src/modules/my/routes.test.tsx`, `src/api/http-auth.test.ts`) unrelated to this migration.
- `git diff --check`: passed.
# Teachers list migration
## Delivered
- Moved the teacher-list route to `modules/teachers/list`, retaining the existing filters, columns, permissions, drawers, modal flows, validation, role lookup, status/password/delete actions, and commission settings.
- Extracted form-encoded API calls, query construction, and list state into `api.ts`, `query.ts`, and `useTeacherList.ts`.
- Replaced only the legacy `TableCom` and `PaginationCom` list frame with controlled `AppList` pagination.
- Deleted the old `pages/teacher/query` implementation after the live module route stopped importing it.
## Verification
- `pnpm build`: passed for both learning and admin.
- `pnpm test`: admin 11/11 passed. The workspace command still fails in pre-existing learning tests, including `src/api/http-auth.test.ts`, `src/modules/my/routes.test.tsx`, and router tests missing a `QueryClientProvider`.
- `git diff --check`: passed.
## Compatibility note
The obsolete `src/routes/routes.jsx` still contains a dead legacy teacher-page declaration, but the live `app/routes.tsx` does not import that file. It is outside this migration's permitted route scope.
...@@ -2,30 +2,25 @@ ...@@ -2,30 +2,25 @@
## Project Structure & Module Organization ## Project Structure & Module Organization
This is a pnpm monorepo. `apps/admin` is the React 19 administration app (mostly JavaScript/JSX); `apps/learning` is the React 19 learning app written in TypeScript. Both apps currently use Ant Design 6, but they keep separate routers, API clients, state, and build outputs. Keep application code, assets, routes, API clients, and tests inside the owning app—never import another app’s `src` directory. Shared packages belong in `packages/` only when they are genuinely used by both apps. This pnpm monorepo contains two independent React 19 apps: `apps/admin` (JavaScript/JSX) and `apps/learning` (TypeScript). Each app owns its routes, API clients, state, assets, and tests; do not import from another app’s `src` directory. Within an app, place domain features in `src/modules/<domain>/`, reusable UI in `src/components/`, and cross-cutting hooks and utilities in `src/hooks/` and `src/utils/`. Shared code belongs in `packages/` only when both apps use it. Admin WangEditor features stay under `apps/admin/src/components/editor/wangeditor-customer/features/`.
Within an app, feature code lives under `src/modules/<domain>/`; reusable UI belongs in `src/components/`; cross-cutting utilities and hooks belong in `src/utils/` and `src/hooks/`. Keep editor features isolated under `apps/admin/src/components/editor/wangeditor-customer/features/`. Put plans and technical notes in `docs/`.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `pnpm dev` — run Admin and Learning Vite servers in parallel. - `pnpm dev` starts both Vite apps; use `pnpm dev:admin` or `pnpm dev:learning` for one app.
- `pnpm dev:admin` / `pnpm dev:learning` — run one app only. - `pnpm build` builds Learning and then Admin; `pnpm build:learning` and `pnpm build:admin` build separately.
- `pnpm build` — build Learning, then Admin. - `pnpm test` runs both app test suites; `pnpm test:learning` and `pnpm test:admin` run one suite.
- `pnpm build:admin` / `pnpm build:learning` — build one app. - `pnpm lint:check` runs read-only Oxlint checks. `pnpm lint:learning` and `pnpm lint:admin` apply fixes, so inspect the diff afterward.
- `pnpm test`, `pnpm test:admin`, `pnpm test:learning` — run Vitest and Node tests. - `pnpm typecheck:learning` runs the strict Learning TypeScript check.
- `pnpm lint:admin` / `pnpm lint:learning` — run Oxlint with auto-fix; review the resulting diff. - Format an app with `pnpm --filter @ebook/learning format` or `pnpm --filter @ebook/admin format`.
- `pnpm lint:check` — run read-only Oxlint checks for both apps.
- `pnpm typecheck:learning` — run the strict Learning TypeScript check.
- `pnpm --filter @ebook/admin format` — format Admin source with Oxfmt; use the equivalent Learning filter when needed.
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
Use two-space indentation, single quotes, and no semicolons; Oxfmt is authoritative. Prefer functional React components. Components use `PascalCase`, hooks use `useXxx`, and utilities use `camelCase`. Learning code should remain TypeScript-first; avoid `any` and keep server state in React Query, URL state in nuqs, and transient shared state in Zustand. Follow the surrounding module’s established JavaScript/JSX style in Admin. Use two-space indentation, single quotes, and no semicolons; Oxfmt is authoritative. Prefer functional React components. Name components `PascalCase`, hooks `useXxx`, and utilities `camelCase`. Keep Learning TypeScript-first, avoid `any`, put server state in React Query, URL state in nuqs, and shared transient state in Zustand. Follow the surrounding JavaScript/JSX style in Admin. Both apps use Ant Design 6; check existing components and tokens before adding custom UI.
## Testing Guidelines ## Testing Guidelines
Use Vitest for unit/component tests and Node’s test runner where existing Admin tests do. Place tests beside the code as `*.test.ts`, `*.test.tsx`, `*.test.js`, or `*.test.mjs`. Add tests for behavior changes with meaningful branching or data transformation; do not add superficial snapshot-only tests. Use Vitest for unit and component tests, and Node’s test runner where existing Admin tests do. Place tests beside the code as `*.test.ts`, `*.test.tsx`, `*.test.js`, or `*.test.mjs`. Add focused tests for meaningful branching or data transformation; avoid snapshot-only tests. Run the relevant app’s tests and build for behavior changes.
## Commit & Pull Request Guidelines ## Commit & Pull Request Guidelines
Use concise Conventional Commit subjects, e.g. `feat: add book export styles`, `fix(admin): restore toolbar state`, or `refactor: simplify permission hook`. Keep commits focused. PRs should state scope, verification performed, relevant API/configuration changes, and include screenshots for visible UI changes. Do not commit secrets, local `.env` values, build output, or unrelated formatting changes. Use concise Conventional Commit subjects such as `feat: add book export styles` or `fix(admin): restore toolbar state`; keep commits focused. PRs should describe scope and verification, call out API or configuration changes, link related issues when available, and include screenshots for visible UI changes. Keep local environment values, secrets, generated build output, and unrelated formatting changes out of commits.
VITE_ADMIN_URL=https://zijingebook.ezijing.com VITE_ADMIN_URL=https://zijingebook.ezijing.com
VITE_APP_ID=dkFv6OwWyQhSeaZG
VITE_APP_SECRET=d34cea92316d1da113e95158bed9ca97
...@@ -17,18 +17,20 @@ ...@@ -17,18 +17,20 @@
} }
.learning-layout__header-inner { .learning-layout__header-inner {
display: flex; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 400px) minmax(0, 1fr);
width: min(var(--learning-content-max-width), 100%); width: min(var(--learning-content-max-width), 100%);
height: 100%; height: 100%;
margin: 0 auto; margin: 0 auto;
align-items: center; align-items: center;
justify-content: space-between; gap: 24px;
} }
.learning-layout__brand { .learning-layout__brand {
display: inline-flex; display: inline-flex;
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
justify-self: start;
white-space: nowrap; white-space: nowrap;
} }
...@@ -38,9 +40,17 @@ ...@@ -38,9 +40,17 @@
height: 36px; height: 36px;
} }
.learning-layout__search {
width: 100%;
max-width: 400px;
justify-self: center;
}
.learning-layout__header-actions { .learning-layout__header-actions {
display: flex; display: flex;
flex: 0 0 auto;
align-items: center; align-items: center;
justify-self: end;
min-width: 0; min-width: 0;
gap: 16px; gap: 16px;
} }
...@@ -151,6 +161,37 @@ ...@@ -151,6 +161,37 @@
color: var(--main-color); color: var(--main-color);
} }
@media (max-width: 900px) {
.learning-layout__header {
height: auto;
min-height: 84px;
padding-block: 10px;
}
.learning-layout__header-inner {
height: auto;
min-height: 60px;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px 16px;
}
.learning-layout__brand {
grid-column: 1;
grid-row: 1;
}
.learning-layout__header-actions {
grid-column: 2;
grid-row: 1;
}
.learning-layout__search {
grid-column: 1 / -1;
grid-row: 2;
width: min(400px, 100%);
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.learning-layout__header, .learning-layout__header,
.learning-layout__content { .learning-layout__content {
......
import { BellOutlined } from '@ant-design/icons' import { BellOutlined, SearchOutlined } from '@ant-design/icons'
import { Badge, Button, Layout } from 'antd' import { Badge, Button, Input, Layout } from 'antd'
import { Link, Outlet, useLocation, useNavigate } from 'react-router' import { Link, Outlet, useLocation, useNavigate } from 'react-router'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react' import { useEffect, useState } from 'react'
import './LearningLayout.css' import './LearningLayout.css'
import { getTopNavigationKey, navigationItems } from './navigation' import { getTopNavigationKey, navigationItems } from './navigation'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
...@@ -16,9 +16,11 @@ import { unreadMessageCountOptions } from '@/modules/my/messages/query' ...@@ -16,9 +16,11 @@ import { unreadMessageCountOptions } from '@/modules/my/messages/query'
export function LearningLayout() { export function LearningLayout() {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const routeKeyword = new URLSearchParams(location.search).get('keyword') ?? ''
const session = useSessionStore((state) => state.session) const session = useSessionStore((state) => state.session)
const clearSession = useSessionStore((state) => state.clearSession) const clearSession = useSessionStore((state) => state.clearSession)
const [mobileDownloadOpen, setMobileDownloadOpen] = useState(false) const [mobileDownloadOpen, setMobileDownloadOpen] = useState(false)
const [searchValue, setSearchValue] = useState(routeKeyword)
const adminAccessQuery = useQuery({ const adminAccessQuery = useQuery({
queryKey: ['admin-access'], queryKey: ['admin-access'],
queryFn: () => requestAdminAccess(), queryFn: () => requestAdminAccess(),
...@@ -29,6 +31,13 @@ export function LearningLayout() { ...@@ -29,6 +31,13 @@ export function LearningLayout() {
const unreadQuery = useQuery({ ...unreadMessageCountOptions(), enabled: Boolean(session) }) const unreadQuery = useQuery({ ...unreadMessageCountOptions(), enabled: Boolean(session) })
const unreadCount = unreadQuery.data ?? 0 const unreadCount = unreadQuery.data ?? 0
useEffect(() => setSearchValue(routeKeyword), [routeKeyword])
const searchBooks = (value: string) => {
const keyword = value.trim()
navigate(keyword ? `/search?keyword=${encodeURIComponent(keyword)}` : '/search')
}
const logoutMember = async () => { const logoutMember = async () => {
try { try {
await logout() await logout()
...@@ -47,69 +56,63 @@ export function LearningLayout() { ...@@ -47,69 +56,63 @@ export function LearningLayout() {
} }
return ( return (
<Layout className='learning-layout'> <Layout className="learning-layout">
<Layout.Header className='learning-layout__header'> <Layout.Header className="learning-layout__header">
<div className='learning-layout__header-inner'> <div className="learning-layout__header-inner">
<Link <Link className="learning-layout__brand" to="/library" aria-label="清控紫荆数智学堂图书馆">
className='learning-layout__brand' <img src={logo} alt="清控紫荆数智学堂" />
to='/library'
aria-label='清控紫荆数智学堂图书馆'
>
<img src={logo} alt='清控紫荆数智学堂' />
</Link> </Link>
<div className='learning-layout__header-actions'> <Input.Search
<nav className='learning-layout__navigation' aria-label='主导航'> className="learning-layout__search"
aria-label="搜索图书"
placeholder="搜索书名或作者"
value={searchValue}
allowClear
enterButton={<SearchOutlined />}
onChange={(event) => setSearchValue(event.target.value)}
onSearch={searchBooks}
/>
<div className="learning-layout__header-actions">
<nav className="learning-layout__navigation" aria-label="主导航">
{navigationItems.map((item) => {navigationItems.map((item) =>
item.key === 'mobile-download' ? ( item.key === 'mobile-download' ? (
<button <button
key={item.key} key={item.key}
type='button' type="button"
className='learning-layout__nav-item' className="learning-layout__nav-item"
onClick={() => setMobileDownloadOpen(true)} onClick={() => setMobileDownloadOpen(true)}>
>
{item.label} {item.label}
</button> </button>
) : ( ) : (
<Link <Link
key={item.key} key={item.key}
className={`learning-layout__nav-item${getTopNavigationKey(location.pathname) === item.key ? ' learning-layout__nav-item--active' : ''}`} className={`learning-layout__nav-item${getTopNavigationKey(location.pathname) === item.key ? ' learning-layout__nav-item--active' : ''}`}
to={item.key} to={item.key}>
>
{item.label} {item.label}
</Link> </Link>
), ),
)} )}
{session && adminAccessQuery.data?.has_access ? (
<button
type='button'
className='learning-layout__nav-item'
disabled={issueAdminAccess.isPending}
onClick={() => issueAdminAccess.mutate(undefined, { onSuccess: openAdmin })}
>
管理后台
</button>
) : null}
</nav> </nav>
<div className='learning-layout__session' aria-label='用户会话区'> <div className="learning-layout__session" aria-label="用户会话区">
{session ? ( {session ? (
<> <>
<Link <Link
className='learning-layout__nav-item' className="learning-layout__nav-item"
to='/my/messages' to="/my/messages"
aria-label={`消息中心${unreadCount ? `,${unreadCount}条未读` : ''}`} aria-label={`消息中心${unreadCount ? `,${unreadCount}条未读` : ''}`}>
> <Badge count={unreadCount} size="small" offset={[4, -2]}>
<Badge count={unreadCount} size='small' offset={[4, -2]}>
<BellOutlined style={{ fontSize: 18 }} /> <BellOutlined style={{ fontSize: 18 }} />
</Badge> </Badge>
</Link> </Link>
<AccountMenu session={session} onLogout={logoutMember} /> <AccountMenu
session={session}
onLogout={logoutMember}
hasAdminAccess={Boolean(adminAccessQuery.data?.has_access)}
onOpenAdmin={() => issueAdminAccess.mutate(undefined, { onSuccess: openAdmin })}
/>
</> </>
) : ( ) : (
<Button <Button className="learning-layout__login" type="primary" onClick={() => navigate('/login')}>
className='learning-layout__login'
type='primary'
onClick={() => navigate('/login')}
>
登录 登录
</Button> </Button>
)} )}
...@@ -117,28 +120,26 @@ export function LearningLayout() { ...@@ -117,28 +120,26 @@ export function LearningLayout() {
</div> </div>
</div> </div>
</Layout.Header> </Layout.Header>
<Layout.Content className='learning-layout__content'> <Layout.Content className="learning-layout__content">
<Outlet /> <Outlet />
</Layout.Content> </Layout.Content>
<Layout.Footer className='learning-layout__footer'> <Layout.Footer className="learning-layout__footer">
<nav className='learning-layout__footer-links' aria-label='页脚导航'> <nav className="learning-layout__footer-links" aria-label="页脚导航">
<Link to='/about'>关于我们</Link> <Link to="/about">关于我们</Link>
<Link to='/help'>帮助中心</Link> <Link to="/help">帮助中心</Link>
<a <a
href='https://webapp-pub.ezijing.com/book-app/agreement/ser_agreement.html?v=3' href="https://webapp-pub.ezijing.com/book-app/agreement/ser_agreement.html?v=3"
target='_blank' target="_blank"
rel='noreferrer' rel="noreferrer">
>
用户协议 用户协议
</a> </a>
<a <a
href='https://webapp-pub.ezijing.com/book-app/agreement/pri_agreement.html?v=3' href="https://webapp-pub.ezijing.com/book-app/agreement/pri_agreement.html?v=3"
target='_blank' target="_blank"
rel='noreferrer' rel="noreferrer">
>
隐私政策 隐私政策
</a> </a>
<Link to='/contact'>联系我们</Link> <Link to="/contact">联系我们</Link>
</nav> </nav>
</Layout.Footer> </Layout.Footer>
<MobileDownloadModal open={mobileDownloadOpen} onClose={() => setMobileDownloadOpen(false)} /> <MobileDownloadModal open={mobileDownloadOpen} onClose={() => setMobileDownloadOpen(false)} />
......
...@@ -15,28 +15,17 @@ ...@@ -15,28 +15,17 @@
border-color: #edf0f4; border-color: #edf0f4;
} }
.account-menu__summary,
.account-menu__actions .ant-btn { .account-menu__actions .ant-btn {
width: 100%; width: 100%;
color: #273142; color: #273142;
text-align: left; text-align: left;
} }
.account-menu__summary:hover,
.account-menu__actions .ant-btn:hover { .account-menu__actions .ant-btn:hover {
color: #a7193f !important; color: #a7193f !important;
background: #fff3f5 !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 { .account-menu__trigger-copy {
display: flex; display: flex;
min-width: 0; min-width: 0;
...@@ -45,8 +34,6 @@ ...@@ -45,8 +34,6 @@
align-items: flex-start; align-items: flex-start;
} }
.account-menu__summary-name,
.account-menu__summary-level,
.account-menu__trigger-name, .account-menu__trigger-name,
.account-menu__trigger-level { .account-menu__trigger-level {
display: block; display: block;
...@@ -56,27 +43,6 @@ ...@@ -56,27 +43,6 @@
white-space: nowrap; 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 { .account-menu__actions {
display: grid; display: grid;
gap: 2px; gap: 2px;
......
import { import { AppstoreOutlined, DownOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons'
DownOutlined,
LogoutOutlined,
SafetyCertificateOutlined,
UserOutlined,
} from '@ant-design/icons'
import { Avatar, Button, Divider, Popover, Typography } from 'antd' import { Avatar, Button, Divider, Popover, Typography } from 'antd'
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
...@@ -13,13 +8,15 @@ import './AccountMenu.css' ...@@ -13,13 +8,15 @@ import './AccountMenu.css'
interface AccountMenuProps { interface AccountMenuProps {
session: MemberSession session: MemberSession
onLogout: () => Promise<void> onLogout: () => Promise<void>
hasAdminAccess: boolean
onOpenAdmin: () => void
} }
function avatarText(session: MemberSession) { function avatarText(session: MemberSession) {
return (session.name || session.phone).trim().slice(0, 1).toUpperCase() return (session.name || session.phone).trim().slice(0, 1).toUpperCase()
} }
export function AccountMenu({ session, onLogout }: AccountMenuProps) { export function AccountMenu({ session, onLogout, hasAdminAccess, onOpenAdmin }: AccountMenuProps) {
const navigate = useNavigate() const navigate = useNavigate()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const displayName = session.name || session.phone const displayName = session.name || session.phone
...@@ -38,39 +35,29 @@ export function AccountMenu({ session, onLogout }: AccountMenuProps) { ...@@ -38,39 +35,29 @@ export function AccountMenu({ session, onLogout }: AccountMenuProps) {
const content = ( const content = (
<section className='account-menu__panel' aria-label='账户菜单'> <section className='account-menu__panel' aria-label='账户菜单'>
<Button <div className='account-menu__actions'>
type='text' <Button type='text' icon={<UserOutlined />} onClick={() => navigateTo('/my/profile')}>
className='account-menu__summary' 个人设置
onClick={() => navigateTo('/my/profile')}
>
<Avatar className='account-menu__avatar' size={48} src={session.head_img || undefined}>
{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> </Button>
</div>
{hasAdminAccess ? (
<>
<Divider /> <Divider />
<div className='account-menu__actions'> <div className='account-menu__actions'>
<Button type='text' icon={<UserOutlined />} onClick={() => navigateTo('/my/profile')}>
个人资料
</Button>
<Button <Button
type='text' type='text'
icon={<SafetyCertificateOutlined />} icon={<AppstoreOutlined />}
onClick={() => navigateTo('/my/security')} onClick={() => {
setOpen(false)
onOpenAdmin()
}}
> >
账号安全 进入管理后台
</Button> </Button>
</div> </div>
</>
) : null}
<Divider /> <Divider />
......
...@@ -8,6 +8,7 @@ import { RouterProvider } from 'react-router' ...@@ -8,6 +8,7 @@ import { RouterProvider } from 'react-router'
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7' import { NuqsAdapter } from 'nuqs/adapters/react-router/v7'
import { router } from '@/router/routes' import { router } from '@/router/routes'
import { LoadingFallback } from '@/router/LoadingFallback' import { LoadingFallback } from '@/router/LoadingFallback'
import { useSessionStore } from '@/stores/session'
import '@/styles/global.css' import '@/styles/global.css'
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
...@@ -16,9 +17,18 @@ export const queryClient = new QueryClient({ ...@@ -16,9 +17,18 @@ export const queryClient = new QueryClient({
}, },
}) })
useSessionStore.subscribe((state, previous) => {
if (state.session?.token !== previous.session?.token) queryClient.clear()
})
const mainColor = getComputedStyle(document.documentElement).getPropertyValue('--main-color').trim()
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ConfigProvider locale={zhCN} theme={{ token: { colorPrimary: '#aa1941' } }}> <ConfigProvider
locale={zhCN}
theme={{ token: { colorPrimary: mainColor, colorInfo: mainColor, colorLink: mainColor } }}
>
<NuqsAdapter> <NuqsAdapter>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
padding: 32px; padding: 32px;
background: background:
radial-gradient(circle at 18% 18%, rgb(170 25 65 / 10%), transparent 30%), radial-gradient(circle at 18% 18%, rgb(170 25 65 / 10%), transparent 30%),
linear-gradient(135deg, #fbf7f8 0%, #f5f7fa 55%, #eef3f8 100%); linear-gradient(135deg, #fbf7f8 0%, #f5f7fa 55%, #faf2f5 100%);
} }
.login-view__card { .login-view__card {
......
import { Avatar, Empty, List, Rate, Typography } from 'antd' import { Avatar, Empty, Rate, Typography } from 'antd'
import type { BookComment } from '../api' import type { BookComment } from '../api'
export function BookComments({ comments }: { comments: BookComment[] }) { export function BookComments({ comments }: { comments: BookComment[] }) {
if (!comments.length) return <Empty description='暂无书评' /> if (!comments.length) return <Empty description='暂无书评' />
return ( return (
<List <ul className='learning-list learning-list--plain'>
itemLayout='horizontal' {comments.map((comment, index) => (
dataSource={comments} <li className='learning-list__row' key={index}>
renderItem={(comment) => ( <div className='learning-list__meta'>
<List.Item>
<List.Item.Meta
avatar={
<Avatar src={comment.userImage || undefined}>{comment.user.slice(0, 1)}</Avatar> <Avatar src={comment.userImage || undefined}>{comment.user.slice(0, 1)}</Avatar>
} <div className='learning-list__content'>
title={
<>
<Typography.Text>{comment.user || '匿名读者'}</Typography.Text>{' '} <Typography.Text>{comment.user || '匿名读者'}</Typography.Text>{' '}
<Rate disabled allowHalf value={comment.rating} /> <Rate disabled allowHalf value={comment.rating} />
</> <div className='learning-list__description'>
}
description={
<>
<Typography.Paragraph>{comment.comments}</Typography.Paragraph> <Typography.Paragraph>{comment.comments}</Typography.Paragraph>
<Typography.Text type='secondary'>{comment.createTime}</Typography.Text> <Typography.Text type='secondary'>{comment.createTime}</Typography.Text>
</> </div>
} </div>
/> </div>
</List.Item> </li>
)} ))}
/> </ul>
) )
} }
import { Image } from 'antd' import { Image } from 'antd'
import defaultBookCover from '@/assets/default-book-cover.jpg' import defaultBookCover from '@/assets/default-book-cover.jpg'
export function BookCover({ src, alt }: { src: string; alt: string }) { interface BookCoverProps {
url?: string | null
alt?: string
width?: number
height?: number
}
export function BookCover({ url, alt = '书籍封面', width = 52, height = 72 }: BookCoverProps) {
return ( return (
<Image <Image
className='book-cover' className='book-cover'
width={width}
height={height}
preview={false} preview={false}
src={src || defaultBookCover} src={url || defaultBookCover}
alt={alt} alt={alt}
fallback={defaultBookCover} fallback={defaultBookCover}
/> />
......
import { Space, Tag, Typography } from 'antd'
interface BookPriceProps {
isFree: boolean
price: string
vipPrice: string
priceClassName?: string
freeTagClassName?: string
}
function hasOriginalPrice(price: string) {
const amount = Number(price)
return Number.isFinite(amount) && amount > 0
}
export function BookPrice({
isFree,
price,
vipPrice,
priceClassName,
freeTagClassName,
}: BookPriceProps) {
const originalPrice = hasOriginalPrice(price) ? (
<Typography.Text delete type='secondary'>
¥{price}
</Typography.Text>
) : null
if (isFree) {
return (
<Space size={8}>
<Tag className={freeTagClassName}>免费</Tag>
{originalPrice}
</Space>
)
}
const currentPrice = vipPrice || price
if (!currentPrice) return null
return (
<Space size={8}>
<Typography.Text className={priceClassName}>¥{currentPrice}</Typography.Text>
{vipPrice && originalPrice && vipPrice !== price ? originalPrice : null}
</Space>
)
}
import { queryOptions } from '@tanstack/react-query' import { queryOptions } from '@tanstack/react-query'
import { getBookChapters, getBookComments, getBookDetail } from './api' import { getBookChapters, getBookComments, getBookDetail, getBookStudyReport } from './api'
export const bookKeys = { export const bookKeys = {
all: ['book'] as const, all: ['book'] as const,
detail: (bookId: string) => ['book', bookId, 'detail'] as const, detail: (bookId: string) => ['book', bookId, 'detail'] as const,
chapters: (bookId: string) => ['book', bookId, 'chapters'] as const, chapters: (bookId: string) => ['book', bookId, 'chapters'] as const,
comments: (bookId: string) => ['book', bookId, 'comments'] as const, comments: (bookId: string) => ['book', bookId, 'comments'] as const,
studyReport: (bookId: string) => ['book', bookId, 'study-report'] as const,
} }
export function bookDetailOptions(bookId: string) { export function bookDetailOptions(bookId: string) {
...@@ -31,3 +32,11 @@ export function bookCommentsOptions(bookId: string) { ...@@ -31,3 +32,11 @@ export function bookCommentsOptions(bookId: string) {
enabled: Boolean(bookId), enabled: Boolean(bookId),
}) })
} }
export function bookStudyReportOptions(bookId: string) {
return queryOptions({
queryKey: bookKeys.studyReport(bookId),
queryFn: () => getBookStudyReport(bookId),
enabled: Boolean(bookId),
})
}
...@@ -2,6 +2,10 @@ import type { RouteObject } from 'react-router' ...@@ -2,6 +2,10 @@ import type { RouteObject } from 'react-router'
import { lazyRoute } from '@/router/lazy-route' import { lazyRoute } from '@/router/lazy-route'
export const routes: RouteObject[] = [ export const routes: RouteObject[] = [
{
path: 'book/:bookId/report',
lazy: lazyRoute(() => import('./views/StudyReportView'), 'StudyReportView'),
},
{ {
path: 'book/:bookId', path: 'book/:bookId',
lazy: lazyRoute(() => import('./views/BookDetailView'), 'BookDetailView'), lazy: lazyRoute(() => import('./views/BookDetailView'), 'BookDetailView'),
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
} }
.book-detail__hero .ant-card-body { .book-detail__hero .ant-card-body {
padding: 28px; padding: 32px;
} }
.book-detail .ant-card { .book-detail .ant-card {
...@@ -18,17 +18,18 @@ ...@@ -18,17 +18,18 @@
} }
.book-detail__hero h1 { .book-detail__hero h1 {
margin: 0 0 20px; margin: 8px 0 10px;
font-size: 18px; color: #20242b;
font-weight: 500; font-size: clamp(24px, 3vw, 34px);
color: rgba(0, 0, 0, 0.45); font-weight: 650;
line-height: 1.35;
} }
.book-detail__hero h2 { .book-detail__hero .ant-typography-paragraph {
margin: 0 0 8px; margin: 0 0 12px;
} }
.book-cover, .book-detail .book-cover,
.book-cover img { .book-detail .book-cover img {
width: 180px !important; width: 180px !important;
height: 240px !important; height: 240px !important;
object-fit: cover; object-fit: cover;
...@@ -38,13 +39,64 @@ ...@@ -38,13 +39,64 @@
} }
.book-detail__summary { .book-detail__summary {
min-width: min(580px, 100%); min-width: min(520px, 100%);
flex: 1; flex: 1;
} }
.book-detail__price { .book-detail__price {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: #c02d55; color: var(--main-color);
}
.book-detail__metadata {
display: block;
margin-top: 8px;
font-size: 13px;
}
.book-detail__access {
display: flex;
min-height: 34px;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.book-detail__access-tag {
margin: 0;
color: var(--main-color);
background: color-mix(in srgb, var(--main-color) 8%, white);
border-color: color-mix(in srgb, var(--main-color) 24%, white);
}
.book-detail__hero-footer {
display: flex;
align-items: end;
justify-content: space-between;
flex-wrap: wrap;
gap: 20px;
padding-top: 20px;
border-top: 1px solid var(--learning-border);
}
.book-detail__hero-footer .ant-statistic-title {
margin-bottom: 2px;
font-size: 12px;
}
.book-detail__hero-footer .ant-statistic-content {
font-size: 18px;
}
.book-detail__review-summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 18px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--learning-border);
} }
.book-detail__html { .book-detail__html {
width: 100%; width: 100%;
...@@ -55,24 +107,33 @@ ...@@ -55,24 +107,33 @@
white-space: pre-wrap; white-space: pre-wrap;
line-height: 2; 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) { @media (max-width: 640px) {
.book-detail { .book-detail {
padding: 0; padding: 0;
} }
.book-cover, .book-detail .book-cover,
.book-cover img { .book-detail .book-cover img {
width: 132px !important; width: 132px !important;
height: 176px !important; height: 176px !important;
} }
.book-detail__hero .ant-card-body { .book-detail__hero .ant-card-body {
padding: 20px; padding: 20px;
} }
.book-detail__summary {
min-width: 100%;
}
.book-detail__hero-footer {
align-items: stretch;
flex-direction: column;
}
.book-detail__hero-footer .ant-space {
width: 100%;
}
.book-detail__hero-footer .ant-space .ant-btn-primary {
flex: 1;
}
} }
.study-report {
display: grid;
width: 100%;
min-width: 0;
gap: 20px;
}
.study-report__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.study-report__back.ant-btn {
height: auto;
padding: 4px 0;
color: var(--main-color);
}
.study-report__header h1.ant-typography {
margin: 0;
font-size: 26px;
}
.study-report__eyebrow {
color: var(--main-color);
font-size: 12px;
font-weight: 600;
}
.study-report__book {
border-color: var(--learning-border);
border-radius: 14px;
background: linear-gradient(110deg, color-mix(in srgb, var(--main-color) 7%, white), white 68%);
}
.study-report__book .ant-card-body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 22px;
}
.study-report__book .book-cover,
.study-report__book .book-cover img {
flex: 0 0 auto;
object-fit: cover;
border-radius: 5px;
box-shadow: 0 8px 18px rgb(31 41 55 / 13%);
}
.study-report__book-copy {
min-width: 0;
flex: 1;
}
.study-report__continue.ant-btn {
flex: 0 0 auto;
}
.study-report__book-copy h2.ant-typography {
margin: 4px 0 8px;
font-size: 22px;
}
.study-report__stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.study-report__stats .ant-card {
border-color: var(--learning-border);
border-radius: 12px;
}
.study-report__stats .ant-card-body {
padding: 18px;
}
.study-report__stats .ant-statistic-title {
margin-bottom: 10px;
}
.study-report__stats .ant-statistic-content {
color: #252a32;
font-size: 22px;
}
.study-report__activity {
border-color: var(--learning-border);
border-radius: 12px;
}
.study-report__activity .ant-card-body {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 28px;
}
.study-report__activity h3.ant-typography {
margin: 4px 0;
}
.study-report__unit {
font-size: 14px;
font-weight: 400;
}
.study-report__last-chapter .ant-typography-paragraph {
margin: 6px 0 0;
color: #252a32;
}
@media (max-width: 700px) {
.study-report__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.study-report__activity .ant-card-body {
grid-template-columns: 1fr;
gap: 18px;
}
}
@media (max-width: 480px) {
.study-report__header {
flex-wrap: wrap;
}
.study-report__book .ant-card-body {
align-items: start;
flex-wrap: wrap;
gap: 14px;
padding: 16px;
}
.study-report__book .book-cover,
.study-report__book .book-cover img {
width: 72px !important;
height: 96px !important;
}
.study-report__book-copy h2.ant-typography {
font-size: 18px;
}
.study-report__continue.ant-btn {
width: 100%;
}
}
import { useQuery } from '@tanstack/react-query'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { Alert, Button, Card, Skeleton, Statistic, Typography } from 'antd'
import { useMemo } from 'react'
import { useNavigate, useParams } from 'react-router'
import { findFirstChapterId } from '../chapter-navigation'
import { BookCover } from '../components/BookCover'
import { bookChaptersOptions, bookDetailOptions, bookStudyReportOptions } from '../query'
import './StudyReportView.css'
function formatReadTime(value: string) {
const seconds = Number(value)
if (!value || !Number.isFinite(seconds)) return value || '—'
if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return minutes ? `${hours}小时${minutes}分钟` : `${hours}小时`
}
if (seconds >= 60) return `${Math.floor(seconds / 60)}分钟`
return `${seconds}秒`
}
export function StudyReportView() {
const { bookId = '' } = useParams()
const navigate = useNavigate()
const bookQuery = useQuery(bookDetailOptions(bookId))
const chaptersQuery = useQuery(bookChaptersOptions(bookId))
const reportQuery = useQuery(bookStudyReportOptions(bookId))
const book = bookQuery.data
const report = reportQuery.data
const firstChapterId = useMemo(
() => findFirstChapterId(chaptersQuery.data ?? []),
[chaptersQuery.data],
)
const resumeChapterId = book?.chapterId || firstChapterId
return (
<main className='study-report'>
<header className='study-report__header'>
<div>
<Typography.Title level={1}>学习报告</Typography.Title>
</div>
<Button
className='study-report__back'
type='text'
icon={<ArrowLeftOutlined />}
onClick={() => navigate(`/book/${bookId}`)}
>
返回图书详情
</Button>
</header>
{reportQuery.isPending ? <Skeleton active paragraph={{ rows: 7 }} /> : null}
{reportQuery.isError ? (
<Alert
type='error'
showIcon
message='学习报告暂时无法加载'
description={reportQuery.error.message || '请稍后重试。'}
action={<Button onClick={() => void reportQuery.refetch()}>重试</Button>}
/>
) : null}
{report ? (
<>
<Card className='study-report__book'>
<BookCover
url={book?.img}
alt={book ? `${book.bookName}封面` : '书籍封面'}
width={92}
height={122}
/>
<div className='study-report__book-copy'>
<Typography.Text className='study-report__eyebrow'>当前教材</Typography.Text>
<Typography.Title level={2}>{book?.bookName || '教材学习情况'}</Typography.Title>
<Typography.Text type='secondary'>
{book?.authors || '持续阅读,积累学习成果'}
</Typography.Text>
</div>
<Button
className='study-report__continue'
type='primary'
disabled={!resumeChapterId}
onClick={() => {
if (resumeChapterId) navigate(`/reader/${bookId}/${resumeChapterId}`)
}}
>
继续阅读
</Button>
</Card>
<section className='study-report__stats' aria-label='学习数据'>
<Card>
<Statistic title='学习进度' value={report.progress || '—'} />
</Card>
<Card>
<Statistic title='阅读时长' value={formatReadTime(report.readSecond)} />
</Card>
<Card>
<Statistic title='笔记' value={report.noteNums} suffix='篇' />
</Card>
<Card>
<Statistic title='划线' value={report.lineNums} suffix='处' />
</Card>
</section>
<Card title='学习活动' className='study-report__activity'>
<div>
<Typography.Text type='secondary'>参与讨论</Typography.Text>
<Typography.Title level={3}>
{report.commentPostNums + report.commentReplyNums}
<Typography.Text className='study-report__unit'> 条</Typography.Text>
</Typography.Title>
<Typography.Text type='secondary'>
主题 {report.commentPostNums} · 回复 {report.commentReplyNums}
</Typography.Text>
</div>
<div className='study-report__last-chapter'>
<Typography.Text type='secondary'>最近阅读</Typography.Text>
<Typography.Paragraph ellipsis={{ rows: 2 }}>
{report.lastChapter || '还没有阅读章节'}
</Typography.Paragraph>
</div>
</Card>
</>
) : null}
</main>
)
}
import { HeartFilled, HeartOutlined, LoadingOutlined } from '@ant-design/icons' import { Card, Typography } from 'antd'
import { Button, Card, Image, Tag, Tooltip, Typography } from 'antd'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import defaultBookCover from '@/assets/default-book-cover.jpg' import { BookCover } from '@/modules/book/components/BookCover'
import { BookPrice } from '@/modules/book/components/BookPrice'
import type { LibraryBook } from '../api' import type { LibraryBook } from '../api'
interface LibraryBookCardProps { interface LibraryBookCardProps {
book: LibraryBook book: LibraryBook
view: 'grid' | 'list'
isUpdatingCollection: boolean
onCollectionChange: (book: LibraryBook) => void
} }
function bookPrice(book: LibraryBook) { export function LibraryBookCard({ book }: LibraryBookCardProps) {
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 navigate = useNavigate() const navigate = useNavigate()
const favourite = book.is_collection === 1
const openDetail = () => navigate(`/book/${book.book_id}`) const openDetail = () => navigate(`/book/${book.book_id}`)
return ( return (
<Card <Card
className={`library-book-card library-book-card--${view}`} className='library-book-card library-book-card--grid'
hoverable hoverable
role='link' role='link'
tabIndex={0} tabIndex={0}
...@@ -37,58 +23,23 @@ export function LibraryBookCard({ ...@@ -37,58 +23,23 @@ export function LibraryBookCard({
if (event.key === 'Enter' && event.currentTarget === event.target) openDetail() if (event.key === 'Enter' && event.currentTarget === event.target) openDetail()
}} }}
> >
<div <div className='library-book-card__cover' aria-label={`查看《${book.book_name}》详情`}>
className='library-book-card__cover' <BookCover url={book.img} alt={`${book.book_name}封面`} width={240} height={324} />
aria-label={`查看《${book.book_name}》详情`}
>
<Image
preview={false}
src={book.img || defaultBookCover}
alt={`${book.book_name}封面`}
fallback={defaultBookCover}
/>
</div> </div>
<div className='library-book-card__content'> <div className='library-book-card__content'>
<div> <div className='library-book-card__heading'>
<Typography.Title level={4} ellipsis={{ rows: 2 }}> <Typography.Title level={4} ellipsis={{ rows: 2 }}>
{book.book_name} {book.book_name}
</Typography.Title> </Typography.Title>
<Typography.Text type='secondary'>{book.authors || '作者未知'}</Typography.Text> <Typography.Text type='secondary'>{book.authors || '作者未知'}</Typography.Text>
</div> </div>
{view === 'list' && book.introduction ? ( <BookPrice
<Typography.Paragraph isFree={book.is_free === 1}
className='library-book-card__intro' price={book.price}
ellipsis={{ rows: 2 }} vipPrice={book.vip_price}
type='secondary' priceClassName='library-book-card__price'
> freeTagClassName='library-book-card__free-tag'
{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}
disabled={isUpdatingCollection}
icon={
isUpdatingCollection ? (
<LoadingOutlined spin />
) : favourite ? (
<HeartFilled />
) : (
<HeartOutlined />
)
}
onClick={(event) => {
event.stopPropagation()
onCollectionChange(book)
}}
/> />
</Tooltip>
</div>
</div> </div>
</Card> </Card>
) )
......
import { Button, Form, Input, Segmented, Select, Space } from 'antd' import { Button, Form, Select, Space } from 'antd'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { LibraryFilter } from '../api' import type { LibraryFilter } from '../api'
import type { LibrarySearchState } from '../query-state' import type { LibrarySearchState } from '../query-state'
...@@ -7,12 +7,10 @@ interface LibraryToolbarProps { ...@@ -7,12 +7,10 @@ interface LibraryToolbarProps {
state: LibrarySearchState state: LibrarySearchState
categories: LibraryFilter[] categories: LibraryFilter[]
labels: LibraryFilter[] labels: LibraryFilter[]
onKeywordSearch: (keyword: string) => void
onFiltersChange: ( onFiltersChange: (
filters: Partial<Pick<LibrarySearchState, 'category' | 'label' | 'price'>>, filters: Partial<Pick<LibrarySearchState, 'category' | 'label' | 'price'>>,
) => void ) => void
onSortChange: (sort: Partial<Pick<LibrarySearchState, 'sortField' | 'sortOrder'>>) => void onSortChange: (sort: Partial<Pick<LibrarySearchState, 'sortField' | 'sortOrder'>>) => void
onViewChange: (view: LibrarySearchState['view']) => void
onReset: () => void onReset: () => void
} }
...@@ -20,15 +18,11 @@ export function LibraryToolbar({ ...@@ -20,15 +18,11 @@ export function LibraryToolbar({
state, state,
categories, categories,
labels, labels,
onKeywordSearch,
onFiltersChange, onFiltersChange,
onSortChange, onSortChange,
onViewChange,
onReset, onReset,
}: LibraryToolbarProps) { }: LibraryToolbarProps) {
const [keyword, setKeyword] = useState(state.keyword)
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false) const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
const isSearching = Boolean(state.keyword)
const hasAdvancedFilters = const hasAdvancedFilters =
state.category !== 0 || state.category !== 0 ||
state.label !== 0 || state.label !== 0 ||
...@@ -43,54 +37,18 @@ export function LibraryToolbar({ ...@@ -43,54 +37,18 @@ export function LibraryToolbar({
})) }))
const labelOptions = withAllOption(labels).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])
useEffect(() => { useEffect(() => {
if (hasAdvancedFilters) setShowAdvancedFilters(true) if (hasAdvancedFilters) setShowAdvancedFilters(true)
}, [hasAdvancedFilters]) }, [hasAdvancedFilters])
return ( return (
<Form component={false} aria-label='图书馆筛选'> <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>
</Space>
<Space className='library-toolbar__secondary' size='small' aria-label='显示与筛选'>
{/* 暂时隐藏筛选入口,筛选逻辑保留 */}
<Segmented
aria-label='图书馆视图'
value={state.view}
options={[
{ value: 'grid', label: '网格' },
{ value: 'list', label: '列表' },
]}
onChange={(value) => onViewChange(value as LibrarySearchState['view'])}
/>
</Space>
</div>
{showAdvancedFilters ? ( {showAdvancedFilters ? (
<div className='library-toolbar__advanced'> <div className='library-toolbar__advanced'>
<Space wrap size='middle'> <Space wrap size='middle'>
<Form.Item label='分类'> <Form.Item label='分类'>
<Select <Select
aria-label='分类' aria-label='分类'
disabled={isSearching}
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
value={state.category} value={state.category}
options={categoryOptions} options={categoryOptions}
...@@ -100,7 +58,6 @@ export function LibraryToolbar({ ...@@ -100,7 +58,6 @@ export function LibraryToolbar({
<Form.Item label='标签'> <Form.Item label='标签'>
<Select <Select
aria-label='标签' aria-label='标签'
disabled={isSearching}
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
value={state.label} value={state.label}
options={labelOptions} options={labelOptions}
...@@ -110,7 +67,6 @@ export function LibraryToolbar({ ...@@ -110,7 +67,6 @@ export function LibraryToolbar({
<Form.Item label='价格'> <Form.Item label='价格'>
<Select <Select
aria-label='价格' aria-label='价格'
disabled={isSearching}
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
value={state.price} value={state.price}
options={[ options={[
...@@ -125,7 +81,6 @@ export function LibraryToolbar({ ...@@ -125,7 +81,6 @@ export function LibraryToolbar({
<Space.Compact> <Space.Compact>
<Select <Select
aria-label='排序' aria-label='排序'
disabled={isSearching}
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
value={state.sortField} value={state.sortField}
options={[ options={[
...@@ -137,7 +92,6 @@ export function LibraryToolbar({ ...@@ -137,7 +92,6 @@ export function LibraryToolbar({
/> />
<Select <Select
aria-label='排序方向' aria-label='排序方向'
disabled={isSearching}
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
value={state.sortOrder} value={state.sortOrder}
options={[ options={[
......
...@@ -2,6 +2,10 @@ import type { RouteObject } from 'react-router' ...@@ -2,6 +2,10 @@ import type { RouteObject } from 'react-router'
import { lazyRoute } from '@/router/lazy-route' import { lazyRoute } from '@/router/lazy-route'
export const routes: RouteObject[] = [ export const routes: RouteObject[] = [
{
path: 'search',
lazy: lazyRoute(() => import('./views/SearchView'), 'SearchView'),
},
{ {
path: 'library', path: 'library',
lazy: lazyRoute(() => import('./views/LibraryView'), 'LibraryView'), lazy: lazyRoute(() => import('./views/LibraryView'), 'LibraryView'),
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 0; padding: 0;
gap: 18px; gap: 36px;
} }
.library-platform-banner { .library-platform-banner {
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
display: flex; display: flex;
min-height: 300px; min-height: 300px;
border-radius: 16px; border-radius: 16px;
background: #edf3ff; background: color-mix(in srgb, var(--main-color) 7%, white);
} }
.library-platform-banner__image { .library-platform-banner__image {
...@@ -77,27 +77,6 @@ ...@@ -77,27 +77,6 @@
line-height: 1.7; 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__advanced { .library-toolbar__advanced {
padding: 14px 16px; padding: 14px 16px;
border: 1px solid #edf0f4; border: 1px solid #edf0f4;
...@@ -109,14 +88,8 @@ ...@@ -109,14 +88,8 @@
margin-bottom: 0; margin-bottom: 0;
} }
.library-toolbar__controls .ant-form-item { .library-search-view__heading h1.ant-typography {
margin-bottom: 0; margin: 0 0 4px;
}
.library-toolbar__controls .ant-input-search {
width: 260px;
}
.library-toolbar__controls .ant-space-item {
flex: 0 0 auto;
} }
.library-books { .library-books {
...@@ -125,11 +98,8 @@ ...@@ -125,11 +98,8 @@
} }
.library-books--grid { .library-books--grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, 168px);
} gap: 48px;
.library-books--list {
grid-template-columns: 1fr;
} }
.library-book-card .ant-card-body { .library-book-card .ant-card-body {
...@@ -159,7 +129,25 @@ ...@@ -159,7 +129,25 @@
.library-book-card--grid .ant-card-body { .library-book-card--grid .ant-card-body {
display: flex; display: flex;
padding: 12px; flex-direction: column;
padding: 0;
gap: 10px;
}
.library-book-card--grid.ant-card {
border: 0;
background: transparent;
box-shadow: none;
}
.library-book-card--grid.ant-card:hover,
.library-book-card--grid.ant-card:focus-within {
transform: none;
box-shadow: none;
}
.library-book-card--grid.ant-card:active {
transform: none;
} }
.library-book-card__cover { .library-book-card__cover {
...@@ -172,16 +160,18 @@ ...@@ -172,16 +160,18 @@
} }
.library-book-card--grid .library-book-card__cover { .library-book-card--grid .library-book-card__cover {
flex: 0 0 92px; flex: none;
width: 92px; width: 100%;
height: 124px; height: auto;
margin-bottom: 0; aspect-ratio: 20 / 27;
border-radius: 0;
box-shadow: 0 4px 12px rgb(31 41 55 / 14%);
} }
.library-book-card__cover .ant-image, .library-book-card__cover .ant-image,
.library-book-card__cover img { .library-book-card__cover img {
width: 100%; width: 100% !important;
height: 100%; height: 100% !important;
object-fit: cover; object-fit: cover;
} }
...@@ -191,44 +181,46 @@ ...@@ -191,44 +181,46 @@
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 6px;
} }
.library-book-card__content h4 { .library-book-card__heading {
margin: 0 0 2px; display: grid;
font-size: 15px; gap: 4px;
line-height: 21px;
} }
.library-book-card__intro { .library-book-card__content h4 {
margin: 0; margin: 0;
color: #253247;
font-size: 16px;
line-height: 1.4;
} }
.library-book-card__meta { .library-book-card__heading .ant-typography-secondary {
display: flex; overflow: hidden;
align-items: center; color: #929cab;
gap: 6px; font-size: 13px;
font-size: 12px; text-overflow: ellipsis;
white-space: nowrap;
} }
.library-book-card__meta .ant-tag { .library-book-card__content > .ant-space {
margin-inline-end: 0; display: flex;
min-width: 0;
white-space: nowrap;
} }
.library-book-card__meta .ant-btn { .library-book-card__price {
margin-left: auto; color: var(--main-color) !important;
display: grid; font-size: 17px;
width: 32px; font-weight: 500;
height: 32px;
padding: 0;
place-items: center;
border-radius: 50%;
font-size: 20px;
line-height: 1;
} }
.library-book-card__meta .ant-btn:hover { .library-book-card__free-tag {
background: #fff1f4 !important; color: var(--main-color);
font-size: 13px;
background: color-mix(in srgb, var(--main-color) 8%, white);
border-color: color-mix(in srgb, var(--main-color) 24%, white);
} }
.library-view__pagination { .library-view__pagination {
...@@ -256,28 +248,26 @@ ...@@ -256,28 +248,26 @@
rgb(237 243 255 / 10%) 100% rgb(237 243 255 / 10%) 100%
); );
} }
.library-toolbar__controls {
flex-wrap: wrap;
}
.library-toolbar__secondary {
padding: 0;
border: 0;
background: transparent;
}
.library-toolbar__advanced { .library-toolbar__advanced {
padding: 0; padding: 0;
border: 0; border: 0;
background: transparent; background: transparent;
} }
.library-books--grid { .library-books--grid {
grid-template-columns: 1fr; grid-template-columns: repeat(2, minmax(0, 168px));
gap: 36px 32px;
}
.library-book-card--grid .library-book-card__content h4 {
font-size: 15px;
} }
.library-book-card--list .ant-card-body { .library-book-card--grid .library-book-card__price {
gap: 12px; font-size: 16px;
} }
.library-book-card__cover { }
flex-basis: 88px;
width: 88px; @media (max-width: 420px) {
.library-books--grid {
grid-template-columns: 168px;
} }
} }
......
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Empty, Pagination, Skeleton, Space } from 'antd' import { Empty, Pagination, Skeleton, Space } from 'antd'
import { useState } from 'react'
import { setBookCollection, type LibraryBook } from '../api'
import { LibraryBanner } from '../components/LibraryBanner' import { LibraryBanner } from '../components/LibraryBanner'
import { LibraryBookCard } from '../components/LibraryBookCard' import { LibraryBookCard } from '../components/LibraryBookCard'
import { LibraryToolbar } from '../components/LibraryToolbar' import { LibraryToolbar } from '../components/LibraryToolbar'
...@@ -17,65 +15,29 @@ import './LibraryView.css' ...@@ -17,65 +15,29 @@ import './LibraryView.css'
export function LibraryView() { export function LibraryView() {
const queryState = useLibraryQueryState() const queryState = useLibraryQueryState()
const { state } = queryState const { state } = queryState
const queryClient = useQueryClient()
const [changingBookId, setChangingBookId] = useState<string>()
const categoriesQuery = useQuery(libraryCategoriesOptions()) const categoriesQuery = useQuery(libraryCategoriesOptions())
const labelsQuery = useQuery(libraryLabelsOptions()) const labelsQuery = useQuery(libraryLabelsOptions())
const booksQuery = useQuery(libraryListOptions(state)) 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),
})
return ( return (
<main className='library-view'> <main className='library-view'>
<LibraryBanner /> <LibraryBanner />
<div className='library-toolbar'>
<LibraryToolbar <LibraryToolbar
state={state} state={state}
categories={categoriesQuery.data ?? []} categories={categoriesQuery.data ?? []}
labels={labelsQuery.data ?? []} labels={labelsQuery.data ?? []}
onKeywordSearch={(keyword) => void queryState.setKeyword(keyword)}
onFiltersChange={(filters) => void queryState.setFilters(filters)} onFiltersChange={(filters) => void queryState.setFilters(filters)}
onSortChange={(sort) => void queryState.setSort(sort)} onSortChange={(sort) => void queryState.setSort(sort)}
onViewChange={(view) => void queryState.setView(view)}
onReset={() => void queryState.reset()} onReset={() => void queryState.reset()}
/> />
</div>
{booksQuery.isLoading ? ( {booksQuery.isLoading ? (
<Skeleton active paragraph={{ rows: 8 }} /> <Skeleton active paragraph={{ rows: 8 }} />
) : booksQuery.data?.list.length ? ( ) : booksQuery.data?.list.length ? (
<div className={`library-books library-books--${state.view}`}> <div className='library-books library-books--grid'>
{booksQuery.data.list.map((book) => ( {booksQuery.data.list.map((book) => (
<LibraryBookCard <LibraryBookCard key={book.book_id} book={book} />
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> </div>
) : ( ) : (
......
import { useQuery } from '@tanstack/react-query'
import { Alert, Empty, Pagination, Skeleton, Space, Typography } from 'antd'
import { LibraryBookCard } from '../components/LibraryBookCard'
import { libraryListOptions } from '../query'
import { useLibraryQueryState } from '../query-state'
import './LibraryView.css'
export function SearchView() {
const queryState = useLibraryQueryState()
const { state } = queryState
const keyword = state.keyword.trim()
const booksQuery = useQuery({
...libraryListOptions(state),
enabled: Boolean(keyword),
})
return (
<main className='library-view library-search-view'>
<div className='library-search-view__heading'>
<Typography.Title level={1}>搜索结果</Typography.Title>
{keyword ? (
<Typography.Text type='secondary'>“{keyword}” 的搜索结果</Typography.Text>
) : (
<Typography.Text type='secondary'>请在顶部输入书名或作者进行搜索。</Typography.Text>
)}
</div>
{booksQuery.isPending && keyword ? <Skeleton active paragraph={{ rows: 8 }} /> : null}
{booksQuery.isError ? (
<Alert type='error' showIcon message='搜索失败' description={booksQuery.error.message} />
) : null}
{!keyword ? <Empty description='输入关键词开始搜索' /> : null}
{booksQuery.data && booksQuery.data.list.length === 0 ? (
<Empty description='没有找到符合条件的图书' />
) : null}
{booksQuery.data?.list.length ? (
<>
<div className='library-books library-books--grid'>
{booksQuery.data.list.map((book) => (
<LibraryBookCard key={book.book_id} book={book} />
))}
</div>
<Space className='library-view__pagination'>
<Pagination
current={state.page}
pageSize={state.pageSize}
total={booksQuery.data.total}
hideOnSinglePage
showSizeChanger={false}
showTotal={(total) => `共 ${total} 本`}
onChange={(page, pageSize) => void queryState.setPagination(page, pageSize)}
/>
</Space>
</>
) : null}
</main>
)
}
import { Card, Empty, Image, Pagination, Skeleton, Tag, Typography } from 'antd' import { Card, Empty, Pagination, Skeleton, Tag, Typography } from 'antd'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router' import { Link } from 'react-router'
import defaultBookCover from '@/assets/default-book-cover.jpg' import { BookCover } from '@/modules/book/components/BookCover'
import { myCoursesOptions } from '../../query' import { myCoursesOptions } from '../../query'
import { useMyListQueryState } from '../../query-state' import { useMyListQueryState } from '../../query-state'
import './MyBooksView.css' import './MyBooksView.css'
...@@ -45,11 +45,11 @@ export function MyBooksView() { ...@@ -45,11 +45,11 @@ export function MyBooksView() {
to={`/book/${book.bookId}`} to={`/book/${book.bookId}`}
aria-label={`查看《${book.bookName}》详情`} aria-label={`查看《${book.bookName}》详情`}
> >
<Image <BookCover
url={book.img}
alt={`${book.bookName}封面`} alt={`${book.bookName}封面`}
fallback={defaultBookCover} width={120}
preview={false} height={160}
src={book.img || defaultBookCover}
/> />
</Link> </Link>
<div className='my-books__card-content'> <div className='my-books__card-content'>
...@@ -57,7 +57,9 @@ export function MyBooksView() { ...@@ -57,7 +57,9 @@ export function MyBooksView() {
title={book.bookName} title={book.bookName}
description={<div className='my-books__meta'>{book.authors || '—'}</div>} description={<div className='my-books__meta'>{book.authors || '—'}</div>}
/> />
{book.progress ? <Tag color='blue'>已学习 {book.progress}</Tag> : null} {book.progress ? (
<Tag className='learning-theme-tag'>已学习 {book.progress}</Tag>
) : null}
{book.lastChapter ? ( {book.lastChapter ? (
<Typography.Paragraph ellipsis={{ rows: 1 }}> <Typography.Paragraph ellipsis={{ rows: 1 }}>
最近:{book.lastChapter} 最近:{book.lastChapter}
......
...@@ -3,7 +3,6 @@ import { ...@@ -3,7 +3,6 @@ import {
Card, Card,
Checkbox, Checkbox,
Empty, Empty,
Image,
Pagination, Pagination,
Popconfirm, Popconfirm,
Skeleton, Skeleton,
...@@ -14,7 +13,7 @@ import { ...@@ -14,7 +13,7 @@ import {
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router' import { Link } from 'react-router'
import defaultBookCover from '@/assets/default-book-cover.jpg' import { BookCover } from '@/modules/book/components/BookCover'
import { deleteBookshelfItem } from '../../api' import { deleteBookshelfItem } from '../../api'
import { bookshelfOptions, myKeys } from '../../query' import { bookshelfOptions, myKeys } from '../../query'
import { useMyListQueryState } from '../../query-state' import { useMyListQueryState } from '../../query-state'
...@@ -139,11 +138,11 @@ export function MyBookshelfView() { ...@@ -139,11 +138,11 @@ export function MyBookshelfView() {
to={`/book/${book.bookId}`} to={`/book/${book.bookId}`}
aria-label={`查看《${book.bookName}》详情`} aria-label={`查看《${book.bookName}》详情`}
> >
<Image <BookCover
url={book.img}
alt={`${book.bookName}封面`} alt={`${book.bookName}封面`}
fallback={defaultBookCover} width={120}
preview={false} height={160}
src={book.img || defaultBookCover}
/> />
</Link> </Link>
<div className='my-bookshelf__card-content'> <div className='my-bookshelf__card-content'>
......
...@@ -8,14 +8,17 @@ export function CouponList({ items, status }: { items: CouponItem[]; status: num ...@@ -8,14 +8,17 @@ export function CouponList({ items, status }: { items: CouponItem[]; status: num
<Card <Card
key={coupon.coupon_rec_id} key={coupon.coupon_rec_id}
size='small' size='small'
style={{ borderLeft: `4px solid ${status === 0 ? '#1677ff' : '#bfbfbf'}` }} style={{ borderLeft: `4px solid ${status === 0 ? 'var(--main-color)' : '#bfbfbf'}` }}
> >
<Space orientation='vertical' size={4} style={{ width: '100%' }}> <Space orientation='vertical' size={4} style={{ width: '100%' }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}> <Space style={{ justifyContent: 'space-between', width: '100%' }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
{coupon.coupon_name || '优惠券'} {coupon.coupon_name || '优惠券'}
</Typography.Title> </Typography.Title>
<Tag color={status === 0 ? 'blue' : 'default'}> <Tag
color={status === 0 ? undefined : 'default'}
className={status === 0 ? 'learning-theme-tag' : undefined}
>
{status === 0 ? '可使用' : '已使用/已失效'} {status === 0 ? '可使用' : '已使用/已失效'}
</Tag> </Tag>
</Space> </Space>
...@@ -25,9 +28,7 @@ export function CouponList({ items, status }: { items: CouponItem[]; status: num ...@@ -25,9 +28,7 @@ export function CouponList({ items, status }: { items: CouponItem[]; status: num
</Typography.Text> </Typography.Text>
 满 {coupon.norm_price || '0'} 元可用  满 {coupon.norm_price || '0'} 元可用
</Typography.Text> </Typography.Text>
<Typography.Text type='secondary'> <Typography.Text type='secondary'>有效期至:{coupon.end_time || '—'}</Typography.Text>
有效期至:{coupon.end_time || '—'}
</Typography.Text>
</Space> </Space>
</Card> </Card>
))} ))}
......
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Empty, Pagination, Segmented, Skeleton, Space, Typography } from 'antd' import { Empty, Pagination, Skeleton, Space, Tabs, Typography } from 'antd'
import type { CouponStatus } from '../api' import type { CouponStatus } from '../api'
import { CouponList } from '../components/CouponList' import { CouponList } from '../components/CouponList'
import { couponsOptions } from '../query' import { couponsOptions } from '../query'
...@@ -23,14 +23,13 @@ export function MyCouponsView() { ...@@ -23,14 +23,13 @@ export function MyCouponsView() {
按优惠券状态查看可使用与已使用/已失效的记录。 按优惠券状态查看可使用与已使用/已失效的记录。
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
<Segmented<CouponStatus> <Tabs
block activeKey={String(queryState.state.status)}
value={queryState.state.status} items={[
options={[ { key: '0', label: '可使用' },
{ label: '可使用', value: 0 }, { key: '1', label: '已使用/已失效' },
{ label: '已使用/已失效', value: 1 },
]} ]}
onChange={(status) => void queryState.setStatus(status)} onChange={(status) => void queryState.setStatus(Number(status) as CouponStatus)}
/> />
{couponsQuery.isPending ? <Skeleton active paragraph={{ rows: 8 }} /> : null} {couponsQuery.isPending ? <Skeleton active paragraph={{ rows: 8 }} /> : null}
{couponsQuery.data && items.length === 0 ? <Empty description='暂无优惠券' /> : null} {couponsQuery.data && items.length === 0 ? <Empty description='暂无优惠券' /> : null}
......
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Empty, Image, List, Pagination, Statistic, Skeleton, Typography } from 'antd' import { Empty, Pagination, Statistic, Skeleton, Typography } from 'antd'
import { Link } from 'react-router' import { Link } from 'react-router'
import { BookCover } from '@/modules/book/components/BookCover'
import { useMyListQueryState } from '../../query-state' import { useMyListQueryState } from '../../query-state'
import { discussedBooksOptions } from '../query' import { discussedBooksOptions } from '../query'
...@@ -21,30 +22,23 @@ export function MyDiscussionsView() { ...@@ -21,30 +22,23 @@ export function MyDiscussionsView() {
{discussionsQuery.data && books.length === 0 ? <Empty description='暂无讨论' /> : null} {discussionsQuery.data && books.length === 0 ? <Empty description='暂无讨论' /> : null}
{books.length ? ( {books.length ? (
<> <>
<List <ul className='learning-list'>
bordered {books.map((book) => (
dataSource={books} <li className='learning-list__row' key={book.bookId}>
renderItem={(book) => ( <div className='learning-list__meta'>
<List.Item <BookCover url={book.img} alt='' width={52} height={72} />
actions={[ <div className='learning-list__content'>
<Link key='detail' to={`/book/${book.bookId}`}> <Typography.Text strong>{book.bookName}</Typography.Text>
前往图书讨论 <div className='learning-list__description'>{book.authors || '作者未知'}</div>
</Link>, </div>
]} </div>
> <div className='learning-list__actions'>
<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} /> <Statistic title='讨论' value={book.commentNum} />
</List.Item> <Link to={`/book/${book.bookId}`}>前往图书讨论</Link>
)} </div>
/> </li>
))}
</ul>
<div style={{ marginTop: 20, textAlign: 'right' }}> <div style={{ marginTop: 20, textAlign: 'right' }}>
<Pagination <Pagination
current={queryState.state.page} current={queryState.state.page}
......
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import { Button, Empty, Pagination, Popconfirm, Skeleton, Space, Typography } from 'antd'
Button,
Empty,
Image,
List,
Pagination,
Popconfirm,
Skeleton,
Space,
Typography,
} from 'antd'
import { Link } from 'react-router' import { Link } from 'react-router'
import { BookCover } from '@/modules/book/components/BookCover'
import { removeFavorite } from '../api' import { removeFavorite } from '../api'
import { favoriteKeys, favoritesOptions } from '../query' import { favoriteKeys, favoritesOptions } from '../query'
import { useMyListQueryState } from '../../query-state' import { useMyListQueryState } from '../../query-state'
...@@ -38,36 +29,14 @@ export function MyFavoritesView() { ...@@ -38,36 +29,14 @@ export function MyFavoritesView() {
{favoritesQuery.data && favorites.length === 0 ? <Empty description='暂无收藏' /> : null} {favoritesQuery.data && favorites.length === 0 ? <Empty description='暂无收藏' /> : null}
{favorites.length ? ( {favorites.length ? (
<> <>
<List <ul className='learning-list'>
bordered {favorites.map((book) => (
dataSource={favorites} <li className='learning-list__row' key={book.bookId}>
renderItem={(book) => ( <div className='learning-list__meta'>
<List.Item <BookCover url={book.img} alt='' width={52} height={72} />
actions={[ <div className='learning-list__content'>
<Link key='detail' to={`/book/${book.bookId}`}> <Typography.Text strong>{book.bookName}</Typography.Text>
查看详情 <div className='learning-list__description'>
</Link>,
<Popconfirm
key='remove'
title='取消收藏这本书?'
okText='取消收藏'
cancelText='保留'
onConfirm={() => removeMutation.mutate(book.bookId)}
>
<Button type='link' danger loading={removeMutation.isPending}>
取消收藏
</Button>
</Popconfirm>,
]}
>
<List.Item.Meta
avatar={
book.img ? (
<Image width={52} height={72} src={book.img} alt='' preview={false} />
) : null
}
title={book.bookName}
description={
<Space orientation='vertical' size={2}> <Space orientation='vertical' size={2}>
<Typography.Text type='secondary'> <Typography.Text type='secondary'>
{book.authors || '作者未知'} {book.authors || '作者未知'}
...@@ -78,11 +47,25 @@ export function MyFavoritesView() { ...@@ -78,11 +47,25 @@ export function MyFavoritesView() {
</Typography.Paragraph> </Typography.Paragraph>
) : null} ) : null}
</Space> </Space>
} </div>
/> </div>
</List.Item> </div>
)} <div className='learning-list__actions'>
/> <Link to={`/book/${book.bookId}`}>查看详情</Link>
<Popconfirm
title='取消收藏这本书?'
okText='取消收藏'
cancelText='保留'
onConfirm={() => removeMutation.mutate(book.bookId)}
>
<Button type='link' danger loading={removeMutation.isPending}>
取消收藏
</Button>
</Popconfirm>
</div>
</li>
))}
</ul>
<div style={{ marginTop: 20, textAlign: 'right' }}> <div style={{ marginTop: 20, textAlign: 'right' }}>
<Pagination <Pagination
current={queryState.state.page} current={queryState.state.page}
......
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { Button, Card, Form, Input, Typography, message } from 'antd' import { Button, Form, Input, Typography, message } from 'antd'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { submitFeedback } from '../api' import { submitFeedback } from '../api'
...@@ -21,7 +21,6 @@ export function FeedbackView() { ...@@ -21,7 +21,6 @@ export function FeedbackView() {
return ( return (
<main> <main>
<Typography.Title level={1}>意见反馈</Typography.Title> <Typography.Title level={1}>意见反馈</Typography.Title>
<Card style={{ maxWidth: 680 }}>
<Form <Form
form={form} form={form}
layout='vertical' layout='vertical'
...@@ -47,7 +46,6 @@ export function FeedbackView() { ...@@ -47,7 +46,6 @@ export function FeedbackView() {
提交反馈 提交反馈
</Button> </Button>
</Form> </Form>
</Card>
</main> </main>
) )
} }
.my-history__book-link {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 16px;
color: inherit;
text-decoration: none;
}
.my-history__book-link:hover .learning-list__content > .ant-typography {
color: var(--main-color);
}
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Empty, Image, List, Skeleton, Space, Tag, Typography } from 'antd' import { Empty, Skeleton, Space, Tag, Typography } from 'antd'
import { Link } from 'react-router' import { Link } from 'react-router'
import { BookCover } from '@/modules/book/components/BookCover'
import { useMyListQueryState } from '../../query-state' import { useMyListQueryState } from '../../query-state'
import { readRecordsOptions } from '../query' import { readRecordsOptions } from '../query'
import './MyHistoryView.css'
export function MyHistoryView() { export function MyHistoryView() {
const { state } = useMyListQueryState() const { state } = useMyListQueryState()
...@@ -18,28 +20,15 @@ export function MyHistoryView() { ...@@ -18,28 +20,15 @@ export function MyHistoryView() {
{groups.map((group) => ( {groups.map((group) => (
<section key={group.time} aria-label={group.time}> <section key={group.time} aria-label={group.time}>
<Typography.Title level={3}>{group.time}</Typography.Title> <Typography.Title level={3}>{group.time}</Typography.Title>
<List <ul className='learning-list'>
bordered {group.books.map((book) => (
dataSource={group.books} <li className='learning-list__row' key={book.bookId}>
renderItem={(book) => ( <Link className='my-history__book-link' to={`/book/${book.bookId}`}>
<List.Item <div className='learning-list__meta'>
actions={[ <BookCover url={book.img} alt='' width={46} height={64} />
<Link key='report' to={`/book/${book.bookId}`}> <div className='learning-list__content'>
学习报告 <Typography.Text strong>{book.bookName}</Typography.Text>
</Link>, <div className='learning-list__description'>
<Link key='detail' to={`/book/${book.bookId}`}>
图书详情
</Link>,
]}
>
<List.Item.Meta
avatar={
book.img ? (
<Image width={46} height={64} src={book.img} alt='' preview={false} />
) : null
}
title={book.bookName}
description={
<Space orientation='vertical' size={2}> <Space orientation='vertical' size={2}>
<Typography.Text type='secondary'> <Typography.Text type='secondary'>
{book.authors || '作者未知'} {book.authors || '作者未知'}
...@@ -51,12 +40,19 @@ export function MyHistoryView() { ...@@ -51,12 +40,19 @@ export function MyHistoryView() {
<Typography.Text type='secondary'>{book.createTime}</Typography.Text> <Typography.Text type='secondary'>{book.createTime}</Typography.Text>
) : null} ) : null}
</Space> </Space>
} </div>
/> </div>
{book.progress ? <Tag color='blue'>{book.progress}</Tag> : null} </div>
</List.Item> {book.progress ? (
)} <Tag className='learning-theme-tag'>{book.progress}</Tag>
/> ) : null}
</Link>
<div className='learning-list__actions'>
<Link to={`/book/${book.bookId}/report`}>学习报告</Link>
</div>
</li>
))}
</ul>
</section> </section>
))} ))}
</Space> </Space>
......
...@@ -35,6 +35,14 @@ ...@@ -35,6 +35,14 @@
padding: 24px; padding: 24px;
} }
.my-layout__content h1.ant-typography {
margin-block: 0 12px;
color: #20242b;
font-size: 24px;
font-weight: 600;
line-height: 1.4;
}
/* ---- custom sidebar ---- */ /* ---- custom sidebar ---- */
.my-sidebar { .my-sidebar {
...@@ -100,3 +108,9 @@ ...@@ -100,3 +108,9 @@
margin-left: 0; margin-left: 0;
} }
} }
@media (max-width: 640px) {
.my-layout__content h1.ant-typography {
font-size: 22px;
}
}
...@@ -32,25 +32,21 @@ export const myMenuGroups: MyMenuGroup[] = [ ...@@ -32,25 +32,21 @@ export const myMenuGroups: MyMenuGroup[] = [
], ],
}, },
{ {
key: 'account-settings', key: 'account-help',
label: '账户设置', label: '账户与帮助',
items: [ items: [
{ path: '/my/profile', label: '个人资料' }, { path: '/my/profile', label: '个人设置' },
{ path: '/my/security', label: '账号安全' }, { path: '/my/feedback', label: '意见反馈' },
], ],
}, },
{
key: 'help-feedback',
label: '帮助与反馈',
items: [{ path: '/my/feedback', label: '意见反馈' }],
},
] ]
const visibleItems = myMenuGroups.flatMap((group) => group.items) const visibleItems = myMenuGroups.flatMap((group) => group.items)
export function findMyMenuKey(pathname: string) { export function findMyMenuKey(pathname: string) {
const menuPath = pathname.startsWith('/my/security') ? '/my/profile' : pathname
const match = visibleItems const match = visibleItems
.filter((item) => pathname === item.path || pathname.startsWith(`${item.path}/`)) .filter((item) => menuPath === item.path || menuPath.startsWith(`${item.path}/`))
.sort((left, right) => right.path.length - left.path.length)[0]?.path .sort((left, right) => right.path.length - left.path.length)[0]?.path
return match ?? (pathname.startsWith('/my') ? '/my' : pathname) return match ?? (pathname.startsWith('/my') ? '/my' : pathname)
} }
import { Badge, List, Typography } from 'antd' import { Badge, Typography } from 'antd'
import type { MessageItem } from '../api' import type { MessageItem } from '../api'
export function MessageList({ export function MessageList({
...@@ -9,16 +9,15 @@ export function MessageList({ ...@@ -9,16 +9,15 @@ export function MessageList({
onSelect: (message: MessageItem) => void onSelect: (message: MessageItem) => void
}) { }) {
return ( return (
<List <ul className='learning-list'>
bordered {items.map((message, index) => (
dataSource={items} <li
renderItem={(message) => ( className='learning-list__row'
<List.Item key={index}
style={{ cursor: 'pointer', background: message.isRead ? undefined : '#fffbe6' }} style={{ cursor: 'pointer', background: message.isRead ? undefined : '#fffbe6' }}
onClick={() => onSelect(message)} onClick={() => onSelect(message)}
> >
<List.Item.Meta <div className='learning-list__meta'>
avatar={
<Badge dot={!message.isRead}> <Badge dot={!message.isRead}>
<span <span
aria-hidden='true' aria-hidden='true'
...@@ -27,7 +26,7 @@ export function MessageList({ ...@@ -27,7 +26,7 @@ export function MessageList({
width: 30, width: 30,
height: 30, height: 30,
borderRadius: '50%', borderRadius: '50%',
background: '#f0f5ff', background: 'color-mix(in srgb, var(--main-color) 10%, white)',
textAlign: 'center', textAlign: 'center',
lineHeight: '30px', lineHeight: '30px',
}} }}
...@@ -35,17 +34,16 @@ export function MessageList({ ...@@ -35,17 +34,16 @@ export function MessageList({
信 信
</span> </span>
</Badge> </Badge>
} <div className='learning-list__content'>
title={
<Typography.Text strong={!message.isRead}> <Typography.Text strong={!message.isRead}>
{message.title || '系统通知'} {message.title || '系统通知'}
</Typography.Text> </Typography.Text>
} <div className='learning-list__description'>{message.content || '—'}</div>
description={message.content || '—'} </div>
/> </div>
<Typography.Text type='secondary'>{message.createTime}</Typography.Text> <Typography.Text type='secondary'>{message.createTime}</Typography.Text>
</List.Item> </li>
)} ))}
/> </ul>
) )
} }
...@@ -17,6 +17,20 @@ export interface NotedBookList { ...@@ -17,6 +17,20 @@ export interface NotedBookList {
total?: number total?: number
} }
export interface BookNote {
id: string
type: number
chapterId: string
chapterName: string
quote: string
content: string
}
export interface BookNoteList {
items: BookNote[]
total?: number
}
type RecordValue = Record<string, unknown> type RecordValue = Record<string, unknown>
function record(value: unknown): RecordValue { function record(value: unknown): RecordValue {
...@@ -60,3 +74,31 @@ export async function getNotedBooks(params: MyListParams): Promise<NotedBookList ...@@ -60,3 +74,31 @@ export async function getNotedBooks(params: MyListParams): Promise<NotedBookList
total: Number.isFinite(Number(data.total)) ? Number(data.total) : undefined, total: Number.isFinite(Number(data.total)) ? Number(data.total) : undefined,
} }
} }
export async function getBookNotes(bookId: string, params: MyListParams): Promise<BookNoteList> {
const data = record(
await post('/api/app/v1/members/Information/myBookNotes', {
book_id: bookId,
page: params.page,
page_size: params.pageSize,
types: 0,
}),
)
return {
items: (Array.isArray(data.list) ? data.list : []).map((value) => {
const note = record(value)
const content = record(note.note_content)
return {
id: text(note.notes_id),
type: number(note.types),
chapterId: text(note.chapter_id),
chapterName: text(note.chapter_name),
quote: text(note.content),
content: text(
typeof content.text === 'string' ? content.text : record(content.text).content,
),
}
}),
total: Number.isFinite(Number(data.total)) ? Number(data.total) : undefined,
}
}
import { queryOptions } from '@tanstack/react-query' import { queryOptions } from '@tanstack/react-query'
import { getNotedBooks } from './api' import { getBookNotes, getNotedBooks } from './api'
import type { MyListSearchState } from '../query-state' import type { MyListSearchState } from '../query-state'
export const noteKeys = { export const noteKeys = {
all: ['my', 'notes'] as const, all: ['my', 'notes'] as const,
list: (params: MyListSearchState) => [...noteKeys.all, params] as const, list: (params: MyListSearchState) => [...noteKeys.all, params] as const,
book: (bookId: string, params: MyListSearchState) => [...noteKeys.all, bookId, params] as const,
} }
export function notedBooksOptions(params: MyListSearchState) { export function notedBooksOptions(params: MyListSearchState) {
return queryOptions({ queryKey: noteKeys.list(params), queryFn: () => getNotedBooks(params) }) return queryOptions({ queryKey: noteKeys.list(params), queryFn: () => getNotedBooks(params) })
} }
export function bookNotesOptions(bookId: string, params: MyListSearchState) {
return queryOptions({
queryKey: noteKeys.book(bookId, params),
queryFn: () => getBookNotes(bookId, params),
enabled: Boolean(bookId),
})
}
import { Alert, Button, Space, Typography } from 'antd'
import { Link, useParams } from 'react-router'
export function MyNoteDetailPlaceholderView() {
const { bookId = '' } = useParams()
return (
<main>
<Typography.Title level={1}>笔记详情</Typography.Title>
<Typography.Paragraph>图书编号:{bookId || '—'}</Typography.Paragraph>
<Alert
type='info'
showIcon
title='笔记详情路径已就绪'
description='当前阶段先按书籍聚合展示笔记资产;可继续前往图书页阅读和查看学习报告。'
/>
<Space style={{ marginTop: 20 }}>
<Link to='/my/notes'>
<Button>返回我的笔记</Button>
</Link>
{bookId ? (
<Link to={`/book/${bookId}`}>
<Button type='primary'>前往图书页</Button>
</Link>
) : null}
</Space>
</main>
)
}
import { useQuery } from '@tanstack/react-query'
import { Empty, Pagination, Skeleton, Space, Tag, Typography } from 'antd'
import { Link, useParams } from 'react-router'
import { useMyListQueryState } from '../../query-state'
import { bookNotesOptions } from '../query'
const noteTypes: Record<number, string> = { 1: '划线', 2: '高亮', 3: '笔记' }
export function MyNoteDetailView() {
const { bookId = '' } = useParams()
const { state, setPage } = useMyListQueryState()
const notesQuery = useQuery(bookNotesOptions(bookId, state))
const notes = notesQuery.data?.items ?? []
const total =
notesQuery.data?.total ??
(notes.length === state.pageSize
? state.page * state.pageSize + 1
: (state.page - 1) * state.pageSize + notes.length)
return (
<main>
<Typography.Title level={1}>我的笔记</Typography.Title>
<Typography.Paragraph>
<Link to='/my/notes'>返回书籍列表</Link>
</Typography.Paragraph>
{notesQuery.isPending ? <Skeleton active paragraph={{ rows: 8 }} /> : null}
{notesQuery.data && notes.length === 0 ? <Empty description='这本书暂无笔记' /> : null}
{notes.length > 0 ? (
<Space orientation='vertical' size='middle' style={{ width: '100%' }}>
<ul className='learning-list'>
{notes.map((note) => (
<li className='learning-list__row' key={note.id}>
<div className='learning-list__content'>
<Typography.Text strong>
<Space>
<Tag>{noteTypes[note.type] ?? '笔记'}</Tag>
{note.chapterName || `章节 ${note.chapterId}`}
</Space>
</Typography.Text>
<div className='learning-list__description'>
<Space orientation='vertical' size='small'>
{note.quote ? (
<Typography.Text type='secondary'>{note.quote}</Typography.Text>
) : null}
{note.content ? (
<Typography.Paragraph>{note.content}</Typography.Paragraph>
) : null}
</Space>
</div>
</div>
<Link to={`/reader/${bookId}/${note.chapterId}`}>打开章节</Link>
</li>
))}
</ul>
<Pagination
current={state.page}
pageSize={state.pageSize}
total={total}
hideOnSinglePage
showSizeChanger={false}
onChange={(page, pageSize) => void setPage(page, pageSize)}
/>
</Space>
) : null}
</main>
)
}
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Empty, Image, List, Pagination, Space, Statistic, Skeleton, Typography } from 'antd' import { Empty, Pagination, Space, Statistic, Skeleton, Typography } from 'antd'
import { Link } from 'react-router' import { Link } from 'react-router'
import { BookCover } from '@/modules/book/components/BookCover'
import { useMyListQueryState } from '../../query-state' import { useMyListQueryState } from '../../query-state'
import { notedBooksOptions } from '../query' import { notedBooksOptions } from '../query'
...@@ -21,38 +22,27 @@ export function MyNotesView() { ...@@ -21,38 +22,27 @@ export function MyNotesView() {
{notesQuery.data && books.length === 0 ? <Empty description='暂无笔记' /> : null} {notesQuery.data && books.length === 0 ? <Empty description='暂无笔记' /> : null}
{books.length ? ( {books.length ? (
<> <>
<List <ul className='learning-list'>
bordered {books.map((book) => (
dataSource={books} <li className='learning-list__row' key={book.bookId}>
renderItem={(book) => ( <div className='learning-list__meta'>
<List.Item <BookCover url={book.img} alt='' width={52} height={72} />
actions={[ <div className='learning-list__content'>
<Link key='notes' to={`/my/notes/${book.bookId}`}> <Typography.Text strong>{book.bookName}</Typography.Text>
查看笔记 <div className='learning-list__description'>{book.authors || '作者未知'}</div>
</Link>, </div>
<Link key='book' to={`/book/${book.bookId}`}> </div>
图书详情 <Space>
</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 || '作者未知'}
/>
<Space size='large'>
<Statistic title='全部' value={book.notesNum} /> <Statistic title='全部' value={book.notesNum} />
<Statistic title='笔记' value={book.noteNum} /> <Statistic title='笔记' value={book.noteNum} />
<Statistic title='高亮' value={book.colorNum} /> <Statistic title='高亮' value={book.colorNum} />
<Statistic title='划线' value={book.lineNum} /> <Statistic title='划线' value={book.lineNum} />
<Link to={`/my/notes/${book.bookId}`}>查看笔记</Link>
<Link to={`/book/${book.bookId}`}>图书详情</Link>
</Space> </Space>
</List.Item> </li>
)} ))}
/> </ul>
<div style={{ marginTop: 20, textAlign: 'right' }}> <div style={{ marginTop: 20, textAlign: 'right' }}>
<Pagination <Pagination
current={queryState.state.page} current={queryState.state.page}
......
...@@ -6,7 +6,6 @@ export interface OrderListParams { ...@@ -6,7 +6,6 @@ export interface OrderListParams {
page: number page: number
pageSize: number pageSize: number
status: OrderStatus status: OrderStatus
keyword: string
} }
export interface OrderBook { export interface OrderBook {
...@@ -113,7 +112,6 @@ export async function getOrders(params: OrderListParams): Promise<OrderListResul ...@@ -113,7 +112,6 @@ export async function getOrders(params: OrderListParams): Promise<OrderListResul
page_size: params.pageSize, page_size: params.pageSize,
status: params.status, status: params.status,
} }
if (params.keyword.trim()) request.keys = params.keyword.trim()
const data = await post<unknown>('/api/app/v1/orders/Orders/getOrdersList', request) const data = await post<unknown>('/api/app/v1/orders/Orders/getOrdersList', request)
const payload = record(data) const payload = record(data)
return { return {
......
import { Button, Card, Image, Space, Tag, Typography } from 'antd' import { Button, Card, Space, Tag, Typography } from 'antd'
import { BookCover } from '@/modules/book/components/BookCover'
import type { OrderItem } from '../api' import type { OrderItem } from '../api'
const statusLabels: Record<OrderItem['status'], string> = { const statusLabels: Record<OrderItem['status'], string> = {
...@@ -11,7 +12,7 @@ const statusColors: Record<OrderItem['status'], string> = { ...@@ -11,7 +12,7 @@ const statusColors: Record<OrderItem['status'], string> = {
1: 'orange', 1: 'orange',
2: 'default', 2: 'default',
3: 'green', 3: 'green',
4: 'blue', 4: '',
} }
export function OrderList({ export function OrderList({
...@@ -30,7 +31,12 @@ export function OrderList({ ...@@ -30,7 +31,12 @@ export function OrderList({
title={ title={
<Space> <Space>
<Typography.Text>订单号:{order.orderNumber || '—'}</Typography.Text> <Typography.Text>订单号:{order.orderNumber || '—'}</Typography.Text>
<Tag color={statusColors[order.status]}>{statusLabels[order.status]}</Tag> <Tag
color={statusColors[order.status] || undefined}
className={order.status === 4 ? 'learning-theme-tag' : undefined}
>
{statusLabels[order.status]}
</Tag>
</Space> </Space>
} }
extra={ extra={
...@@ -47,9 +53,7 @@ export function OrderList({ ...@@ -47,9 +53,7 @@ export function OrderList({
<Space wrap size='middle' style={{ marginBottom: 10 }}> <Space wrap size='middle' style={{ marginBottom: 10 }}>
{order.books.map((book) => ( {order.books.map((book) => (
<Space key={book.recordId || book.bookId}> <Space key={book.recordId || book.bookId}>
{book.image ? ( <BookCover url={book.image} alt='' width={38} height={52} />
<Image width={38} height={52} src={book.image} alt='' preview={false} />
) : null}
<Typography.Text>{book.name || '图书'}</Typography.Text> <Typography.Text>{book.name || '图书'}</Typography.Text>
</Space> </Space>
))} ))}
......
import { createParser, parseAsString, useQueryStates } from 'nuqs' import { createParser, useQueryStates } from 'nuqs'
import type { OrderStatus } from './api' import type { OrderStatus } from './api'
function positiveInteger(defaultValue: number) { function positiveInteger(defaultValue: number) {
...@@ -17,7 +17,6 @@ const orderSearchParams = { ...@@ -17,7 +17,6 @@ const orderSearchParams = {
orderStatuses.includes(Number(value) as OrderStatus) ? (Number(value) as OrderStatus) : null, orderStatuses.includes(Number(value) as OrderStatus) ? (Number(value) as OrderStatus) : null,
serialize: (value) => String(value), serialize: (value) => String(value),
}).withDefault(0), }).withDefault(0),
q: parseAsString.withDefault(''),
page: positiveInteger(1), page: positiveInteger(1),
pageSize: positiveInteger(10), pageSize: positiveInteger(10),
} }
...@@ -30,7 +29,6 @@ export function useOrdersQueryState() { ...@@ -30,7 +29,6 @@ export function useOrdersQueryState() {
return { return {
state, state,
setStatus: (status: OrderStatus) => setState({ status, page: 1 }), setStatus: (status: OrderStatus) => setState({ status, page: 1 }),
search: (q: string) => setState({ q, page: 1 }),
setPagination: (page: number, pageSize: number) => setState({ page, pageSize }), setPagination: (page: number, pageSize: number) => setState({ page, pageSize }),
} }
} }
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Empty, Input, Pagination, Segmented, Skeleton, Space, Typography } from 'antd' import { Empty, Pagination, Skeleton, Space, Tabs, Typography } from 'antd'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import type { OrderStatus } from '../api' import type { OrderStatus } from '../api'
import { OrderList } from '../components/OrderList' import { OrderList } from '../components/OrderList'
...@@ -22,12 +21,9 @@ function totalFor(page: number, pageSize: number, count: number, total?: number) ...@@ -22,12 +21,9 @@ function totalFor(page: number, pageSize: number, count: number, total?: number)
export function MyOrdersView() { export function MyOrdersView() {
const navigate = useNavigate() const navigate = useNavigate()
const queryState = useOrdersQueryState() const queryState = useOrdersQueryState()
const [draft, setDraft] = useState(queryState.state.q) const ordersQuery = useQuery(ordersOptions(queryState.state))
const ordersQuery = useQuery(ordersOptions({ ...queryState.state, keyword: queryState.state.q }))
const items = ordersQuery.data?.items ?? [] const items = ordersQuery.data?.items ?? []
useEffect(() => setDraft(queryState.state.q), [queryState.state.q])
return ( return (
<main> <main>
<Space orientation='vertical' size='middle' style={{ width: '100%' }}> <Space orientation='vertical' size='middle' style={{ width: '100%' }}>
...@@ -37,19 +33,10 @@ export function MyOrdersView() { ...@@ -37,19 +33,10 @@ export function MyOrdersView() {
查看图书订单和紫荆币充值订单。 查看图书订单和紫荆币充值订单。
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
<Input.Search <Tabs
value={draft} activeKey={String(queryState.state.status)}
allowClear items={statusOptions.map(({ label, value }) => ({ key: String(value), label }))}
enterButton='搜索' onChange={(status) => void queryState.setStatus(Number(status) as OrderStatus)}
placeholder='搜索图书名称'
onChange={(event) => setDraft(event.target.value)}
onSearch={() => void queryState.search(draft.trim())}
/>
<Segmented<OrderStatus>
block
value={queryState.state.status}
options={statusOptions}
onChange={(status) => void queryState.setStatus(status)}
/> />
{ordersQuery.isPending ? <Skeleton active paragraph={{ rows: 10 }} /> : null} {ordersQuery.isPending ? <Skeleton active paragraph={{ rows: 10 }} /> : null}
{ordersQuery.data && items.length === 0 ? <Empty description='暂无订单' /> : null} {ordersQuery.data && items.length === 0 ? <Empty description='暂无订单' /> : null}
......
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import { Alert, Button, Card, Descriptions, Empty, Skeleton, Space, Tag, Typography } from 'antd'
Alert,
Button,
Card,
Descriptions,
Empty,
Image,
List,
Skeleton,
Space,
Tag,
Typography,
} from 'antd'
import { Link, useParams } from 'react-router' import { Link, useParams } from 'react-router'
import { BookCover } from '@/modules/book/components/BookCover'
import { getPayScene, payOrderAgain } from '@/modules/purchase/api' import { getPayScene, payOrderAgain } from '@/modules/purchase/api'
import { orderDetailOptions, orderKeys } from '../query' import { orderDetailOptions, orderKeys } from '../query'
...@@ -20,7 +9,7 @@ const statuses: Record<number, { label: string; color: string }> = { ...@@ -20,7 +9,7 @@ const statuses: Record<number, { label: string; color: string }> = {
1: { label: '待支付', color: 'orange' }, 1: { label: '待支付', color: 'orange' },
2: { label: '已取消', color: 'default' }, 2: { label: '已取消', color: 'default' },
3: { label: '已完成', color: 'green' }, 3: { label: '已完成', color: 'green' },
4: { label: '已退款', color: 'blue' }, 4: { label: '已退款', color: '' },
} }
export function OrderDetailView() { export function OrderDetailView() {
...@@ -67,7 +56,12 @@ export function OrderDetailView() { ...@@ -67,7 +56,12 @@ export function OrderDetailView() {
<Typography.Title level={1} style={{ margin: 0 }}> <Typography.Title level={1} style={{ margin: 0 }}>
订单详情 订单详情
</Typography.Title> </Typography.Title>
<Tag color={status.color}>{status.label}</Tag> <Tag
color={status.color || undefined}
className={order.status === 4 ? 'learning-theme-tag' : undefined}
>
{status.label}
</Tag>
</Space> </Space>
{order.status === 1 ? ( {order.status === 1 ? (
<Alert <Alert
...@@ -145,30 +139,30 @@ export function OrderDetailView() { ...@@ -145,30 +139,30 @@ export function OrderDetailView() {
</Card> </Card>
) : ( ) : (
<Card title='图书'> <Card title='图书'>
<List {order.books.length ? (
dataSource={order.books} <ul className='learning-list'>
locale={{ emptyText: '暂无图书明细' }} {order.books.map((book, index) => (
renderItem={(book) => ( <li className='learning-list__row' key={index}>
<List.Item> <div className='learning-list__meta'>
<List.Item.Meta <BookCover url={book.image} alt='' width={48} height={66} />
avatar={ <div className='learning-list__content'>
book.image ? ( <Typography.Text strong>
<Image width={48} height={66} src={book.image} alt='' preview={false} /> {book.bookId ? (
) : null
}
title={
book.bookId ? (
<Link to={`/book/${book.bookId}`}>{book.name || '图书'}</Link> <Link to={`/book/${book.bookId}`}>{book.name || '图书'}</Link>
) : ( ) : (
book.name || '图书' book.name || '图书'
) )}
} </Typography.Text>
description={book.introduction || '—'} <div className='learning-list__description'>{book.introduction || '—'}</div>
/> </div>
</div>
<Typography.Text>¥{book.price || '0.00'}</Typography.Text> <Typography.Text>¥{book.price || '0.00'}</Typography.Text>
</List.Item> </li>
))}
</ul>
) : (
<Empty description='暂无图书明细' />
)} )}
/>
</Card> </Card>
)} )}
</Space> </Space>
......
.my-overview { .my-overview {
display: grid; display: grid;
gap: 28px; gap: 32px;
} }
.my-overview__profile { .my-overview__profile {
...@@ -13,6 +13,136 @@ ...@@ -13,6 +13,136 @@
min-width: 0; min-width: 0;
} }
.my-overview__profile-copy h2 {
font-size: 20px !important;
line-height: 1.4;
}
.my-overview__section-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
margin-bottom: 10px;
}
.my-overview__section-heading .ant-typography {
margin-bottom: 4px;
}
.my-overview__section-heading h3 {
font-size: 18px;
line-height: 1.4;
}
.my-overview__continue-card {
overflow: hidden;
border-color: var(--learning-border);
border-radius: 14px;
background: linear-gradient(110deg, color-mix(in srgb, var(--main-color) 7%, white), white 62%);
}
.my-overview__continue-card .ant-card-body {
padding: 18px 20px;
}
.my-overview__continue-book {
display: flex;
align-items: center;
gap: 18px;
}
.my-overview__continue-card .book-cover,
.my-overview__continue-card .book-cover img {
width: 80px !important;
height: 106px !important;
flex: 0 0 auto;
object-fit: cover;
border-radius: 5px;
box-shadow: 0 8px 18px rgb(31 41 55 / 13%);
}
.my-overview__continue-copy {
min-width: 0;
flex: 1;
}
.my-overview__continue-copy h4 {
display: -webkit-box;
overflow: hidden;
margin: 4px 0;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 16px;
line-height: 1.45;
}
.my-overview__eyebrow {
color: var(--main-color);
font-size: 12px;
font-weight: 600;
}
.my-overview__reading-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin-top: 8px;
color: #69717d;
font-size: 13px;
}
.my-overview__continue-actions {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.my-overview__continue-actions .ant-btn-link {
padding-inline: 0;
}
.my-overview__recent-books {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.my-overview__recent-book {
display: grid;
min-width: 0;
gap: 6px;
padding: 10px 12px;
color: #252a32;
text-align: left;
background: #fff;
border: 1px solid var(--learning-border);
border-radius: 10px;
cursor: pointer;
transition:
border-color 140ms ease-out,
background-color 140ms ease-out;
}
.my-overview__recent-book:hover {
border-color: color-mix(in srgb, var(--main-color) 48%, var(--learning-border));
background: color-mix(in srgb, var(--main-color) 3%, white);
}
.my-overview__recent-book span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.my-overview__recent-book span:last-child {
color: #7a818c;
font-size: 12px;
}
.my-overview__statistics { .my-overview__statistics {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
...@@ -37,6 +167,29 @@ ...@@ -37,6 +167,29 @@
} }
} }
@media (max-width: 640px) {
.my-overview__continue-book {
align-items: flex-start;
flex-wrap: wrap;
gap: 16px;
}
.my-overview__continue-copy {
width: calc(100% - 112px);
flex: 1 1 calc(100% - 112px);
}
.my-overview__continue-actions {
width: 100%;
flex-direction: row;
align-items: center;
}
.my-overview__recent-books {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) { @media (max-width: 480px) {
.my-overview__statistics { .my-overview__statistics {
grid-template-columns: 1fr; grid-template-columns: 1fr;
......
import { Avatar, Card, Skeleton, Statistic, Typography } from 'antd' import { Avatar, Button, Card, Empty, Skeleton, Statistic, Typography, message } from 'antd'
import { useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { getBookDetail } from '@/modules/book/api'
import { BookCover } from '@/modules/book/components/BookCover'
import { readRecordsOptions } from '../../history/query'
import { myStatisticsOptions } from '../../query' import { myStatisticsOptions } from '../../query'
import { overviewDestinations } from '../overview-navigation' import { overviewDestinations } from '../overview-navigation'
import './MyOverviewView.css' import './MyOverviewView.css'
...@@ -19,7 +22,20 @@ const accountStatistics = [ ...@@ -19,7 +22,20 @@ const accountStatistics = [
export function MyOverviewView() { export function MyOverviewView() {
const statisticsQuery = useQuery(myStatisticsOptions()) const statisticsQuery = useQuery(myStatisticsOptions())
const historyQuery = useQuery(readRecordsOptions({ page: 1, pageSize: 5 }))
const navigate = useNavigate() const navigate = useNavigate()
const [messageApi, contextHolder] = message.useMessage()
const resumeMutation = useMutation({
mutationFn: (bookId: string) => getBookDetail(bookId),
onSuccess: (book, bookId) => {
if (book.chapterId) {
navigate(`/reader/${bookId}/${book.chapterId}`)
return
}
navigate(`/book/${bookId}`)
messageApi.info('已打开教材,请从目录选择章节')
},
})
const navigateOnKeyDown = (path: string) => (event: React.KeyboardEvent) => { const navigateOnKeyDown = (path: string) => (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault() event.preventDefault()
...@@ -33,9 +49,14 @@ export function MyOverviewView() { ...@@ -33,9 +49,14 @@ export function MyOverviewView() {
const statistics = statisticsQuery.data const statistics = statisticsQuery.data
if (!statistics) return null if (!statistics) return null
const recentBooks = (historyQuery.data ?? []).flatMap((group) => group.books)
const latestBook = recentBooks[0]
const latestProgress = Number(latestBook?.progress?.replace(/[^\d.]/g, ''))
const latestAction = latestProgress >= 100 ? '再次阅读' : '继续阅读'
return ( return (
<main className='my-overview'> <main className='my-overview'>
{contextHolder}
<section aria-label='用户信息'> <section aria-label='用户信息'>
<div className='my-overview__profile'> <div className='my-overview__profile'>
<Avatar size={48} src={statistics.headImg || undefined}> <Avatar size={48} src={statistics.headImg || undefined}>
...@@ -43,11 +64,101 @@ export function MyOverviewView() { ...@@ -43,11 +64,101 @@ export function MyOverviewView() {
</Avatar> </Avatar>
<div className='my-overview__profile-copy'> <div className='my-overview__profile-copy'>
<Typography.Title level={2} style={{ margin: 0 }}> <Typography.Title level={2} style={{ margin: 0 }}>
{statistics.name || '—'} 欢迎回来,{statistics.name || '同学'}
</Typography.Title>
<Typography.Text type='secondary'>
{latestBook ? '从上次的进度继续学习' : '从图书馆选择一本教材开始学习'}
</Typography.Text>
</div>
</div>
</section>
<section aria-labelledby='my-continue-learning'>
<div className='my-overview__section-heading'>
<div>
<Typography.Title id='my-continue-learning' level={3}>
继续学习
</Typography.Title> </Typography.Title>
<Typography.Text type='secondary'>{statistics.phone || ''}</Typography.Text> <Typography.Text type='secondary'>接着上次读到的地方继续</Typography.Text>
</div>
<Button type='link' onClick={() => navigate('/my/history')}>
全部学习记录
</Button>
</div>
{historyQuery.isPending ? (
<Card className='my-overview__continue-card'>
<Skeleton active avatar paragraph={{ rows: 2 }} />
</Card>
) : historyQuery.isError ? (
<Card className='my-overview__continue-card'>
<Typography.Text>学习记录暂时无法加载</Typography.Text>
<Button type='link' onClick={() => void historyQuery.refetch()}>
重试
</Button>
</Card>
) : latestBook ? (
<Card className='my-overview__continue-card'>
<div className='my-overview__continue-book'>
<BookCover
url={latestBook.img}
alt={`${latestBook.bookName}封面`}
width={80}
height={106}
/>
<div className='my-overview__continue-copy'>
<Typography.Text className='my-overview__eyebrow'>最近阅读</Typography.Text>
<Typography.Title level={4}>{latestBook.bookName}</Typography.Title>
<Typography.Text type='secondary'>
{latestBook.authors || '作者信息暂缺'}
</Typography.Text>
<div className='my-overview__reading-meta'>
{latestBook.lastChapter ? <span>读到:{latestBook.lastChapter}</span> : null}
{latestBook.progress ? <span>进度:{latestBook.progress}</span> : null}
</div> </div>
</div> </div>
<div className='my-overview__continue-actions'>
<Button
type='primary'
loading={
resumeMutation.isPending && resumeMutation.variables === latestBook.bookId
}
onClick={() => resumeMutation.mutate(latestBook.bookId)}
>
{latestAction}
</Button>
<Button type='link' onClick={() => navigate(`/book/${latestBook.bookId}`)}>
查看教材
</Button>
</div>
</div>
</Card>
) : (
<Card className='my-overview__continue-card'>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description='还没有学习记录,从一本教材开始吧'
>
<Button type='primary' onClick={() => navigate('/library')}>
去图书馆
</Button>
</Empty>
</Card>
)}
{recentBooks.length > 1 ? (
<div className='my-overview__recent-books' aria-label='最近阅读的教材'>
{recentBooks.slice(1, 4).map((book) => (
<button
className='my-overview__recent-book'
key={book.bookId}
type='button'
onClick={() => resumeMutation.mutate(book.bookId)}
>
<span>{book.bookName}</span>
<span>{book.progress || book.lastChapter || '继续阅读'} · 继续阅读</span>
</button>
))}
</div>
) : null}
</section> </section>
<section aria-labelledby='my-learning-statistics'> <section aria-labelledby='my-learning-statistics'>
......
.profile-view__section-title.ant-typography {
margin-block: 24px 8px;
font-size: 18px;
font-weight: 600;
}
.profile-view__body {
display: grid;
grid-template-columns: 144px minmax(0, 560px);
align-items: start;
gap: 36px;
padding-top: 12px;
}
.profile-view__avatar-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding-top: 8px;
}
.profile-view__avatar-area .ant-upload {
display: inline-flex;
border-radius: 50%;
}
.profile-view__details {
min-width: 0;
}
.profile-view__details .ant-form-item {
margin-bottom: 24px;
}
.profile-view__security {
max-width: 740px;
padding-top: 20px;
margin-top: 32px;
border-top: 1px solid var(--learning-border);
}
.profile-view__security-list {
max-width: 740px;
}
.profile-view__security-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 68px;
border-bottom: 1px solid var(--learning-border);
}
.profile-view__security-copy {
display: flex;
flex-direction: column;
gap: 4px;
}
@media (max-width: 640px) {
.profile-view__body {
grid-template-columns: minmax(0, 1fr);
gap: 24px;
}
.profile-view__avatar-area {
align-items: flex-start;
}
}
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { UploadOutlined } from '@ant-design/icons' import { Avatar, Button, Form, Input, Radio, Skeleton, Typography, Upload, message } from 'antd'
import {
Avatar,
Button,
Card,
Form,
Input,
Radio,
Skeleton,
Typography,
Upload,
message,
} from 'antd'
import type { UploadProps } from 'antd' import type { UploadProps } from 'antd'
import { useNavigate } from 'react-router'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { myKeys } from '../../query' import { myKeys } from '../../query'
import { updateMemberProfile, type UpdateMemberProfileInput } from '../api' import { updateMemberProfile, type UpdateMemberProfileInput } from '../api'
import { uploadAvatar } from '../avatar-upload' import { uploadAvatar } from '../avatar-upload'
import { memberProfileOptions, profileKeys } from '../query' import { memberProfileOptions, profileKeys } from '../query'
import './ProfileView.css'
interface ProfileFormValues { interface ProfileFormValues {
name: string name: string
...@@ -35,6 +25,7 @@ const sexOptions = [ ...@@ -35,6 +25,7 @@ const sexOptions = [
export function ProfileView() { export function ProfileView() {
const [form] = Form.useForm<ProfileFormValues>() const [form] = Form.useForm<ProfileFormValues>()
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const navigate = useNavigate()
const session = useSessionStore((state) => state.session) const session = useSessionStore((state) => state.session)
const setSession = useSessionStore((state) => state.setSession) const setSession = useSessionStore((state) => state.setSession)
const queryClient = useQueryClient() const queryClient = useQueryClient()
...@@ -101,13 +92,16 @@ export function ProfileView() { ...@@ -101,13 +92,16 @@ export function ProfileView() {
return ( return (
<main> <main>
<Typography.Title level={1}>个人资料</Typography.Title> <Typography.Title level={1}>个人设置</Typography.Title>
<Card style={{ maxWidth: 680 }}> <Typography.Title level={3} className='profile-view__section-title'>
个人资料
</Typography.Title>
<Form <Form
form={form} form={form}
layout='vertical' layout='vertical'
requiredMark='optional' requiredMark='optional'
onFinish={(values) => saveMutation.mutate(values)} onFinish={(values) => saveMutation.mutate(values)}
className='profile-view__body'
> >
<Form.Item name='headImg' hidden> <Form.Item name='headImg' hidden>
<Input /> <Input />
...@@ -119,18 +113,22 @@ export function ProfileView() { ...@@ -119,18 +113,22 @@ export function ProfileView() {
} }
> >
{() => ( {() => (
<Form.Item label='头像' extra='支持 JPG、PNG、WebP,大小不超过 5MB。'> <div className='profile-view__avatar-area'>
<Avatar size={72} src={form.getFieldValue('headImg') || undefined}> <Upload {...avatarUploadProps}>
<Avatar
size={120}
src={form.getFieldValue('headImg') || undefined}
style={{ cursor: uploading ? 'wait' : 'pointer' }}
aria-label='点击更换头像'
>
{form.getFieldValue('name')?.slice(0, 1)} {form.getFieldValue('name')?.slice(0, 1)}
</Avatar> </Avatar>
<Upload {...avatarUploadProps}>
<Button icon={<UploadOutlined />} loading={uploading} style={{ marginLeft: 12 }}>
上传头像
</Button>
</Upload> </Upload>
</Form.Item> <Typography.Text type='secondary'>点击头像更换</Typography.Text>
</div>
)} )}
</Form.Item> </Form.Item>
<div className='profile-view__details'>
<Form.Item <Form.Item
label='昵称' label='昵称'
name='name' name='name'
...@@ -144,14 +142,42 @@ export function ProfileView() { ...@@ -144,14 +142,42 @@ export function ProfileView() {
<Form.Item label='性别' name='sex' rules={[{ required: true, message: '请选择性别' }]}> <Form.Item label='性别' name='sex' rules={[{ required: true, message: '请选择性别' }]}>
<Radio.Group options={sexOptions} /> <Radio.Group options={sexOptions} />
</Form.Item> </Form.Item>
<Form.Item label='手机号'>
<Input value={profileQuery.data.phone} disabled />
</Form.Item>
<Button type='primary' htmlType='submit' loading={saveMutation.isPending}> <Button type='primary' htmlType='submit' loading={saveMutation.isPending}>
保存修改 保存修改
</Button> </Button>
</div>
</Form> </Form>
</Card> <section className='profile-view__security' aria-labelledby='profile-security-title'>
<Typography.Title
level={3}
id='profile-security-title'
className='profile-view__section-title'
>
账号安全
</Typography.Title>
<div className='profile-view__security-list'>
<div className='profile-view__security-row'>
<div className='profile-view__security-copy'>
<Typography.Text strong>手机号码</Typography.Text>
<Typography.Text type='secondary'>
{profileQuery.data.phone || '未绑定'}
</Typography.Text>
</div>
<Button type='link' onClick={() => navigate('/my/security/phone')}>
修改手机号
</Button>
</div>
<div className='profile-view__security-row'>
<div className='profile-view__security-copy'>
<Typography.Text strong>登录密码</Typography.Text>
<Typography.Text type='secondary'>已设置</Typography.Text>
</div>
<Button type='link' onClick={() => navigate('/my/security/password')}>
修改密码
</Button>
</div>
</div>
</section>
</main> </main>
) )
} }
...@@ -45,10 +45,7 @@ export const routes: RouteObject[] = [ ...@@ -45,10 +45,7 @@ export const routes: RouteObject[] = [
}, },
{ {
path: 'notes/:bookId', path: 'notes/:bookId',
lazy: lazyRoute( lazy: lazyRoute(() => import('./notes/views/MyNoteDetailView'), 'MyNoteDetailView'),
() => import('./notes/views/MyNoteDetailPlaceholderView'),
'MyNoteDetailPlaceholderView',
),
}, },
{ {
path: 'discussions', path: 'discussions',
......
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { Button, Card, Form, Input, Typography, message } from 'antd' import { Button, Card, Form, Input, Space, Typography, message } from 'antd'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { changeMemberPassword } from '../api' import { changeMemberPassword } from '../api'
...@@ -28,7 +28,7 @@ export function ChangePasswordView() { ...@@ -28,7 +28,7 @@ export function ChangePasswordView() {
return ( return (
<main> <main>
<Typography.Title level={1}>修改密码</Typography.Title> <Typography.Title level={1}>修改密码</Typography.Title>
<Card style={{ maxWidth: 520 }}> <Card>
<Form <Form
form={form} form={form}
layout='vertical' layout='vertical'
...@@ -61,9 +61,12 @@ export function ChangePasswordView() { ...@@ -61,9 +61,12 @@ export function ChangePasswordView() {
> >
<Input.Password autoComplete='new-password' /> <Input.Password autoComplete='new-password' />
</Form.Item> </Form.Item>
<Space>
<Button onClick={() => navigate('/my/profile')}>返回个人设置</Button>
<Button type='primary' htmlType='submit' loading={mutation.isPending}> <Button type='primary' htmlType='submit' loading={mutation.isPending}>
确认修改 确认修改
</Button> </Button>
</Space>
</Form> </Form>
</Card> </Card>
</main> </main>
......
...@@ -27,7 +27,7 @@ export function ChangePhoneView() { ...@@ -27,7 +27,7 @@ export function ChangePhoneView() {
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: profileKeys.all }) await queryClient.invalidateQueries({ queryKey: profileKeys.all })
message.success('手机号修改成功') message.success('手机号修改成功')
navigate('/my/security', { replace: true }) navigate('/my/profile', { replace: true })
}, },
}) })
...@@ -49,7 +49,7 @@ export function ChangePhoneView() { ...@@ -49,7 +49,7 @@ export function ChangePhoneView() {
return ( return (
<main> <main>
<Typography.Title level={1}>修改手机号</Typography.Title> <Typography.Title level={1}>修改手机号</Typography.Title>
<Card style={{ maxWidth: 520 }}> <Card>
<Form <Form
form={form} form={form}
layout='vertical' layout='vertical'
...@@ -87,9 +87,12 @@ export function ChangePhoneView() { ...@@ -87,9 +87,12 @@ export function ChangePhoneView() {
</Button> </Button>
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Space>
<Button onClick={() => navigate('/my/profile')}>返回个人设置</Button>
<Button type='primary' htmlType='submit' loading={changeMutation.isPending}> <Button type='primary' htmlType='submit' loading={changeMutation.isPending}>
确认修改 确认修改
</Button> </Button>
</Space>
</Form> </Form>
</Card> </Card>
</main> </main>
......
import { useState } from 'react' import { Navigate } from 'react-router'
import { useMutation, useQuery } from '@tanstack/react-query'
import { Button, Card, Descriptions, Modal, Space, Typography, message } from 'antd'
import { useNavigate } from 'react-router'
import { useSessionStore } from '@/stores/session'
import { getMemberProfile } from '../../profile/api'
import { profileKeys } from '../../profile/query'
import { deleteMemberAccount } from '../api'
export function SecurityView() { export function SecurityView() {
const navigate = useNavigate() return <Navigate to='/my/profile' replace />
const clearSession = useSessionStore((state) => state.clearSession)
const [confirmOpen, setConfirmOpen] = useState(false)
const profileQuery = useQuery({ queryKey: profileKeys.detail(), queryFn: getMemberProfile })
const deleteMutation = useMutation({
mutationFn: deleteMemberAccount,
onSuccess: () => {
clearSession()
message.success('账号已注销')
navigate('/login', { replace: true })
},
})
return (
<main>
<Typography.Title level={1}>账号安全</Typography.Title>
<Card
style={{ maxWidth: 680, marginBottom: 16 }}
loading={profileQuery.isPending}
title='账号设置'
>
<Descriptions
column={1}
items={[
{ key: 'phone', label: '手机号码', children: profileQuery.data?.phone || '未绑定' },
{ key: 'password', label: '登录密码', children: '已设置' },
]}
/>
<Space wrap>
<Button onClick={() => navigate('/my/security/phone')}>修改手机号</Button>
<Button onClick={() => navigate('/my/security/password')}>修改密码</Button>
</Space>
</Card>
<Card style={{ maxWidth: 680 }} title='危险操作'>
<Typography.Paragraph type='secondary'>
注销后将无法恢复账户及其相关数据。
</Typography.Paragraph>
<Button danger onClick={() => setConfirmOpen(true)}>
注销账号
</Button>
</Card>
<Modal
title='确定注销账号吗?'
open={confirmOpen}
okText='确认注销'
okButtonProps={{ danger: true, loading: deleteMutation.isPending }}
cancelText='取消'
onCancel={() => setConfirmOpen(false)}
onOk={() => deleteMutation.mutate()}
>
<Typography.Paragraph>该操作不可撤销,请确认你已了解其影响。</Typography.Paragraph>
</Modal>
</main>
)
} }
import { List, Tag, Typography } from 'antd' import { Tag, Typography } from 'antd'
import type { WalletRecord } from '../api' import type { WalletRecord } from '../api'
export function WalletRecordList({ export function WalletRecordList({
...@@ -9,28 +9,23 @@ export function WalletRecordList({ ...@@ -9,28 +9,23 @@ export function WalletRecordList({
kind: '积分' | '紫荆币' kind: '积分' | '紫荆币'
}) { }) {
return ( return (
<List <ul className='learning-list'>
bordered {items.map((item, index) => {
dataSource={items}
renderItem={(item) => {
const increase = item.option === 1 const increase = item.option === 1
return ( return (
<List.Item <li className='learning-list__row' key={index}>
extra={ <div className='learning-list__content'>
<Typography.Text>{item.remarks || `${kind}变动`}</Typography.Text>
<div className='learning-list__description'>{item.createTime || '—'}</div>
</div>
<Tag>{item.balance ? `余额 ${item.balance}` : kind}</Tag>
<Typography.Text strong type={increase ? 'success' : 'danger'}> <Typography.Text strong type={increase ? 'success' : 'danger'}>
{increase ? '+' : '-'} {increase ? '+' : '-'}
{item.amount || '0'} {item.amount || '0'}
</Typography.Text> </Typography.Text>
} </li>
>
<List.Item.Meta
title={<Typography.Text>{item.remarks || `${kind}变动`}</Typography.Text>}
description={item.createTime || '—'}
/>
<Tag>{item.balance ? `余额 ${item.balance}` : kind}</Tag>
</List.Item>
) )
}} })}
/> </ul>
) )
} }
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Empty, Image, List, Pagination, Statistic, Skeleton, Typography } from 'antd' import { Empty, Pagination, Statistic, Skeleton, Typography } from 'antd'
import { Link } from 'react-router' import { Link } from 'react-router'
import { BookCover } from '@/modules/book/components/BookCover'
import { useMyListQueryState } from '../../query-state' import { useMyListQueryState } from '../../query-state'
import { wrongQuestionBooksOptions } from '../query' import { wrongQuestionBooksOptions } from '../query'
...@@ -21,30 +22,23 @@ export function MyWrongQuestionsView() { ...@@ -21,30 +22,23 @@ export function MyWrongQuestionsView() {
{wrongQuestionsQuery.data && books.length === 0 ? <Empty description='暂无错题' /> : null} {wrongQuestionsQuery.data && books.length === 0 ? <Empty description='暂无错题' /> : null}
{books.length ? ( {books.length ? (
<> <>
<List <ul className='learning-list'>
bordered {books.map((book) => (
dataSource={books} <li className='learning-list__row' key={book.bookId}>
renderItem={(book) => ( <div className='learning-list__meta'>
<List.Item <BookCover url={book.img} alt='' width={52} height={72} />
actions={[ <div className='learning-list__content'>
<Link key='detail' to={`/book/${book.bookId}`}> <Typography.Text strong>{book.bookName}</Typography.Text>
查看图书 <div className='learning-list__description'>{book.authors || '作者未知'}</div>
</Link>, </div>
]} </div>
> <div className='learning-list__actions'>
<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.wrongNum} /> <Statistic title='错题' value={book.wrongNum} />
</List.Item> <Link to={`/book/${book.bookId}`}>查看图书</Link>
)} </div>
/> </li>
))}
</ul>
<div style={{ marginTop: 20, textAlign: 'right' }}> <div style={{ marginTop: 20, textAlign: 'right' }}>
<Pagination <Pagination
current={queryState.state.page} current={queryState.state.page}
......
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Button, Result, Skeleton, Space } from 'antd' import { Button, Result, Skeleton, Space } from 'antd'
import { useEffect, useRef } from 'react' import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router' import { useSearchParams } from 'react-router'
import { getPaymentStatus } from '../api' import { getPaymentStatus } from '../api'
...@@ -11,16 +11,18 @@ function orderNumberFromSearch(searchParams: URLSearchParams) { ...@@ -11,16 +11,18 @@ function orderNumberFromSearch(searchParams: URLSearchParams) {
export function AlipayReturnView() { export function AlipayReturnView() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const pollingStartedAt = useRef(Date.now()) const [timedOutOrder, setTimedOutOrder] = useState('')
const orderNumber = orderNumberFromSearch(searchParams) const orderNumber = orderNumberFromSearch(searchParams)
const timedOut = timedOutOrder === orderNumber
useEffect(() => {
const timer = window.setTimeout(() => setTimedOutOrder(orderNumber), 60_000)
return () => window.clearTimeout(timer)
}, [orderNumber])
const statusQuery = useQuery({ const statusQuery = useQuery({
queryKey: ['purchase', 'payment-status', orderNumber], queryKey: ['purchase', 'payment-status', orderNumber],
queryFn: () => getPaymentStatus(orderNumber), queryFn: () => getPaymentStatus(orderNumber),
enabled: Boolean(orderNumber), enabled: Boolean(orderNumber),
refetchInterval: (query) => refetchInterval: (query) => (query.state.data?.pay_success === 1 || timedOut ? false : 2000),
query.state.data?.pay_success === 1 || Date.now() - pollingStartedAt.current > 60_000
? false
: 2000,
refetchIntervalInBackground: true, refetchIntervalInBackground: true,
}) })
...@@ -40,8 +42,12 @@ export function AlipayReturnView() { ...@@ -40,8 +42,12 @@ export function AlipayReturnView() {
return ( return (
<Result <Result
status='info' status='info'
title='支付结果确认中' title={timedOut ? '尚未确认支付结果' : '支付结果确认中'}
subTitle='支付宝已返回,订单状态还在同步,请稍候。' subTitle={
timedOut
? '自动查询已结束,请重新确认或前往订单详情查看状态。'
: '支付宝已返回,订单状态还在同步,请稍候。'
}
extra={ extra={
<Space> <Space>
<Button onClick={() => void statusQuery.refetch()}>重新确认</Button> <Button onClick={() => void statusQuery.refetch()}>重新确认</Button>
......
...@@ -226,13 +226,12 @@ export async function getSectionContent(bookId: string, chapterId: string): Prom ...@@ -226,13 +226,12 @@ export async function getSectionContent(bookId: string, chapterId: string): Prom
chapter_id: chapterId, chapter_id: chapterId,
}) })
} catch (error) { } catch (error) {
if (error instanceof Error && 'code' in error && error.code === 3001) { if (error instanceof Error && error.message === '您没有购买过此书籍') {
throw new ReaderChapterLockedError() throw new ReaderChapterLockedError()
} }
throw error throw error
} }
const payload = record(body) const payload = record(body)
if (Number(payload.code) === 3001) throw new ReaderChapterLockedError()
return { return {
content: string(payload.content), content: string(payload.content),
previousChapter: toSectionLink(payload.up_chapter), previousChapter: toSectionLink(payload.up_chapter),
...@@ -321,13 +320,21 @@ export function addReadTime(bookId: string, readTypes: 'open' | 'close') { ...@@ -321,13 +320,21 @@ export function addReadTime(bookId: string, readTypes: 'open' | 'close') {
}) })
} }
export function getChapterNotesList(bookId: string, chapterId: string) { export async function getChapterNotesList(bookId: string, chapterId: string) {
return post<unknown>('/api/app/v1/book/Information/getChapterNotesList', { const notes: ReaderNote[] = []
page: 1, let page = 1
while (true) {
const data = await post<unknown>('/api/app/v1/book/Information/getChapterNotesList', {
page,
page_size: 100, page_size: 100,
book_id: bookId, book_id: bookId,
chapter_id: chapterId, chapter_id: chapterId,
}).then((data) => list(data).map(toNote)) })
const items = list(data)
notes.push(...items.map(toNote))
if (items.length < 100) return notes
page++
}
} }
export function addNotes( export function addNotes(
...@@ -377,9 +384,9 @@ export function delNotes(bookId: string, noteId: string) { ...@@ -377,9 +384,9 @@ export function delNotes(bookId: string, noteId: string) {
}) })
} }
export function getChapterCommentList(bookId: string, chapterId: string) { export function getChapterCommentList(bookId: string, chapterId: string, page = 1) {
return post<unknown>('/api/app/v1/book/Information/getChapterCommentList', { return post<unknown>('/api/app/v1/book/Information/getChapterCommentList', {
page: 1, page,
page_size: 100, page_size: 100,
book_id: bookId, book_id: bookId,
chapter_id: chapterId, chapter_id: chapterId,
......
...@@ -8,14 +8,13 @@ import { ...@@ -8,14 +8,13 @@ import {
MenuFoldOutlined, MenuFoldOutlined,
PlusOutlined, PlusOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
Avatar, Avatar,
Button, Button,
Empty, Empty,
Form, Form,
Input, Input,
List,
Modal, Modal,
Popconfirm, Popconfirm,
Segmented, Segmented,
...@@ -81,12 +80,9 @@ function NoteList({ ...@@ -81,12 +80,9 @@ function NoteList({
if (!notes.length) if (!notes.length)
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description='本章节还没有笔记' /> return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description='本章节还没有笔记' />
return ( return (
<List <ul className='reader-note-list'>
className='reader-note-list' {notes.map((note) => (
dataSource={notes} <li className='reader-note' key={note.id}>
split={false}
renderItem={(note) => (
<List.Item className='reader-note'>
<article className='reader-note__body'> <article className='reader-note__body'>
<Space className='reader-note__actions' size={2}> <Space className='reader-note__actions' size={2}>
{note.type === 3 ? ( {note.type === 3 ? (
...@@ -157,9 +153,9 @@ function NoteList({ ...@@ -157,9 +153,9 @@ function NoteList({
</Typography.Text> </Typography.Text>
) : null} ) : null}
</article> </article>
</List.Item> </li>
)} ))}
/> </ul>
) )
} }
...@@ -261,7 +257,8 @@ export function ReaderSidePanel({ ...@@ -261,7 +257,8 @@ export function ReaderSidePanel({
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const notesQuery = useQuery(readerNotesOptions(bookId, chapterId)) const notesQuery = useQuery(readerNotesOptions(bookId, chapterId))
const commentsQuery = useQuery(readerCommentsOptions(bookId, chapterId)) const commentsQuery = useInfiniteQuery(readerCommentsOptions(bookId, chapterId))
const comments = commentsQuery.data?.pages.flat() ?? []
const [noteForm] = Form.useForm<NoteFormValues>() const [noteForm] = Form.useForm<NoteFormValues>()
const [commentForm] = Form.useForm<CommentFormValues>() const [commentForm] = Form.useForm<CommentFormValues>()
const [noteModalOpen, setNoteModalOpen] = useState(false) const [noteModalOpen, setNoteModalOpen] = useState(false)
...@@ -446,10 +443,10 @@ export function ReaderSidePanel({ ...@@ -446,10 +443,10 @@ export function ReaderSidePanel({
</Tooltip> </Tooltip>
</div> </div>
{commentsQuery.isPending ? <Skeleton active avatar paragraph={{ rows: 8 }} /> : null} {commentsQuery.isPending ? <Skeleton active avatar paragraph={{ rows: 8 }} /> : null}
{commentsQuery.data?.length === 0 ? ( {commentsQuery.data && comments.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description='还没有章节讨论' /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description='还没有章节讨论' />
) : null} ) : null}
{commentsQuery.data?.map((comment) => ( {comments.map((comment) => (
<CommentItem <CommentItem
key={comment.id} key={comment.id}
comment={comment} comment={comment}
...@@ -461,6 +458,15 @@ export function ReaderSidePanel({ ...@@ -461,6 +458,15 @@ export function ReaderSidePanel({
} }
/> />
))} ))}
{commentsQuery.hasNextPage ? (
<Button
block
loading={commentsQuery.isFetchingNextPage}
onClick={() => void commentsQuery.fetchNextPage()}
>
加载更多讨论
</Button>
) : null}
</> </>
)} )}
</div> </div>
......
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs' import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
const isCompactScreen =
typeof window !== 'undefined' && window.matchMedia('(max-width: 760px)').matches
export const readerSearchParams = { export const readerSearchParams = {
anchor: parseAsString.withDefault('0'), anchor: parseAsString.withDefault('0'),
catalogueCollapsed: parseAsBoolean.withDefault(false), catalogueCollapsed: parseAsBoolean.withDefault(isCompactScreen),
panelCollapsed: parseAsBoolean.withDefault(false), panelCollapsed: parseAsBoolean.withDefault(true),
} }
export interface ReaderQueryState { export interface ReaderQueryState {
......
import { queryOptions } from '@tanstack/react-query' import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'
import { import {
getChapterCommentList, getChapterCommentList,
getChapterNotesList, getChapterNotesList,
...@@ -25,9 +25,11 @@ export function readerNotesOptions(bookId: string, chapterId: string, enabled = ...@@ -25,9 +25,11 @@ export function readerNotesOptions(bookId: string, chapterId: string, enabled =
} }
export function readerCommentsOptions(bookId: string, chapterId: string, enabled = true) { export function readerCommentsOptions(bookId: string, chapterId: string, enabled = true) {
return queryOptions({ return infiniteQueryOptions({
queryKey: readerKeys.comments(bookId, chapterId), queryKey: readerKeys.comments(bookId, chapterId),
queryFn: () => getChapterCommentList(bookId, chapterId), queryFn: ({ pageParam }) => getChapterCommentList(bookId, chapterId, pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage, pages) => (lastPage.length === 100 ? pages.length + 1 : undefined),
enabled: Boolean(bookId && chapterId && enabled), enabled: Boolean(bookId && chapterId && enabled),
}) })
} }
......
.reader-view { .reader-view {
--reader-header-height: 72px;
--reader-catalogue-width: 224px;
--reader-panel-width: 288px;
--reader-border: #e5e6eb; --reader-border: #e5e6eb;
--reader-nav: #f7f8fa; --reader-nav: #f7f8fa;
--reader-primary: var(--main-color, #aa1941); --reader-primary: var(--main-color, #aa1941);
min-width: 1024px; min-width: 0;
min-height: 100vh; min-height: 100vh;
background: #fff; background: #fff;
color: #1f2329; color: #1f2329;
} }
.reader-view--catalogue-collapsed {
--reader-catalogue-width: 48px;
}
.reader-view--panel-collapsed {
--reader-panel-width: 48px;
}
.reader-view__header { .reader-view__header {
position: sticky; position: sticky;
z-index: 20; z-index: 20;
top: 0; top: 0;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: grid;
width: 100%;
grid-template-columns: var(--reader-catalogue-width) minmax(0, 1fr) var(--reader-panel-width);
align-items: center; align-items: center;
height: 64px; height: var(--reader-header-height);
background: #fff; background: #fff;
border-bottom: 1px solid var(--reader-border); border-bottom: 1px solid var(--reader-border);
} }
...@@ -30,11 +43,12 @@ ...@@ -30,11 +43,12 @@
} }
.reader-view__header-title { .reader-view__header-title {
display: flex; display: flex;
grid-column: 2;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding-right: 24px; padding-inline: 40px;
text-align: left; text-align: left;
} }
.reader-view__header-title h4 { .reader-view__header-title h4 {
...@@ -42,11 +56,20 @@ ...@@ -42,11 +56,20 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.reader-view__book-title {
overflow: hidden;
max-width: min(70vw, 520px);
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 1.25;
}
.reader-view__header-left { .reader-view__header-left {
display: flex; display: flex;
grid-column: 1;
height: 100%; height: 100%;
align-items: center; align-items: center;
padding: 0 8px 0 16px; padding: 0 12px;
} }
.reader-view__back { .reader-view__back {
width: 28px; width: 28px;
...@@ -61,27 +84,19 @@ ...@@ -61,27 +84,19 @@
} }
.reader-view__layout { .reader-view__layout {
display: grid; display: grid;
grid-template-columns: 260px minmax(520px, 1fr) 320px; width: 100%;
min-height: calc(100vh - 64px); grid-template-columns: var(--reader-catalogue-width) minmax(0, 1fr) var(--reader-panel-width);
} min-height: calc(100vh - var(--reader-header-height));
margin-inline: auto;
.reader-view__layout--catalogue-collapsed {
grid-template-columns: 48px minmax(520px, 1fr) 320px;
}
.reader-view__layout--panel-collapsed {
grid-template-columns: 260px minmax(520px, 1fr) 48px;
}
.reader-view__layout--catalogue-collapsed.reader-view__layout--panel-collapsed {
grid-template-columns: 48px minmax(520px, 1fr) 48px;
} }
.reader-view__catalogue, .reader-view__catalogue,
.reader-view__panel { .reader-view__panel {
position: fixed; position: fixed;
z-index: 10; z-index: 10;
top: 64px; top: var(--reader-header-height);
box-sizing: border-box; box-sizing: border-box;
height: calc(100vh - 64px); height: calc(100vh - var(--reader-header-height));
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-gutter: stable; scrollbar-gutter: stable;
...@@ -89,7 +104,7 @@ ...@@ -89,7 +104,7 @@
.reader-view__catalogue { .reader-view__catalogue {
left: 0; left: 0;
width: 260px; width: var(--reader-catalogue-width);
padding: 12px 0 24px; padding: 12px 0 24px;
background: var(--reader-nav); background: var(--reader-nav);
border-right: 1px solid var(--reader-border); border-right: 1px solid var(--reader-border);
...@@ -97,22 +112,20 @@ ...@@ -97,22 +112,20 @@
.reader-view__panel { .reader-view__panel {
right: 0; right: 0;
width: 320px; width: var(--reader-panel-width);
padding: 12px 0 24px; padding: 12px 0 24px;
background: #fff; background: #fff;
border-left: 1px solid var(--reader-border); border-left: 1px solid var(--reader-border);
} }
.reader-view__layout--catalogue-collapsed .reader-view__catalogue { .reader-view--catalogue-collapsed .reader-view__catalogue {
width: 48px;
padding-inline: 7px; padding-inline: 7px;
} }
.reader-view__layout--panel-collapsed .reader-view__panel { .reader-view--panel-collapsed .reader-view__panel {
width: 48px;
padding-inline: 7px; padding-inline: 7px;
} }
.reader-view__layout--catalogue-collapsed .reader-view__catalogue .reader-view__side-header, .reader-view--catalogue-collapsed .reader-view__catalogue .reader-view__side-header,
.reader-view__layout--panel-collapsed .reader-view__panel .reader-view__side-header { .reader-view--panel-collapsed .reader-view__panel .reader-view__side-header {
justify-content: center; justify-content: center;
margin-inline: 0; margin-inline: 0;
} }
...@@ -170,7 +183,7 @@ ...@@ -170,7 +183,7 @@
grid-column: 2; grid-column: 2;
position: relative; position: relative;
min-width: 0; min-width: 0;
padding: 0 48px 64px; padding: 0 40px 64px;
background: #fff; background: #fff;
} }
...@@ -211,6 +224,9 @@ ...@@ -211,6 +224,9 @@
margin-bottom: 12px; margin-bottom: 12px;
} }
.reader-note-list { .reader-note-list {
margin: 0;
padding: 0;
list-style: none;
border: 0; border: 0;
} }
.reader-note-list .reader-note { .reader-note-list .reader-note {
...@@ -301,7 +317,7 @@ ...@@ -301,7 +317,7 @@
.reader-content { .reader-content {
display: grid; display: grid;
gap: 14px; gap: 14px;
width: min(900px, 100%); width: min(960px, 100%);
margin: 0 auto; margin: 0 auto;
} }
...@@ -310,7 +326,7 @@ ...@@ -310,7 +326,7 @@
display: block; display: block;
width: 100%; width: 100%;
min-height: 720px; min-height: 720px;
padding: 48px 20px 96px; padding: 44px 16px 88px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
background: #fff; background: #fff;
color: #1f2329; color: #1f2329;
...@@ -517,23 +533,90 @@ ...@@ -517,23 +533,90 @@
} }
@media (max-width: 1240px) { @media (max-width: 1240px) {
.reader-view {
--reader-catalogue-width: 208px;
--reader-panel-width: 260px;
}
.reader-view--catalogue-collapsed {
--reader-catalogue-width: 48px;
}
.reader-view--panel-collapsed {
--reader-panel-width: 48px;
}
.reader-view__layout { .reader-view__layout {
grid-template-columns: 240px minmax(520px, 1fr) 280px; width: 100%;
grid-template-columns: var(--reader-catalogue-width) minmax(0, 1fr) var(--reader-panel-width);
}
.reader-view__catalogue {
width: var(--reader-catalogue-width);
}
.reader-view__panel {
width: var(--reader-panel-width);
}
.reader-view__body {
padding-inline: 24px;
}
}
@media (max-width: 760px) {
.reader-view {
--reader-header-height: 64px;
}
.reader-view__header {
grid-template-columns: 48px minmax(0, 1fr);
} }
.reader-view__layout--catalogue-collapsed {
grid-template-columns: 48px minmax(520px, 1fr) 280px; .reader-view__header-left {
grid-column: 1;
padding-inline: 8px;
}
.reader-view__header-title {
grid-column: 2;
padding-inline: 12px;
}
.reader-view__layout {
display: block;
width: 100%;
} }
.reader-view__layout--panel-collapsed {
grid-template-columns: 240px minmax(520px, 1fr) 48px; .reader-view__catalogue,
.reader-view__panel {
z-index: 30;
width: min(300px, calc(100vw - 24px));
box-shadow: 0 12px 32px rgb(20 25 32 / 16%);
} }
.reader-view__catalogue { .reader-view__catalogue {
width: 240px; left: 0;
} }
.reader-view__panel { .reader-view__panel {
width: 280px; right: 0;
}
.reader-view--catalogue-collapsed .reader-view__catalogue {
width: 48px;
}
.reader-view--panel-collapsed .reader-view__panel {
width: 48px;
} }
.reader-view__body { .reader-view__body {
padding-inline: 32px; padding: 0 20px 48px;
}
.reader-content__chapter {
min-height: calc(100vh - 112px);
padding: 30px 8px 64px;
font-size: 17px;
line-height: 1.9;
} }
} }
......
差异被折叠。
差异被折叠。
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论