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

chore: update

上级 34919163
......@@ -19,7 +19,6 @@
"@chuangkit/chuangkit-design": "^2.0.8",
"@ezijing/ai-react": "^1.0.40",
"@fortaine/fetch-event-source": "^3.0.6",
"@reduxjs/toolkit": "^1.9.7",
"@remixicon/react": "^4.9.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-react": "^1.0.6",
......@@ -30,7 +29,6 @@
"dayjs": "^1.11.11",
"easy-formula-editor": "^0.0.2-alpha.1",
"echarts": "^5.4.3",
"evit-gm-crypt": "^1.0.1",
"file-saver": "^2.0.5",
"highlight.js": "^11.11.1",
"html-docx-js-typescript": "^0.1.5",
......@@ -38,17 +36,14 @@
"js-cookie": "^3.0.5",
"js-md5": "^0.8.3",
"lodash-es": "^4.17.21",
"lucide-react": "^0.474.0",
"markdown-it": "^14.1.0",
"mathjax": "3.2.2",
"nanoid": "^3.3.8",
"qs": "^6.11.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-redux": "^8.1.3",
"react-use": "^17.6.0",
"redux-persist": "^6.0.0",
"react-router": "^7.18.1",
"snabbdom": "^3.6.2",
"zustand": "^4.5.5"
},
......@@ -63,7 +58,6 @@
"less": "^4.2.0",
"oxfmt": "^0.59.0",
"oxlint": "^1.74.0",
"react-router": "^7.18.1",
"typescript": "^7.0.2",
"vite": "^8.1.5",
"vite-plugin-mkcert": "^1.17.10",
......
......@@ -50,14 +50,14 @@ export function AppList<Row extends object, Filters extends object = Record<stri
}
return (
<div className='app-list'>
<div className="app-list">
{filters ? (
<section className='app-list__filters' aria-label='筛选条件'>
<div className='app-list__filter-main'>
<div className='app-list__filter-items'>
<section className="app-list__filters" aria-label="筛选条件">
<div className="app-list__filter-main">
<div className="app-list__filter-items">
{filters.items.map((item) => (
<div className='app-list__filter-item' key={String(item.name)}>
<span className='app-list__filter-label'>{item.label}</span>
<div className="app-list__filter-item" key={String(item.name)}>
<span className="app-list__filter-label">{item.label}</span>
{item.render({
value: filters.value[item.name],
onChange: (value) => updateFilter(item, value),
......@@ -65,8 +65,8 @@ export function AppList<Row extends object, Filters extends object = Record<stri
</div>
))}
</div>
<Space wrap className='app-list__filter-actions'>
<Button type='primary' icon={<SearchOutlined />} onClick={filters.onSearch}>
<Space wrap className="app-list__filter-actions">
<Button type="primary" icon={<SearchOutlined />} onClick={filters.onSearch}>
搜索
</Button>
<Button icon={<ReloadOutlined />} onClick={filters.onReset}>
......@@ -74,21 +74,21 @@ export function AppList<Row extends object, Filters extends object = Record<stri
</Button>
</Space>
</div>
{toolbar ? <div className='app-list__toolbar'>{toolbar}</div> : null}
{toolbar ? <div className="app-list__toolbar">{toolbar}</div> : null}
</section>
) : null}
{!filters && toolbar ? <div className='app-list__toolbar'>{toolbar}</div> : null}
{!filters && toolbar ? <div className="app-list__toolbar">{toolbar}</div> : null}
{hasRefreshError ? (
<Alert
className='app-list__error'
type='error'
className="app-list__error"
type="error"
showIcon
message={data.error?.message}
action={
onRetry ? (
<Button size='small' danger onClick={onRetry}>
<Button size="small" danger onClick={onRetry}>
重试
</Button>
) : null
......@@ -97,10 +97,10 @@ export function AppList<Row extends object, Filters extends object = Record<stri
) : null}
{hasBlockingError ? (
<div className='app-list__blocking-error' role='alert'>
<div className="app-list__blocking-error" role="alert">
<Empty description={data.error?.message}>
{onRetry ? (
<Button type='primary' danger onClick={onRetry}>
<Button type="primary" danger onClick={onRetry}>
重试
</Button>
) : null}
......@@ -115,8 +115,7 @@ export function AppList<Row extends object, Filters extends object = Record<stri
loading={data.loading}
pagination={tablePagination}
rowSelection={selection}
scroll={{ x: 'max-content' }}
locale={{ ...tableProps?.locale, emptyText: <Empty description='暂无数据' /> }}
locale={{ ...tableProps?.locale, emptyText: <Empty description="暂无数据" /> }}
/>
)}
</div>
......
......@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
const shellPath = resolve(
process.cwd(),
'src/common/wangeditor-customer/shell/WangEditorCustomer.jsx',
'src/components/editor/wangeditor-customer/shell/WangEditorCustomer.jsx',
)
const shellSource = readFileSync(shellPath, 'utf8')
......
......@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { Modal, Input, Button, Flex, Spin, Form, App } from 'antd'
import { useAIEdit } from '@/components/editor/hooks/useBaiduAI'
import { addType, addQuestion } from '@/components/editor/api/ai'
import store from '@/store/index'
import { useUserStore } from '@/stores/userStore'
const { TextArea } = Input
......@@ -142,15 +142,15 @@ export default function AIQuestionModal({ editor, action }) {
return result
}
const { user } = store.getState()
const treeChapter = useUserStore((state) => state.treeChapter)
// 提交
const handlePrimary = async () => {
const { data } = await addType({
name: `${user.treeChapter.book_name}/${user.treeChapter.chapter_name}`,
name: `${treeChapter.book_name}/${treeChapter.chapter_name}`,
})
const res = await addQuestion({
...question,
book_id: user.treeChapter.book_id,
book_id: treeChapter.book_id,
type_id: data.type_id,
question_style: action,
question_list: JSON.stringify(question.question_list),
......
......@@ -15,7 +15,7 @@ const { TextArea } = Input
import './search.less'
import { usePaper } from '@/components/editor/hooks/useWenku'
import { useCopyToClipboard } from 'react-use'
import { CircleEllipsis } from 'lucide-react'
import { RiMore2Line } from '@remixicon/react'
export default function AIModal() {
const [isModalOpen, setIsModalOpen] = useState(true)
......@@ -155,7 +155,7 @@ export default function AIModal() {
>
<Button
type='text'
icon={<CircleEllipsis color='#222' size={20} />}
icon={<RiMore2Line color='#222' size={20} />}
></Button>
</Dropdown>
)}
......
import { useEffect } from 'react'
import { Modal } from 'antd'
import { useDispatch } from 'react-redux'
import { setPracticeRandom } from '@/store/modules/editor'
import { useEditorStore } from '@/stores/editorStore'
import PracticeInsertDialog from './PracticeInsertDialog'
import PracticeSettingsDialog from './PracticeSettingsDialog'
......@@ -34,17 +33,15 @@ export function PracticeInsertFeatureDialog({ open, onClose, runtime }) {
}
export function PracticeSettingsFeatureDialog({ open, onClose, payload, runtime }) {
const dispatch = useDispatch()
const setPracticeRandom = useEditorStore((state) => state.setPracticeRandom)
useEffect(() => {
dispatch(
setPracticeRandom({
practiceNum: payload.practiceNum,
practiceTitle: payload.title,
practiceTheme: payload.theme,
}),
)
return () => dispatch(setPracticeRandom({ practiceNum: null, practiceTitle: null }))
}, [dispatch, payload])
setPracticeRandom({
practiceNum: payload.practiceNum,
practiceTitle: payload.title,
practiceTheme: payload.theme,
})
return () => setPracticeRandom({ practiceNum: null, practiceTitle: null })
}, [payload, setPracticeRandom])
return (
<Modal
......
......@@ -4,13 +4,12 @@ import { Divider, Input, Space, Button, Form, ColorPicker } from 'antd'
import { SlateTransforms, SlateEditor, SlateElement } from '@wangeditor/editor'
import { removeSelectedEmptyParagraph } from '../../shared/editor-operations'
import { setPracticeRandom } from '@/store/modules/editor'
import { useDispatch } from 'react-redux'
import { useEditorStore } from '@/stores/editorStore'
import '../../index.less'
const PracticeModal = (props, ref) => {
const { bookId, chapterId, setPracticeOpenVisible } = props
const dispatch = useDispatch()
const setPracticeRandom = useEditorStore((state) => state.setPracticeRandom)
const { editor } = props
const [form] = Form.useForm()
const [themeValue, setThemeValue] = useState('#ab1941')
......@@ -44,7 +43,7 @@ const PracticeModal = (props, ref) => {
})
setNodes(nodeEntries)
dispatch(setPracticeRandom({ practiceNum, practiceTitle }))
setPracticeRandom({ practiceNum, practiceTitle })
}
const onFinish = (values) => {
......
import React, { useState, useEffect, forwardRef, useImperativeHandle, useRef } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import {
Modal,
Row,
......@@ -15,7 +14,7 @@ import {
ColorPicker,
} from 'antd'
import { CloseOutlined } from '@ant-design/icons'
import { setPracticeRandom } from '@/store/modules/editor'
import { useEditorStore } from '@/stores/editorStore'
import { DomEditor, SlateEditor, SlateTransforms, SlateElement } from '@wangeditor/editor'
import ListTopic from './components/list'
......@@ -30,8 +29,10 @@ import './styles.less'
const PracticeSettingModal = (props, ref) => {
const { editor, chapterId, bookId, closePanel } = props
const dispatch = useDispatch()
const { practiceRandom, practiceTitle, practiceTheme } = useSelector((state) => state.editor)
const practiceRandom = useEditorStore((state) => state.practiceRandom)
const practiceTitle = useEditorStore((state) => state.practiceTitle)
const practiceTheme = useEditorStore((state) => state.practiceTheme)
const setPracticeRandom = useEditorStore((state) => state.setPracticeRandom)
const [themeValue, setThemeValue] = useState(practiceTheme)
const listRef = useRef()
......@@ -140,9 +141,7 @@ const PracticeSettingModal = (props, ref) => {
{ ...node, title: practiceTitleValue, theme: themeValue },
{ at: path },
)
dispatch(
setPracticeRandom({ practiceNum: node.practiceNum, practiceTitle: practiceTitleValue }),
)
setPracticeRandom({ practiceNum: node.practiceNum, practiceTitle: practiceTitleValue })
setPracticeTitleVisible(false)
return false
}
......
......@@ -2,7 +2,6 @@ import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'rea
import { Form, Space, Input, Select, Button, Spin, Row, Col, Checkbox, Radio } from 'antd'
import { MinusOutlined, PlusOutlined } from '@ant-design/icons'
import { useSelector } from 'react-redux'
import { Editor, Toolbar } from '@wangeditor/editor-for-react'
import { DomEditor } from '@wangeditor/editor'
......@@ -13,9 +12,6 @@ import { values } from 'lodash-es'
const CustomerTopic = (props, ref) => {
const { getTopList, bookNameIdData, setPracticeStatus, bookId, setCustomerType } = props
// 获取操作权限
const { operationPermissionsList } = useSelector((state) => state.user)
useImperativeHandle(ref, () => {
return {}
})
......
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react'
import React, { useState, useMemo, forwardRef, useImperativeHandle } from 'react'
import { Button, Popconfirm, message } from 'antd'
import { CloseOutlined } from '@ant-design/icons'
import { useDispatch } from 'react-redux'
import { setPracticeRandom } from '@/store/modules/editor'
import { useEditorStore } from '@/stores/editorStore'
import TopicItem from './topicItem'
import { addChapterTopic, getChapterTopic } from '../../../shared/request'
......@@ -19,7 +18,7 @@ const InsertTopic = (props, ref) => {
chapterId,
bookId,
} = props
const dispatch = useDispatch()
const setPracticeRandom = useEditorStore((state) => state.setPracticeRandom)
useImperativeHandle(ref, () => {
return {}
......@@ -27,11 +26,19 @@ const InsertTopic = (props, ref) => {
// 插入的题库
const [btnLoading, setBtnLoading] = useState(false)
const [radioList, setRadioList] = useState([])
const [checkboxList, setCheckboxList] = useState([])
const [equalsList, setEqualsList] = useState([])
const [pressList, setPressList] = useState([])
const [textareaList, setTextAreaList] = useState([])
const questionGroups = useMemo(() => {
const groups = { radio: [], checkbox: [], equals: [], press: [], textarea: [] }
const groupByStyle = { 1: 'radio', 2: 'checkbox', 3: 'equals', 4: 'press', 5: 'textarea' }
insertList.forEach((item) => {
const group = groupByStyle[Number(item.question_style)]
if (group) groups[group].push(item)
})
return groups
}, [insertList])
const { radio: radioList, checkbox: checkboxList, equals: equalsList, press: pressList, textarea: textareaList } =
questionGroups
// 删除
const delConfirm = (record) => {
......@@ -54,34 +61,12 @@ const InsertTopic = (props, ref) => {
})
if (data) {
message.success('添加成功!')
dispatch(setPracticeRandom({ practiceNum: '', practiceTitle: '' }))
setPracticeRandom({ practiceNum: '', practiceTitle: '' })
setBtnLoading(false)
closePanel()
}
}
useEffect(() => {
if (insertList.length > 0) {
const t1 = insertList.filter((item) => parseInt(item.question_style) === 1)
const t2 = insertList.filter((item) => parseInt(item.question_style) === 2)
const t3 = insertList.filter((item) => parseInt(item.question_style) === 3)
const t4 = insertList.filter((item) => parseInt(item.question_style) === 4)
const t5 = insertList.filter((item) => parseInt(item.question_style) === 5)
setRadioList(t1)
setCheckboxList(t2)
setEqualsList(t3)
setPressList(t4)
setTextAreaList(t5)
} else {
setRadioList([])
setCheckboxList([])
setEqualsList([])
setPressList([])
setTextAreaList([])
}
}, [insertList])
return (
<div className='practice-insert-container'>
<div className='practice-insert-topic list-topic-box'>
......
......@@ -788,7 +788,7 @@ const PreviewScreen = (props, ref) => {
placement='left'
classNames={{ header: 'priview-drawer-header', body: 'priview-drawer-body' }}
width='200'
title='章节列表'
title='章节目录'
rootClassName='priview-drawer-container'
>
<Tree
......
......@@ -4,8 +4,13 @@
display: flex;
justify-content: space-between;
height: 100%;
min-height: 0;
.editor-content-container {
display: flex;
flex-direction: column;
box-sizing: border-box;
height: 100%;
min-height: 0;
overflow-y: hidden;
flex: 3;
padding: 10px;
......@@ -31,13 +36,20 @@
margin: 0 !important;
}
.ant-spin-nested-loading,
.ant-spin-container,
.w-e-text-container {
.ant-spin-container {
height: 100%;
min-height: 0;
}
.ant-spin-container > div,
#w-e-textarea-1 {
min-height: 99%;
.w-e-text-container {
flex: 1;
min-height: 0;
height: auto;
[data-w-e-textarea='true'] {
box-sizing: border-box;
min-height: 100%;
}
}
.w-e-text-container [data-slate-editor] p:not(:empty) {
margin: 15px 0;
......
import { useState, useEffect, useCallback } from 'react'
import { useSelector } from 'react-redux'
import { useUserStore } from '@/stores/userStore'
import { Spin, Empty } from 'antd'
import { useWebSocket, useInterval } from 'ahooks'
import { getDesignList, getSourceImage } from '@/components/editor/api/chuangkit'
......@@ -8,7 +8,7 @@ import { uploadFileByUrl } from '@/utils/oss'
export default function OnlineImageList({ imgUrl, onChange }) {
const [isLoading, setLoading] = useState(false)
const [list, setList] = useState([])
const { userInfo } = useSelector((state) => state.user)
const userInfo = useUserStore((state) => state.userInfo)
const fetchDesignList = useCallback(async () => {
setLoading(true)
......
import { Button } from 'antd'
import { Toolbar } from '@wangeditor/editor-for-react'
const tabs = [
{ key: 'text', title: '文本设置' },
{ key: 'style', title: '样式模版' },
]
export const EditorSidebar = ({
editor,
toolbarConfig,
......@@ -14,10 +9,17 @@ export const EditorSidebar = ({
onTabChange,
colorList,
onApplyColor,
reviewCommentPanel,
}) => (
<div className='menu-tabs-key'>
<div className='tabs'>
{tabs.map((item) => (
{[
{ key: 'text', title: '文本设置' },
{ key: 'style', title: '样式模版' },
...(reviewCommentPanel
? [{ key: 'comment', title: '评论' }]
: []),
].map((item) => (
<div
className={`tabs-item ${item.key === tabKey ? 'active' : ''}`}
key={item.key}
......@@ -57,6 +59,11 @@ export const EditorSidebar = ({
</ul>
</div>
)}
{reviewCommentPanel && (
<div className='editor-review-comment-tab' hidden={tabKey !== 'comment'}>
{reviewCommentPanel}
</div>
)}
</div>
</div>
)
......@@ -3,7 +3,7 @@ import { Button, Divider, Space, Modal } from 'antd'
import { EyeOutlined, SaveOutlined, HistoryOutlined } from '@ant-design/icons'
import dayjs from 'dayjs'
import { useSelector } from 'react-redux'
import { useEditorStore } from '@/stores/editorStore'
// import formulaEditor from 'easy-formula-editor'
import '../shared/jax.less'
import { Boot } from '@wangeditor/editor'
......@@ -44,10 +44,11 @@ const WangEditorCustomer = (props, ref) => {
nowTitle,
disabled,
isView = false,
reviewCommentPanel,
} = props
// 自动保存时间
const { autosaveTime } = useSelector((state) => state.editor)
const autosaveTime = useEditorStore((state) => state.autosaveTime)
const toolbarRef = useRef()
......@@ -159,7 +160,7 @@ const WangEditorCustomer = (props, ref) => {
const tabKeyChange = (key) => {
setTabKey(key)
editor?.focus()
if (key !== 'comment') editor?.focus()
}
// 预览
......@@ -232,7 +233,7 @@ const WangEditorCustomer = (props, ref) => {
value={content}
onCreated={setEditor}
mode='default'
style={{ height: 'calc(100vh - 250px)', overflowY: 'hidden' }}
style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}
/>
</div>
{!isView && (
......@@ -244,6 +245,7 @@ const WangEditorCustomer = (props, ref) => {
onTabChange={tabKeyChange}
colorList={colorList}
onApplyColor={setColor}
reviewCommentPanel={reviewCommentPanel}
/>
)}
<EditorDialogHost
......
import { useEffect, useCallback } from 'react'
import { Button, Flex, Dropdown } from 'antd'
import { UserOutlined } from '@ant-design/icons'
import { useDispatch, useSelector } from 'react-redux'
import { setUserInfo } from '@/store/modules/user'
import { useUserStore } from '@/stores/userStore'
import { getUser } from '@/api/base'
import { Link } from 'react-router'
function User() {
const dispatch = useDispatch()
const { userInfo } = useSelector((state) => state.user)
const userInfo = useUserStore((state) => state.userInfo)
const setUserInfo = useUserStore((state) => state.setUserInfo)
const fetchUser = useCallback(async () => {
const { data } = await getUser()
dispatch(setUserInfo(data))
}, [dispatch])
setUserInfo(data)
}, [setUserInfo])
useEffect(() => {
fetchUser()
......
import { useState, useEffect, Suspense } from 'react'
import { Button, Layout, Flex, Tooltip } from 'antd'
import { Outlet, Link, useLocation } from 'react-router'
import { useDispatch } from 'react-redux'
import {
RiAdvertisementLine,
RiAppStoreLine,
......@@ -39,7 +38,7 @@ import {
RiLayoutGridLine,
RiPaletteLine,
} from '@remixicon/react'
import { setOperationPermissionsList } from '@/store/modules/user'
import { useUserStore } from '@/stores/userStore'
import EditorIconSprite from '@/components/EditorIconSprite'
import Menu from './components/menu'
......@@ -169,7 +168,7 @@ const pageHeaderOverrides = {
}
export default function AppLayout() {
const dispatch = useDispatch()
const setOperationPermissionsList = useUserStore((state) => state.setOperationPermissionsList)
const { pathname } = useLocation()
const [menuList, setMenuList] = useState([])
const [collapsed, setCollapsed] = useState(false)
......@@ -191,7 +190,7 @@ export default function AppLayout() {
}
// 获取操作权限内容
if (operation_permissions_list && operation_permissions_list.length) {
dispatch(setOperationPermissionsList(operation_permissions_list))
setOperationPermissionsList(operation_permissions_list)
}
}
......
import { useCallback, useEffect, useRef, useState } from 'react'
interface PagedResult<Row> {
rows: Row[]
total: number
}
interface PagedListState<Row, Filters> {
filters: Filters
appliedFilters: Filters
page: number
pageSize: number
rows: Row[]
total: number
loading: boolean
error: Error | null
}
interface UsePagedListOptions<Row, Filters, Query> {
initialFilters: Filters
buildQuery: (params: { page: number; pageSize: number; filters: Filters }) => Query
request: (query: Query) => Promise<PagedResult<Row>>
shouldReloadOnFilterChange?: (previous: Filters, next: Filters) => boolean
useAppliedFilters?: boolean
autoLoad?: boolean
initialPage?: number
initialPageSize?: number
}
function toError(reason: unknown) {
return reason instanceof Error ? reason : new Error('数据获取失败!')
}
export function usePagedList<Row, Filters, Query>({
initialFilters,
buildQuery,
request,
shouldReloadOnFilterChange,
useAppliedFilters = true,
autoLoad = true,
initialPage = 1,
initialPageSize = 10,
}: UsePagedListOptions<Row, Filters, Query>) {
const [state, setState] = useState<PagedListState<Row, Filters>>({
filters: initialFilters,
appliedFilters: initialFilters,
page: initialPage,
pageSize: initialPageSize,
rows: [],
total: 0,
loading: true,
error: null,
})
const requestIdRef = useRef(0)
const requestRef = useRef(request)
const lastQueryRef = useRef(
buildQuery({ page: initialPage, pageSize: initialPageSize, filters: initialFilters }),
)
useEffect(() => {
requestRef.current = request
}, [request])
const load = useCallback(
async (query: Query) => {
const requestId = ++requestIdRef.current
lastQueryRef.current = query
setState((current) => ({ ...current, loading: true, error: null }))
try {
const result = await requestRef.current(query)
if (requestId !== requestIdRef.current) return
setState((current) => ({ ...current, rows: result.rows, total: result.total }))
} catch (reason) {
if (requestId === requestIdRef.current) {
setState((current) => ({ ...current, error: toError(reason) }))
}
} finally {
if (requestId === requestIdRef.current) {
setState((current) => ({ ...current, loading: false }))
}
}
},
[],
)
const loadFor = useCallback(
(page: number, pageSize: number, filters: Filters) =>
load(buildQuery({ page, pageSize, filters })),
[buildQuery, load],
)
useEffect(() => {
if (!autoLoad) {
setState((current) => (current.loading ? { ...current, loading: false } : current))
return undefined
}
void load(lastQueryRef.current)
return () => {
requestIdRef.current += 1
}
}, [autoLoad, load])
const setFilters = useCallback(
(filters: Filters) => {
const shouldReload = shouldReloadOnFilterChange?.(state.filters, filters)
setState((current) => ({
...current,
filters,
...(shouldReload ? { appliedFilters: filters } : {}),
}))
if (shouldReload) void loadFor(state.page, state.pageSize, filters)
},
[loadFor, shouldReloadOnFilterChange, state.filters, state.page, state.pageSize],
)
const search = useCallback(() => {
setState((current) => ({
...current,
...(useAppliedFilters ? { appliedFilters: state.filters } : {}),
page: initialPage,
}))
void loadFor(initialPage, state.pageSize, state.filters)
}, [initialPage, loadFor, state.filters, state.pageSize, useAppliedFilters])
const reset = useCallback(() => {
setState((current) => ({
...current,
filters: initialFilters,
appliedFilters: initialFilters,
page: initialPage,
}))
void loadFor(initialPage, state.pageSize, initialFilters)
}, [initialFilters, initialPage, loadFor, state.pageSize])
const refresh = useCallback(() => {
if (useAppliedFilters) {
setState((current) => ({ ...current, appliedFilters: state.filters }))
}
void loadFor(state.page, state.pageSize, state.filters)
}, [loadFor, state.filters, state.page, state.pageSize, useAppliedFilters])
const refreshUnfiltered = useCallback(() => {
void loadFor(state.page, state.pageSize, initialFilters)
}, [initialFilters, loadFor, state.page, state.pageSize])
const changePage = useCallback(
(page: number) => {
setState((current) => ({ ...current, page }))
void loadFor(page, state.pageSize, useAppliedFilters ? state.appliedFilters : state.filters)
},
[loadFor, state.appliedFilters, state.filters, state.pageSize, useAppliedFilters],
)
const changePageSize = useCallback(
(pageSize: number) => {
setState((current) => ({ ...current, page: initialPage, pageSize }))
void loadFor(
initialPage,
pageSize,
useAppliedFilters ? state.appliedFilters : state.filters,
)
},
[initialPage, loadFor, state.appliedFilters, state.filters, useAppliedFilters],
)
const retry = useCallback(() => void load(lastQueryRef.current), [load])
const fetchPage = useCallback(
(page: number, pageSize: number, filters: Filters) => {
setState((current) => ({
...current,
filters,
appliedFilters: filters,
page,
pageSize,
}))
return loadFor(page, pageSize, filters)
},
[loadFor],
)
return {
...state,
setFilters,
search,
reset,
refresh,
refreshUnfiltered,
changePage,
changePageSize,
fetchPage,
retry,
}
}
import { useCallback } from 'react'
import { hasPermission, type PermissionValue } from '@/utils/permission'
import { useUserStore } from '@/stores/userStore'
export function usePermission() {
const permissions = useUserStore((state) => state.operationPermissionsList)
return useCallback((value: PermissionValue) => hasPermission(permissions, value), [permissions])
}
......@@ -2,10 +2,6 @@
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import store, { persistor } from '@/store'
import { ConfigProvider, App } from 'antd'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
......@@ -17,16 +13,12 @@ dayjs.locale('zh-cn')
createRoot(document.getElementById('root')).render(
// <StrictMode>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<ConfigProvider locale={zhCN} theme={{ token: { colorPrimary: '#aa1941' } }}>
<App>
<BrowserRouter basename='/admin'>
<MyApp />
</BrowserRouter>
</App>
</ConfigProvider>
</PersistGate>
</Provider>,
<ConfigProvider locale={zhCN} theme={{ token: { colorPrimary: '#aa1941' } }}>
<App>
<BrowserRouter basename='/admin'>
<MyApp />
</BrowserRouter>
</App>
</ConfigProvider>,
// </StrictMode>
)
import { useCallback, useEffect, useRef, useState } from 'react'
import { usePagedList } from '@/hooks/usePagedList'
import { getAdminList, type AdminListResult, type AdminRow } from './api'
import { buildAdminQuery, type AdminFilters, type AdminQuery } from './query'
const INITIAL_PAGE = 1
const INITIAL_PAGE_SIZE = 10
import { getAdminList, type AdminRow } from './api'
import { buildAdminQuery, type AdminFilters } from './query'
function isCleared(previous: AdminFilters, next: AdminFilters) {
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]) as Set<keyof AdminFilters>
......@@ -12,121 +9,10 @@ function isCleared(previous: AdminFilters, next: AdminFilters) {
}
export function useAdminList() {
const [filters, setFilterState] = useState<AdminFilters>({})
const [appliedFilters, setAppliedFilters] = useState<AdminFilters>({})
const [page, setPage] = useState(INITIAL_PAGE)
const [pageSize, setPageSize] = useState(INITIAL_PAGE_SIZE)
const [rows, setRows] = useState<AdminRow[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const requestId = useRef(0)
const lastQuery = useRef<AdminQuery>(
buildAdminQuery({ page: INITIAL_PAGE, pageSize: INITIAL_PAGE_SIZE, filters: {} }),
)
const load = useCallback(async (query: AdminQuery) => {
const currentRequest = ++requestId.current
lastQuery.current = query
setLoading(true)
setError(null)
try {
const result: AdminListResult = await getAdminList(query)
if (currentRequest !== requestId.current) return
setRows(result.rows)
setTotal(result.total)
} catch (reason) {
if (currentRequest === requestId.current) {
setError(reason instanceof Error ? reason : new Error('数据获取失败!'))
}
} finally {
if (currentRequest === requestId.current) setLoading(false)
}
}, [])
const loadFor = useCallback(
(nextPage: number, nextPageSize: number, nextFilters: AdminFilters) =>
load(buildAdminQuery({ page: nextPage, pageSize: nextPageSize, filters: nextFilters })),
[load],
)
useEffect(() => {
void load(lastQuery.current)
return () => {
requestId.current += 1
}
}, [load])
const setFilters = useCallback(
(nextFilters: AdminFilters) => {
const shouldReload = isCleared(filters, nextFilters)
setFilterState(nextFilters)
if (shouldReload) {
setAppliedFilters(nextFilters)
void loadFor(page, pageSize, nextFilters)
}
},
[filters, loadFor, page, pageSize],
)
const search = useCallback(() => {
setAppliedFilters(filters)
setPage(INITIAL_PAGE)
void loadFor(INITIAL_PAGE, pageSize, filters)
}, [filters, loadFor, pageSize])
const reset = useCallback(() => {
const emptyFilters: AdminFilters = {}
setFilterState(emptyFilters)
setAppliedFilters(emptyFilters)
setPage(INITIAL_PAGE)
void loadFor(INITIAL_PAGE, pageSize, emptyFilters)
}, [loadFor, pageSize])
const refresh = useCallback(() => {
setAppliedFilters(filters)
void loadFor(page, pageSize, filters)
}, [filters, loadFor, page, pageSize])
const refreshUnfiltered = useCallback(() => {
void loadFor(page, pageSize, {})
}, [loadFor, page, pageSize])
const changePage = useCallback(
(nextPage: number) => {
setPage(nextPage)
void loadFor(nextPage, pageSize, appliedFilters)
},
[appliedFilters, loadFor, pageSize],
)
const changePageSize = useCallback(
(nextPageSize: number) => {
setPage(INITIAL_PAGE)
setPageSize(nextPageSize)
void loadFor(INITIAL_PAGE, nextPageSize, appliedFilters)
},
[appliedFilters, loadFor],
)
const retry = useCallback(() => void load(lastQuery.current), [load])
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset,
refresh,
refreshUnfiltered,
changePage,
changePageSize,
retry,
}
return usePagedList<AdminRow, AdminFilters, ReturnType<typeof buildAdminQuery>>({
initialFilters: {},
buildQuery: buildAdminQuery,
request: getAdminList,
shouldReloadOnFilterChange: isCleared,
})
}
......@@ -3,9 +3,9 @@ import { PlusOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { AppList, type AppListFilterItem } from '@/components/AppList'
import { usePermission } from '@/hooks/usePermission'
import {
addAdmin,
......@@ -24,24 +24,32 @@ import PasswordDrawer, { type PasswordFormValues } from '../components/PasswordD
import type { AdminFilters } from '../query'
import { useAdminList } from '../useAdminList'
interface RootState {
user: {
operationPermissionsList?: string[]
}
}
const { RangePicker } = DatePicker
interface AdminDialogState {
selectedId: number | null
formOpen: boolean
isAdd: boolean
initialAdmin: Partial<AdminFormValues> | null
passwordOpen: boolean
deleteOpen: boolean
}
export default function AdminListView() {
const list = useAdminList()
const permissions = useSelector((state: RootState) => state.user.operationPermissionsList ?? [])
const checkPermission = usePermission()
const [roles, setRoles] = useState<RoleOption[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [formOpen, setFormOpen] = useState(false)
const [isAdd, setIsAdd] = useState(false)
const [initialAdmin, setInitialAdmin] = useState<Partial<AdminFormValues> | null>(null)
const [passwordOpen, setPasswordOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [dialogState, setDialogState] = useState<AdminDialogState>({
selectedId: null,
formOpen: false,
isAdd: false,
initialAdmin: null,
passwordOpen: false,
deleteOpen: false,
})
const updateDialogState = (next: Partial<AdminDialogState>) =>
setDialogState((state) => ({ ...state, ...next }))
const { selectedId, formOpen, isAdd, initialAdmin, passwordOpen, deleteOpen } = dialogState
useEffect(() => {
let active = true
......@@ -55,15 +63,17 @@ export default function AdminListView() {
const handleEdit = async (id: number) => {
const admin = await getAdminInfo(id)
setSelectedId(id)
setIsAdd(false)
setInitialAdmin({
real_name: admin.real_name,
tel: admin.tel,
role_id: admin.role_id,
validity: dayjs(admin.validity),
updateDialogState({
selectedId: id,
isAdd: false,
initialAdmin: {
real_name: admin.real_name,
tel: admin.tel,
role_id: admin.role_id,
validity: dayjs(admin.validity),
},
formOpen: true,
})
setFormOpen(true)
}
const handleFormSubmit = async (values: AdminFormValues) => {
......@@ -71,7 +81,7 @@ export default function AdminListView() {
const success = isAdd ? await addAdmin(params) : await editAdmin({ ...params, id: selectedId })
if (!success) return
list.refreshUnfiltered()
setFormOpen(false)
updateDialogState({ formOpen: false })
}
const handlePasswordSubmit = async ({ password, re_password }: PasswordFormValues) => {
......@@ -83,14 +93,14 @@ export default function AdminListView() {
})
if (!success) return
list.refreshUnfiltered()
setPasswordOpen(false)
updateDialogState({ passwordOpen: false })
}
const handleDelete = async () => {
if (selectedId === null) return
const success = await deleteAdmin(selectedId)
if (success) list.refreshUnfiltered()
setDeleteOpen(false)
updateDialogState({ deleteOpen: false })
}
const handleStatus = async (checked: boolean, id: number) => {
......@@ -122,7 +132,7 @@ export default function AdminListView() {
<Switch
checked={Number(record.status) === 1}
onChange={(checked) => void handleStatus(checked, record.id)}
disabled={!permissions.includes('/jurisdiction/admin/changeStatus')}
disabled={!checkPermission('/jurisdiction/admin/changeStatus')}
/>
),
},
......@@ -139,24 +149,22 @@ export default function AdminListView() {
dataIndex: 'handle',
render: (_, record) => (
<Space>
{permissions.includes('/jurisdiction/admin/getInfoById') ? (
{checkPermission('/jurisdiction/admin/getInfoById') ? (
<Button onClick={() => void handleEdit(record.id)}>编辑</Button>
) : null}
{permissions.includes('/jurisdiction/admin/editPass') ? (
{checkPermission('/jurisdiction/admin/editPass') ? (
<Button
onClick={() => {
setSelectedId(record.id)
setPasswordOpen(true)
updateDialogState({ selectedId: record.id, passwordOpen: true })
}}
>
修改密码
</Button>
) : null}
{permissions.includes('/jurisdiction/admin/del') ? (
{checkPermission('/jurisdiction/admin/del') ? (
<Button
onClick={() => {
setSelectedId(record.id)
setDeleteOpen(true)
updateDialogState({ selectedId: record.id, deleteOpen: true })
}}
>
删除
......@@ -224,16 +232,18 @@ export default function AdminListView() {
},
]
const toolbar = permissions.includes('/jurisdiction/role/add') ? (
const toolbar = checkPermission('/jurisdiction/role/add') ? (
<Button
type='primary'
ghost
icon={<PlusOutlined />}
onClick={() => {
setSelectedId(null)
setInitialAdmin(null)
setIsAdd(true)
setFormOpen(true)
updateDialogState({
selectedId: null,
initialAdmin: null,
isAdd: true,
formOpen: true,
})
}}
>
添加
......@@ -275,17 +285,17 @@ export default function AdminListView() {
isAdd={isAdd}
initialAdmin={initialAdmin}
roles={roles}
onClose={() => setFormOpen(false)}
onClose={() => updateDialogState({ formOpen: false })}
onSubmit={handleFormSubmit}
/>
<PasswordDrawer
open={passwordOpen}
onClose={() => setPasswordOpen(false)}
onClose={() => updateDialogState({ passwordOpen: false })}
onSubmit={handlePasswordSubmit}
/>
<DeleteAdminModal
open={deleteOpen}
onCancel={() => setDeleteOpen(false)}
onCancel={() => updateDialogState({ deleteOpen: false })}
onConfirm={handleDelete}
/>
</>
......
import { useCallback, useEffect, useRef, useState } from 'react'
import { usePagedList } from '@/hooks/usePagedList'
import { getRoleList, type RoleListResult, type RoleRow } from './api'
import { buildRoleQuery, type RoleFilters, type RoleQuery } from './query'
const INITIAL_PAGE = 1
const INITIAL_PAGE_SIZE = 10
import { getRoleList, type RoleRow } from './api'
import { buildRoleQuery, type RoleFilters } from './query'
function isCleared(previous: RoleFilters, next: RoleFilters) {
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]) as Set<keyof RoleFilters>
......@@ -12,120 +9,10 @@ function isCleared(previous: RoleFilters, next: RoleFilters) {
}
export function useRoleList() {
const [filters, setFilterState] = useState<RoleFilters>({})
const [appliedFilters, setAppliedFilters] = useState<RoleFilters>({})
const [page, setPage] = useState(INITIAL_PAGE)
const [pageSize, setPageSize] = useState(INITIAL_PAGE_SIZE)
const [rows, setRows] = useState<RoleRow[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const requestId = useRef(0)
const lastQuery = useRef<RoleQuery>(
buildRoleQuery({ page: INITIAL_PAGE, pageSize: INITIAL_PAGE_SIZE, filters: {} }),
)
const load = useCallback(async (query: RoleQuery) => {
const currentRequest = ++requestId.current
lastQuery.current = query
setLoading(true)
setError(null)
try {
const result: RoleListResult = await getRoleList(query)
if (currentRequest !== requestId.current) return
setRows(result.rows)
setTotal(result.total)
} catch (reason) {
if (currentRequest === requestId.current) {
setError(reason instanceof Error ? reason : new Error('数据获取失败!'))
}
} finally {
if (currentRequest === requestId.current) setLoading(false)
}
}, [])
const loadFor = useCallback(
(nextPage: number, nextPageSize: number, nextFilters: RoleFilters) =>
load(buildRoleQuery({ page: nextPage, pageSize: nextPageSize, filters: nextFilters })),
[load],
)
useEffect(() => {
void load(lastQuery.current)
return () => {
requestId.current += 1
}
}, [load])
const setFilters = useCallback(
(nextFilters: RoleFilters) => {
const shouldReload = isCleared(filters, nextFilters)
setFilterState(nextFilters)
if (shouldReload) {
setAppliedFilters(nextFilters)
void loadFor(page, pageSize, nextFilters)
}
},
[filters, loadFor, page, pageSize],
)
const search = useCallback(() => {
setAppliedFilters(filters)
setPage(INITIAL_PAGE)
void loadFor(INITIAL_PAGE, pageSize, filters)
}, [filters, loadFor, pageSize])
const reset = useCallback(() => {
const emptyFilters: RoleFilters = {}
setFilterState(emptyFilters)
setAppliedFilters(emptyFilters)
setPage(INITIAL_PAGE)
void loadFor(INITIAL_PAGE, pageSize, emptyFilters)
}, [loadFor, pageSize])
const refresh = useCallback(() => {
setAppliedFilters(filters)
void loadFor(page, pageSize, filters)
}, [filters, loadFor, page, pageSize])
const refreshUnfiltered = useCallback(() => {
void loadFor(page, pageSize, {})
}, [loadFor, page, pageSize])
const changePage = useCallback(
(nextPage: number) => {
setPage(nextPage)
void loadFor(nextPage, pageSize, appliedFilters)
},
[appliedFilters, loadFor, pageSize],
)
const changePageSize = useCallback(
(nextPageSize: number) => {
setPage(INITIAL_PAGE)
setPageSize(nextPageSize)
void loadFor(INITIAL_PAGE, nextPageSize, appliedFilters)
},
[appliedFilters, loadFor],
)
const retry = useCallback(() => void load(lastQuery.current), [load])
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset,
refresh,
refreshUnfiltered,
changePage,
changePageSize,
retry,
}
return usePagedList<RoleRow, RoleFilters, ReturnType<typeof buildRoleQuery>>({
initialFilters: {},
buildQuery: buildRoleQuery,
request: getRoleList,
shouldReloadOnFilterChange: isCleared,
})
}
......@@ -3,9 +3,9 @@ import { PlusOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import type { Key } from 'react'
import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { AppList, type AppListFilterItem } from '@/components/AppList'
import { usePermission } from '@/hooks/usePermission'
import { convertToAntdTreeData } from '@/utils/common.js'
import {
......@@ -25,23 +25,31 @@ import RoleFormDrawer, { type RoleFormValues } from '../components/RoleFormDrawe
import type { RoleFilters } from '../query'
import { useRoleList } from '../useRoleList'
interface RootState {
user: {
operationPermissionsList?: string[]
}
}
const { RangePicker } = DatePicker
interface RoleDialogState {
selectedId: number | null
formOpen: boolean
isAdd: boolean
initialRole: Partial<RoleFormValues> | null
deleteOpen: boolean
permissionOpen: boolean
}
export default function RoleListView() {
const list = useRoleList()
const permissions = useSelector((state: RootState) => state.user.operationPermissionsList ?? [])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [formOpen, setFormOpen] = useState(false)
const [isAdd, setIsAdd] = useState(false)
const [initialRole, setInitialRole] = useState<Partial<RoleFormValues> | null>(null)
const [deleteOpen, setDeleteOpen] = useState(false)
const [permissionOpen, setPermissionOpen] = useState(false)
const checkPermission = usePermission()
const [dialogState, setDialogState] = useState<RoleDialogState>({
selectedId: null,
formOpen: false,
isAdd: false,
initialRole: null,
deleteOpen: false,
permissionOpen: false,
})
const updateDialogState = (next: Partial<RoleDialogState>) =>
setDialogState((state) => ({ ...state, ...next }))
const { selectedId, formOpen, isAdd, initialRole, deleteOpen, permissionOpen } = dialogState
const [permissionTree, setPermissionTree] = useState<PermissionTreeNode[]>([])
const [checkedKeys, setCheckedKeys] = useState<Key[]>([])
......@@ -59,14 +67,16 @@ export default function RoleListView() {
const handleEdit = async (id: number) => {
const role = await getRoleInfo(id)
setSelectedId(id)
setIsAdd(false)
setInitialRole({
name: role.name,
remarks: role.remarks,
type: String(role.type),
updateDialogState({
selectedId: id,
isAdd: false,
initialRole: {
name: role.name,
remarks: role.remarks,
type: String(role.type),
},
formOpen: true,
})
setFormOpen(true)
}
const handleFormSubmit = async (values: RoleFormValues) => {
......@@ -75,14 +85,14 @@ export default function RoleListView() {
const success = isAdd ? await addRole(params) : await editRole({ ...params, id: selectedId })
if (!success) return
list.refreshUnfiltered()
setFormOpen(false)
updateDialogState({ formOpen: false })
}
const handleDelete = async () => {
if (selectedId === null) return
const success = await deleteRole(selectedId)
if (success) list.refreshUnfiltered()
setDeleteOpen(false)
updateDialogState({ deleteOpen: false })
}
const handleStatus = async (checked: boolean, id: number) => {
......@@ -91,8 +101,7 @@ export default function RoleListView() {
}
const handleEmpower = async (id: number) => {
setSelectedId(id)
setPermissionOpen(true)
updateDialogState({ selectedId: id, permissionOpen: true })
const result = await getRolePermissionIds(id)
setCheckedKeys(result)
}
......@@ -102,7 +111,7 @@ export default function RoleListView() {
const success = await setRolePermissions(selectedId, checkedKeys as Array<string | number>)
if (!success) return
list.refreshUnfiltered()
setPermissionOpen(false)
updateDialogState({ permissionOpen: false })
}
const columns: ColumnsType<RoleRow> = [
......@@ -164,7 +173,7 @@ export default function RoleListView() {
<Switch
checked={Number(record.status) === 1}
onChange={(checked) => void handleStatus(checked, record.id)}
disabled={!permissions.includes('/jurisdiction/role/changeStatus')}
disabled={!checkPermission('/jurisdiction/role/changeStatus')}
/>
),
},
......@@ -182,17 +191,16 @@ export default function RoleListView() {
width: 400,
render: (_, record) => (
<Space>
{permissions.includes('/jurisdiction/role/getInfoById') ? (
{checkPermission('/jurisdiction/role/getInfoById') ? (
<Button onClick={() => void handleEdit(record.id)}>编辑</Button>
) : null}
{permissions.includes('/jurisdiction/role/getPowerByRoleId') ? (
{checkPermission('/jurisdiction/role/getPowerByRoleId') ? (
<Button onClick={() => void handleEmpower(record.id)}>授权</Button>
) : null}
{permissions.includes('/jurisdiction/role/del') ? (
{checkPermission('/jurisdiction/role/del') ? (
<Button
onClick={() => {
setSelectedId(record.id)
setDeleteOpen(true)
updateDialogState({ selectedId: record.id, deleteOpen: true })
}}
>
删除
......@@ -231,16 +239,18 @@ export default function RoleListView() {
},
]
const toolbar = permissions.includes('/jurisdiction/role/add') ? (
const toolbar = checkPermission('/jurisdiction/role/add') ? (
<Button
type='primary'
ghost
icon={<PlusOutlined />}
onClick={() => {
setSelectedId(null)
setInitialRole(null)
setIsAdd(true)
setFormOpen(true)
updateDialogState({
selectedId: null,
initialRole: null,
isAdd: true,
formOpen: true,
})
}}
>
添加
......@@ -279,12 +289,12 @@ export default function RoleListView() {
<RoleFormDrawer
open={formOpen}
initialRole={initialRole}
onClose={() => setFormOpen(false)}
onClose={() => updateDialogState({ formOpen: false })}
onSubmit={handleFormSubmit}
/>
<DeleteRoleModal
open={deleteOpen}
onCancel={() => setDeleteOpen(false)}
onCancel={() => updateDialogState({ deleteOpen: false })}
onConfirm={handleDelete}
/>
<PermissionDrawer
......@@ -292,7 +302,7 @@ export default function RoleListView() {
treeData={permissionTree}
checkedKeys={checkedKeys}
onCheckedKeysChange={setCheckedKeys}
onClose={() => setPermissionOpen(false)}
onClose={() => updateDialogState({ permissionOpen: false })}
onSubmit={submitPermissions}
/>
</>
......
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback } from 'react'
import { usePagedList } from '@/hooks/usePagedList'
import {
changePositionStatus,
getPositionList,
......@@ -10,6 +11,7 @@ import { buildAdsenseQuery } from './query'
type AdsenseRequest = (query: Record<string, unknown>) => Promise<AdsenseListResult>
type ChangeStatusRequest = (params: { id: number; status: '0' | '1' }) => Promise<boolean>
const initialFilters: Record<string, never> = {}
export function useAdsenseList({
request = getPositionList,
......@@ -18,92 +20,26 @@ export function useAdsenseList({
request?: AdsenseRequest
changeStatusRequest?: ChangeStatusRequest
} = {}) {
const [rows, setRows] = useState<AdsenseRow[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const requestId = useRef(0)
const lastQuery = useRef(buildAdsenseQuery({ page: 1, pageSize: 10 }))
const load = useCallback(
async (nextPage: number, nextPageSize: number) => {
const query = buildAdsenseQuery({ page: nextPage, pageSize: nextPageSize })
lastQuery.current = query
const id = ++requestId.current
setLoading(true)
setError(null)
try {
const result = await request(query)
if (id === requestId.current) {
setRows(result.rows)
setTotal(result.total)
}
} catch (reason) {
if (id === requestId.current) {
setError(reason instanceof Error ? reason : new Error('数据获取失败!'))
}
} finally {
if (id === requestId.current) {
setLoading(false)
}
}
},
[request],
)
useEffect(() => {
void load(1, 10)
}, [load])
const changePage = useCallback(
(nextPage: number) => {
setPage(nextPage)
void load(nextPage, pageSize)
},
[load, pageSize],
)
const changePageSize = useCallback(
(nextPageSize: number) => {
setPage(1)
setPageSize(nextPageSize)
void load(1, nextPageSize)
},
[load],
)
const refresh = useCallback(() => {
void load(page, pageSize)
}, [load, page, pageSize])
const retry = useCallback(() => {
void load(lastQuery.current.page, lastQuery.current.page_size)
}, [load])
const list = usePagedList<AdsenseRow, Record<string, never>, Record<string, unknown>>({
initialFilters,
buildQuery: ({ page, pageSize }) => buildAdsenseQuery({ page, pageSize }),
request,
useAppliedFilters: false,
})
const { fetchPage, filters, page, pageSize } = list
const changeStatus = useCallback(
async (id: number, checked: boolean) => {
const changed = await changeStatusRequest({ id, status: checked ? '1' : '0' })
if (changed) {
await load(page, pageSize)
await fetchPage(page, pageSize, filters)
}
},
[changeStatusRequest, load, page, pageSize],
[changeStatusRequest, fetchPage, filters, page, pageSize],
)
return {
rows,
total,
page,
pageSize,
loading,
error,
changePage,
changePageSize,
refresh,
retry,
...list,
changeStatus,
}
}
import { Switch } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useMemo } from 'react'
import { useSelector } from 'react-redux'
import { AppList } from '@/components/AppList'
import { usePermission } from '@/hooks/usePermission'
import type { AdsenseRow } from '../api'
import { useAdsenseList } from '../useAdsenseList'
export default function AdsenseListView() {
const { operationPermissionsList = [] } = useSelector(
(state: { user: { operationPermissionsList?: string[] } }) => state.user,
)
const canChangeStatus = operationPermissionsList.includes('/advertisement/adsense/changeStatus')
const checkPermission = usePermission()
const canChangeStatus = checkPermission('/advertisement/adsense/changeStatus')
const {
rows,
total,
......
import { useCallback, useEffect, useRef, useState } from 'react'
import { usePagedList } from '@/hooks/usePagedList'
import { getAdvertList, type AdvertListResult, type AdvertRow } from './api'
import { buildAdvertQuery, type AdvertFilters, type AdvertQuery } from './query'
const INITIAL_PAGE = 1
const INITIAL_PAGE_SIZE = 10
import { getAdvertList, type AdvertRow } from './api'
import { buildAdvertQuery, type AdvertFilters } from './query'
function isCleared(previous: AdvertFilters, next: AdvertFilters) {
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]) as Set<keyof AdvertFilters>
......@@ -12,113 +9,11 @@ function isCleared(previous: AdvertFilters, next: AdvertFilters) {
}
export function useAdvertList() {
const [filters, setFilterState] = useState<AdvertFilters>({})
const [page, setPage] = useState(INITIAL_PAGE)
const [pageSize, setPageSize] = useState(INITIAL_PAGE_SIZE)
const [rows, setRows] = useState<AdvertRow[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const requestId = useRef(0)
const lastQuery = useRef<AdvertQuery>(
buildAdvertQuery({ page: INITIAL_PAGE, pageSize: INITIAL_PAGE_SIZE, filters: {} }),
)
const load = useCallback(async (query: AdvertQuery) => {
const currentRequest = ++requestId.current
lastQuery.current = query
setLoading(true)
setError(null)
try {
const result: AdvertListResult = await getAdvertList(query)
if (currentRequest !== requestId.current) return
setRows(result.rows)
setTotal(result.total)
} catch (reason) {
if (currentRequest === requestId.current) {
setError(reason instanceof Error ? reason : new Error('数据获取失败!'))
}
} finally {
if (currentRequest === requestId.current) setLoading(false)
}
}, [])
const loadFor = useCallback(
(nextPage: number, nextPageSize: number, nextFilters: AdvertFilters) =>
load(buildAdvertQuery({ page: nextPage, pageSize: nextPageSize, filters: nextFilters })),
[load],
)
useEffect(() => {
void load(lastQuery.current)
return () => {
requestId.current += 1
}
}, [load])
const setFilters = useCallback(
(nextFilters: AdvertFilters) => {
const shouldReload = isCleared(filters, nextFilters)
setFilterState(nextFilters)
if (shouldReload) void loadFor(page, pageSize, nextFilters)
},
[filters, loadFor, page, pageSize],
)
const search = useCallback(() => {
setPage(INITIAL_PAGE)
void loadFor(INITIAL_PAGE, pageSize, filters)
}, [filters, loadFor, pageSize])
const reset = useCallback(() => {
const emptyFilters: AdvertFilters = {}
setFilterState(emptyFilters)
setPage(INITIAL_PAGE)
void loadFor(INITIAL_PAGE, pageSize, emptyFilters)
}, [loadFor, pageSize])
const refresh = useCallback(() => {
void loadFor(page, pageSize, filters)
}, [filters, loadFor, page, pageSize])
const refreshUnfiltered = useCallback(() => {
void loadFor(page, pageSize, {})
}, [loadFor, page, pageSize])
const changePage = useCallback(
(nextPage: number) => {
setPage(nextPage)
void loadFor(nextPage, pageSize, filters)
},
[filters, loadFor, pageSize],
)
const changePageSize = useCallback(
(nextPageSize: number) => {
setPage(INITIAL_PAGE)
setPageSize(nextPageSize)
void loadFor(INITIAL_PAGE, nextPageSize, filters)
},
[filters, loadFor],
)
const retry = useCallback(() => void load(lastQuery.current), [load])
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset,
refresh,
refreshUnfiltered,
changePage,
changePageSize,
retry,
}
return usePagedList<AdvertRow, AdvertFilters, ReturnType<typeof buildAdvertQuery>>({
initialFilters: {},
buildQuery: buildAdvertQuery,
request: getAdvertList,
shouldReloadOnFilterChange: isCleared,
useAppliedFilters: false,
})
}
......@@ -3,9 +3,9 @@ import { PlusOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { AppList, type AppListFilterItem } from '@/components/AppList'
import { usePermission } from '@/hooks/usePermission'
import {
addAdvert,
......@@ -22,24 +22,31 @@ import DeleteAdvertModal from '../components/DeleteAdvertModal'
import type { AdvertFilters } from '../query'
import { useAdvertList } from '../useAdvertList'
interface RootState {
user: {
operationPermissionsList?: string[]
}
}
const { RangePicker } = DatePicker
const IMAGE_FALLBACK = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='
interface AdvertDialogState {
selectedId: number | null
isAdd: boolean
formOpen: boolean
formValues: AdvertFormValues | null
deleteOpen: boolean
}
export default function AdvertListView() {
const list = useAdvertList()
const permissions = useSelector((state: RootState) => state.user.operationPermissionsList ?? [])
const checkPermission = usePermission()
const [positions, setPositions] = useState<AdvertPosition[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [isAdd, setIsAdd] = useState(false)
const [formOpen, setFormOpen] = useState(false)
const [formValues, setFormValues] = useState<AdvertFormValues | null>(null)
const [deleteOpen, setDeleteOpen] = useState(false)
const [dialogState, setDialogState] = useState<AdvertDialogState>({
selectedId: null,
isAdd: false,
formOpen: false,
formValues: null,
deleteOpen: false,
})
const updateDialogState = (next: Partial<AdvertDialogState>) =>
setDialogState((state) => ({ ...state, ...next }))
const { selectedId, isAdd, formOpen, formValues, deleteOpen } = dialogState
useEffect(() => {
let active = true
......@@ -58,16 +65,18 @@ export default function AdvertListView() {
const handleEdit = async (id: number) => {
const advert = await getAdvertInfo(id)
setSelectedId(advert.id)
setIsAdd(false)
setFormValues({
position_id: advert.position_id,
name: advert.name,
pic: advert.pic,
validity: [dayjs(advert.start_validity), dayjs(advert.end_validity)],
url: advert.url,
updateDialogState({
selectedId: advert.id,
isAdd: false,
formValues: {
position_id: advert.position_id,
name: advert.name,
pic: advert.pic,
validity: [dayjs(advert.start_validity), dayjs(advert.end_validity)],
url: advert.url,
},
formOpen: true,
})
setFormOpen(true)
}
const handleSubmit = async (values: AdvertFormValues) => {
......@@ -92,12 +101,12 @@ export default function AdvertListView() {
}))
if (!success) return
list.refreshUnfiltered()
setFormOpen(false)
updateDialogState({ formOpen: false })
}
const handleDelete = async () => {
if (selectedId === null) return
setDeleteOpen(false)
updateDialogState({ deleteOpen: false })
const success = await deleteAdvert(selectedId)
if (success) list.refreshUnfiltered()
}
......@@ -178,7 +187,7 @@ export default function AdvertListView() {
<Switch
checked={String(record.status) === '1'}
onChange={(enabled) => void handleStatus(enabled, record.id)}
disabled={!permissions.includes('/advertisement/advert/changeStatus')}
disabled={!checkPermission('/advertisement/advert/changeStatus')}
/>
),
},
......@@ -201,14 +210,13 @@ export default function AdvertListView() {
align: 'center',
render: (_, record) => (
<Space>
{permissions.includes('/advertisement/advert/getInfoById') ? (
{checkPermission('/advertisement/advert/getInfoById') ? (
<Button onClick={() => void handleEdit(record.id)}>编辑</Button>
) : null}
{permissions.includes('/advertisement/advert/del') ? (
{checkPermission('/advertisement/advert/del') ? (
<Button
onClick={() => {
setSelectedId(record.id)
setDeleteOpen(true)
updateDialogState({ selectedId: record.id, deleteOpen: true })
}}
>
删除
......@@ -265,16 +273,18 @@ export default function AdvertListView() {
},
]
const toolbar = permissions.includes('/advertisement/advert/add') ? (
const toolbar = checkPermission('/advertisement/advert/add') ? (
<Button
type='primary'
ghost
icon={<PlusOutlined />}
onClick={() => {
setSelectedId(null)
setIsAdd(true)
setFormValues(null)
setFormOpen(true)
updateDialogState({
selectedId: null,
isAdd: true,
formValues: null,
formOpen: true,
})
}}
>
添加
......@@ -312,14 +322,14 @@ export default function AdvertListView() {
/>
<DeleteAdvertModal
open={deleteOpen}
onCancel={() => setDeleteOpen(false)}
onCancel={() => updateDialogState({ deleteOpen: false })}
onConfirm={handleDelete}
/>
<AdvertFormDrawer
open={formOpen}
positions={positions}
initialValues={formValues}
onClose={() => setFormOpen(false)}
onClose={() => updateDialogState({ formOpen: false })}
onSubmit={handleSubmit}
/>
</>
......
......@@ -6,22 +6,28 @@
.audit-review-workspace {
--audit-review-header-height: 56px;
position: relative;
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(420px, 1fr) minmax(300px, 360px);
gap: 20px;
gap: 0;
box-sizing: border-box;
height: calc(100vh - 132px);
min-height: 0;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 8px;
overflow: hidden;
&--tree-collapsed {
grid-template-columns: 42px minmax(420px, 1fr) minmax(300px, 360px);
}
&--comments-collapsed {
grid-template-columns: minmax(220px, 280px) minmax(420px, 1fr) 42px;
grid-template-columns: minmax(220px, 280px) minmax(420px, 1fr);
}
&--tree-collapsed&--comments-collapsed {
grid-template-columns: 42px minmax(420px, 1fr) 42px;
grid-template-columns: 42px minmax(420px, 1fr);
}
.audit-review-preview {
......@@ -35,8 +41,8 @@
.previee-container {
height: 100%;
min-width: 0;
border: 1px solid #e8e8e8;
border-radius: 8px;
border: 0;
border-right: 1px solid #e5e6eb;
background: #fff;
overflow: hidden;
}
......@@ -91,6 +97,7 @@
.previee-container {
display: flex;
flex-direction: column;
border-radius: 0;
}
&.wrap-phone-privew .previee-container .chapter-head-title {
......@@ -114,30 +121,6 @@
overflow: auto;
}
.preview-content-html .review-comment-anchor {
padding: 0;
color: inherit;
font: inherit;
font-weight: inherit;
line-height: inherit;
letter-spacing: inherit;
vertical-align: baseline;
background: transparent;
box-shadow: inset 0 -2px 0 #e3bf4b;
transition:
background-color 160ms ease,
box-shadow 160ms ease;
&--active {
box-shadow: inset 0 -2px 0 #c69522;
}
&--draft {
background: #fff3c7;
box-shadow: inset 0 -2px 0 #d6a228;
}
}
.desktop-preview {
.preview-content-show {
display: flex;
......@@ -162,8 +145,7 @@
display: flex;
flex-direction: column;
padding: 0;
border: 1px solid #e8e8e8;
border-radius: 8px;
border: 0;
background: #fff;
overflow: hidden;
min-height: 0;
......@@ -187,10 +169,12 @@
line-height: 24px;
> span {
color: #8a8a8a;
font-weight: 400;
color: #1f2329;
font-weight: 600;
em {
color: #646a73;
font-weight: 400;
font-style: normal;
}
}
......@@ -215,16 +199,81 @@
overflow: auto;
}
&__filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 12px;
margin-bottom: 14px;
border-bottom: 1px solid #f0f0f0;
}
&--collapsed {
.review-comment-sidebar__header {
justify-content: center;
padding: 0;
position: absolute;
z-index: 5;
top: 8px;
right: 8px;
display: block;
width: 40px;
height: 40px;
min-height: auto;
overflow: visible;
background: transparent;
h3 {
justify-content: center;
.review-comment-sidebar__floating-toggle {
width: 40px;
height: 40px;
color: #4e5969;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(31, 35, 41, 0.08);
> span {
display: none;
&:hover {
color: #aa1941;
background: #fff;
border-color: #d8c2c8;
}
}
}
.review-comment-resolved-switch {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
color: #777;
font-size: 12px;
white-space: nowrap;
}
.review-comment-scope-tabs {
display: flex;
min-width: 0;
button {
position: relative;
padding: 3px 8px;
color: #8a8a8a;
font-size: 13px;
line-height: 24px;
cursor: pointer;
background: transparent;
border: 0;
&.active {
color: #aa1941;
font-weight: 600;
&::after {
position: absolute;
right: 8px;
bottom: -13px;
left: 8px;
height: 2px;
content: '';
background: #aa1941;
}
}
}
......@@ -421,108 +470,16 @@
gap: 10px;
}
.review-comment-card {
position: relative;
padding: 12px;
margin: 0;
background: #fff;
border: 1px solid #ebe7e1;
border-radius: 7px;
transition:
border-color 160ms ease,
box-shadow 160ms ease;
&--anchored {
cursor: pointer;
}
&:hover {
border-color: #d8c7ad;
box-shadow: 0 2px 8px rgba(91, 70, 42, 0.08);
.review-comment-delete {
opacity: 1;
}
}
&__header {
display: flex;
align-items: center;
gap: 8px;
padding-right: 24px;
margin-bottom: 8px;
color: #3d3d3d;
strong,
.review-comment-time {
display: block;
}
}
.review-comment-delete {
position: absolute;
top: 7px;
right: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
color: #c53c5f;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 4px;
opacity: 0;
transition: opacity 160ms ease;
&:hover {
background: #fff1f3;
}
}
}
.review-comment-empty {
padding: 24px 0;
color: #999;
font-size: 13px;
text-align: center;
}
.review-comment-quote {
padding: 2px 0 2px 10px;
margin-bottom: 10px;
color: #7a7f89;
font-size: 13px;
line-height: 1.55;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
background: transparent;
border-left: 2px solid #d8dce4;
}
.review-comment-content {
line-height: 1.7;
white-space: pre-wrap;
}
.review-comment-card__images {
margin-top: 10px;
.ant-image,
img {
object-fit: cover;
border-radius: 4px;
p {
margin: 0 0 8px;
}
}
.review-comment-quote + .review-comment-content {
padding-top: 10px;
border-top: 1px solid #f0ede8;
}
}
}
......
import React, { useEffect, useState, useRef, useImperativeHandle, forwardRef } from 'react'
import React, {
useCallback,
useEffect,
useState,
useRef,
useMemo,
useImperativeHandle,
forwardRef,
} from 'react'
import { Modal, Drawer, Tree, Spin, Space, Button, Input, Image } from 'antd'
import { CloseOutlined, LeftOutlined, RightOutlined, BarsOutlined } from '@ant-design/icons'
import { BarsOutlined, CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'
import { getInfoByChapterId, getAllList } from '@/modules/books/management/chapters/api'
import { getChapterTopic, expandReadInfo } from '@/components/editor/wangeditor-customer/public-api'
import {
......@@ -9,7 +17,7 @@ import {
findFirstNotHasChildren,
clacTopicText,
} from '@/utils/common'
import { findTextAnchor } from '../../review-comments/anchor'
import { findReviewCommentRange, useReviewHighlights } from '../../review-comments/highlights'
import { get } from 'lodash-es'
import $ from 'jquery'
import '@/components/editor/preview.less'
......@@ -67,12 +75,12 @@ const PreviewScreen = (props, ref) => {
const {
bookId,
onChapterChange,
onChapterTreeChange,
onTextSelection,
onToggleTree,
treeCollapsed,
reviewComments = [],
draftCommentAnchor,
pendingSelection,
activeCommentId,
desktop = false,
} = props
......@@ -108,7 +116,22 @@ const PreviewScreen = (props, ref) => {
const [previewImg, setPreviewImg] = useState('') // 扩展阅读内容
const newId = useRef(null)
const previewContentRef = useRef(null)
const selectedRangeRef = useRef(null)
const previewText = useMemo(() => {
const element = document.createElement('div')
element.innerHTML = prviewHtml || ''
return element.textContent || ''
}, [prviewHtml])
const getPreviewRoot = useCallback(() => previewContentRef.current || undefined, [])
useReviewHighlights({
getRoot: getPreviewRoot,
text: previewText,
comments: reviewComments,
draftAnchor: draftCommentAnchor,
activeCommentId,
enabled: true,
rootVersion: prviewHtml,
})
// 递归函数,获取所有节点的key
const getAllNodeKeys = (nodes) => {
......@@ -133,6 +156,7 @@ const PreviewScreen = (props, ref) => {
setPageMap(map)
setTotalPages(total)
setGData(arr)
onChapterTreeChange?.(arr)
const allKeys = getAllNodeKeys(arr)
setExpandedKeys(allKeys)
......@@ -461,87 +485,15 @@ const PreviewScreen = (props, ref) => {
}, [newChapterId])
useEffect(() => {
if (!pendingSelection) selectedRangeRef.current = null
}, [pendingSelection])
useEffect(() => {
const restoreNativeSelection = (event) => {
if (
!(event.target instanceof Element) ||
event.target.closest('.preview-content-html, .review-comment-sidebar') ||
!selectedRangeRef.current
)
return
const selection = window.getSelection()
if (!selection || !selectedRangeRef.current) return
selection.removeAllRanges()
selection.addRange(selectedRangeRef.current)
}
document.addEventListener('mouseup', restoreNativeSelection)
return () => document.removeEventListener('mouseup', restoreNativeSelection)
}, [])
useEffect(() => {
if (!activeCommentId) return
const root = previewContentRef.current
if (!root) return
root.querySelectorAll('[data-review-comment-id]').forEach((element) => {
element.replaceWith(document.createTextNode(element.textContent || ''))
})
root.normalize()
const text = root.textContent || ''
const commentAnchors = reviewComments
.filter((comment) => comment.quote)
.map((comment) => ({ comment, draft: false }))
if (draftCommentAnchor?.quote)
commentAnchors.push({ comment: { ...draftCommentAnchor, id: 'draft' }, draft: true })
const ranges = commentAnchors
.map(({ comment, draft }) => ({ comment, draft, range: findTextAnchor(text, comment) }))
.filter((item) => item.range)
ranges.sort((left, right) => right.range.start_offset - left.range.start_offset)
ranges.forEach(({ comment, draft, range }) => {
let offset = 0
const nodes = []
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()
while (node) {
if (!node.parentElement?.closest('[data-review-comment-id]'))
nodes.push({ node, start: offset, end: offset + node.textContent.length })
offset += node.textContent.length
node = walker.nextNode()
}
nodes.forEach(({ node: textNode, start, end }) => {
const selectedStart = Math.max(start, range.start_offset)
const selectedEnd = Math.min(end, range.end_offset)
if (selectedStart >= selectedEnd || !textNode.parentNode) return
const localStart = selectedStart - start
const localEnd = selectedEnd - start
const fragment = document.createDocumentFragment()
if (localStart > 0)
fragment.append(document.createTextNode(textNode.textContent.slice(0, localStart)))
const marker = document.createElement('span')
marker.className = `review-comment-anchor${Number(activeCommentId) === Number(comment.id) ? ' review-comment-anchor--active' : ''}${draft ? ' review-comment-anchor--draft' : ''}`
marker.dataset.reviewCommentId = String(comment.id)
marker.textContent = textNode.textContent.slice(localStart, localEnd)
fragment.append(marker)
if (localEnd < textNode.textContent.length)
fragment.append(document.createTextNode(textNode.textContent.slice(localEnd)))
textNode.parentNode.replaceChild(fragment, textNode)
})
const comment = reviewComments.find((item) => Number(item.id) === Number(activeCommentId))
const range = root && comment && findReviewCommentRange(root, previewText, comment)
range?.startContainer.parentElement?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}, [prviewHtml, reviewComments, draftCommentAnchor, activeCommentId])
useEffect(() => {
if (!activeCommentId) return
const activeMarker = previewContentRef.current?.querySelector(
`[data-review-comment-id="${activeCommentId}"]`,
)
activeMarker?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, [activeCommentId])
}, [activeCommentId, previewText, reviewComments])
const onClose = () => {
setOpen(false)
......@@ -560,6 +512,20 @@ const PreviewScreen = (props, ref) => {
setOpen(false)
}
useImperativeHandle(
ref,
() => ({
selectChapter: (chapterId) => {
const id = String(chapterId)
setNewChapterId(id)
newId.current = id
setCheckedKeys([id])
setCurrentPage(pageMap[id] || 0)
},
}),
[pageMap],
)
const handleTextSelection = (event) => {
const selection = window.getSelection()
const root = event.currentTarget
......@@ -572,7 +538,6 @@ const PreviewScreen = (props, ref) => {
)
return
const range = selection.getRangeAt(0)
selectedRangeRef.current = range.cloneRange()
const beforeRange = range.cloneRange()
beforeRange.selectNodeContents(root)
beforeRange.setEnd(range.startContainer, range.startOffset)
......@@ -636,12 +601,12 @@ const PreviewScreen = (props, ref) => {
className={`audit-preview-tree${treeCollapsed ? ' audit-preview-tree--collapsed' : ''}`}
>
<h3>
<span>章节列表</span>
<span>章节目录</span>
<Button
type='text'
icon={treeCollapsed ? <RightOutlined /> : <LeftOutlined />}
aria-label={treeCollapsed ? '展开章节列表' : '收起章节列表'}
title={treeCollapsed ? '展开章节列表' : '收起章节列表'}
aria-label={treeCollapsed ? '展开章节目录' : '收起章节目录'}
title={treeCollapsed ? '展开章节目录' : '收起章节目录'}
onClick={onToggleTree}
/>
</h3>
......@@ -695,7 +660,6 @@ const PreviewScreen = (props, ref) => {
className='preview-content-html'
dangerouslySetInnerHTML={{ __html: prviewHtml }}
onMouseDown={() => {
selectedRangeRef.current = null
onTextSelection?.(null)
}}
onMouseUp={handleTextSelection}
......@@ -1045,7 +1009,7 @@ const PreviewScreen = (props, ref) => {
placement='left'
classNames={{ header: 'priview-drawer-header', body: 'priview-drawer-body' }}
width='300'
title='章节列表'
title='章节目录'
rootClassName='priview-drawer-container'
>
{renderChapterTree()}
......
import { useCallback, useEffect, useRef, useState } from 'react'
import { usePagedList } from '@/hooks/usePagedList'
import { getAuditList } from './api'
import { buildAuditQuery, type AuditFilters, type AuditQuery } from './query'
import { buildAuditQuery, type AuditFilters } from './query'
async function requestAuditList(query: ReturnType<typeof buildAuditQuery>) {
const result: any = await getAuditList(query)
if (!result) throw new Error('请求失败')
return { rows: result.list || [], total: result.total || 0 }
}
export function useAuditList() {
const [filters, setFilters] = useState<AuditFilters>({})
const [applied, setApplied] = useState<AuditFilters>({})
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [rows, setRows] = useState<any[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const last = useRef<AuditQuery>(buildAuditQuery(1, 10, {}))
const load = useCallback(async (query: AuditQuery) => {
last.current = query
setLoading(true)
setError(null)
try {
const result: any = await getAuditList(query)
if (!result) throw new Error('请求失败')
setRows(result.list || [])
setTotal(result.total || 0)
} catch (cause) {
setError(cause instanceof Error ? cause : new Error('请求失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load(last.current)
}, [load])
const run = (nextPage: number, nextSize: number, nextFilters: AuditFilters) =>
void load(buildAuditQuery(nextPage, nextSize, nextFilters))
const search = () => {
setApplied(filters)
setPage(1)
run(1, pageSize, filters)
}
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset: () => {
setFilters({})
setApplied({})
setPage(1)
run(1, pageSize, {})
},
refresh: () => {
setApplied(filters)
run(page, pageSize, filters)
},
retry: () => void load(last.current),
changePage: (nextPage: number) => {
setPage(nextPage)
run(nextPage, pageSize, applied)
},
changePageSize: (nextSize: number) => {
setPage(1)
setPageSize(nextSize)
run(1, nextSize, applied)
},
}
return usePagedList<any, AuditFilters, ReturnType<typeof buildAuditQuery>>({
initialFilters: {},
buildQuery: ({ page, pageSize, filters }) => buildAuditQuery(page, pageSize, filters),
request: requestAuditList,
})
}
import { useSearchParams } from 'react-router'
import { useState } from 'react'
import { useRef, useState } from 'react'
import PreviewModal from '../components/PreviewModal'
import ReviewCommentSidebar, {
type ReviewSelection,
} from '../../review-comments/components/ReviewCommentSidebar'
import type { TextAnchor } from '../../review-comments/anchor'
import type { ChapterTreeNode, ReviewComment } from '../../review-comments/types'
import '../audit.less'
export default function AuditCommentView() {
const [searchParams] = useSearchParams()
const bookId = Number(searchParams.get('book_id'))
const releaseNum = Number(searchParams.get('release_num'))
const [chapter, setChapter] = useState<{ chapterId?: number; text: string }>({ text: '' })
const [selection, setSelection] = useState<ReviewSelection | null>(null)
const [comments, setComments] = useState<any[]>([])
const [comments, setComments] = useState<ReviewComment[]>([])
const [chapterTree, setChapterTree] = useState<ChapterTreeNode[]>([])
const [activeCommentId, setActiveCommentId] = useState<number>()
const [draftAnchor, setDraftAnchor] = useState<TextAnchor | null>(null)
const [treeCollapsed, setTreeCollapsed] = useState(false)
const [commentsCollapsed, setCommentsCollapsed] = useState(false)
const previewRef = useRef<{ selectChapter?: (chapterId: number) => void }>(null)
const pendingCommentIdRef = useRef<number>()
if (!bookId || !releaseNum)
return <div style={{ padding: 20 }}>缺少书籍或提交版本,请返回审核列表重新进入。</div>
if (!bookId) return <div style={{ padding: 20 }}>缺少书籍信息,请返回审核列表重新进入。</div>
return (
<div
className={`audit-review-workspace${treeCollapsed ? ' audit-review-workspace--tree-collapsed' : ''}${commentsCollapsed ? ' audit-review-workspace--comments-collapsed' : ''}`}
>
<section className='audit-review-preview wrap-phone-privew'>
<PreviewModal
ref={previewRef}
desktop
treeCollapsed={treeCollapsed}
onToggleTree={() => setTreeCollapsed((collapsed) => !collapsed)}
bookId={bookId}
onChapterTreeChange={setChapterTree}
reviewComments={comments}
draftCommentAnchor={draftAnchor}
pendingSelection={selection}
activeCommentId={activeCommentId}
onChapterChange={(next: any) => {
setChapter(next)
setSelection(null)
setDraftAnchor(null)
setComments([])
setActiveCommentId(undefined)
setActiveCommentId(pendingCommentIdRef.current)
pendingCommentIdRef.current = undefined
}}
onTextSelection={setSelection}
/>
......@@ -50,9 +54,9 @@ export default function AuditCommentView() {
collapsed={commentsCollapsed}
onToggle={() => setCommentsCollapsed((collapsed) => !collapsed)}
bookId={bookId}
releaseNum={releaseNum}
chapterId={chapter.chapterId}
chapterText={chapter.text}
chapterTree={chapterTree}
selection={selection}
onClearSelection={() => {
window.getSelection()?.removeAllRanges()
......@@ -61,6 +65,11 @@ export default function AuditCommentView() {
onDraftAnchorChange={setDraftAnchor}
onCommentsChange={setComments}
onFocusComment={setActiveCommentId}
onSelectChapter={(chapterId, commentId) => {
pendingCommentIdRef.current = commentId
setActiveCommentId(commentId)
previewRef.current?.selectChapter?.(chapterId)
}}
/>
</div>
)
......
import { Button, Card, Form, Input, Modal, Select, Space, Spin, message } from 'antd'
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router'
import { useSelector } from 'react-redux'
import { useNavigate, useSearchParams } from 'react-router'
import { addExamine, getExamineList } from '../api'
import { usePermission } from '@/hooks/usePermission'
import AuditHistory from '../components/AuditHistory'
import '../audit.less'
export default function AuditDetailView() {
const location = useLocation()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const id = (location.state as { id?: string | number } | null)?.id
const { operationPermissionsList = [] } = useSelector((state: any) => state.user)
const id = Number(searchParams.get('id'))
const checkPermission = usePermission()
const [form] = Form.useForm()
const [history, setHistory] = useState<any[]>([])
const [loading, setLoading] = useState(false)
......@@ -97,7 +97,7 @@ export default function AuditDetailView() {
</Space>
</Form.Item>
</Form>
{operationPermissionsList.includes('/books/audit/getExamineList') && (
{checkPermission('/books/audit/getExamineList') && (
<Card className='auditPanel' style={{ marginTop: 100, height: 350, overflow: 'auto' }}>
<AuditHistory entries={history} />
</Card>
......
import { Button, DatePicker, Image, Input, Space } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useNavigate } from 'react-router'
import { useSelector } from 'react-redux'
import { AppList, type AppListFilterItem } from '@/components/AppList'
import { usePermission } from '@/hooks/usePermission'
import type { AuditFilters } from '../query'
import { useAuditList } from '../useAuditList'
const { RangePicker } = DatePicker
export default function AuditListView() {
const list = useAuditList()
const navigate = useNavigate()
const { operationPermissionsList = [] } = useSelector((state: any) => state.user)
const checkPermission = usePermission()
const filters: AppListFilterItem<AuditFilters>[] = [
{
name: 'name',
......@@ -61,21 +61,21 @@ export default function AuditListView() {
title: '操作',
render: (_, row) => (
<Space>
{operationPermissionsList.includes('/books/audit/add') && (
{checkPermission('/books/audit/add') && (
<Button
onClick={(event) => {
event.stopPropagation()
navigate(`comments?book_id=${row.id}&release_num=${row.release_num}`)
navigate(`comments?book_id=${row.id}`)
}}
>
预览
</Button>
)}
{operationPermissionsList.includes('/books/audit/add') && (
{checkPermission('/books/audit/add') && (
<Button
onClick={(event) => {
event.stopPropagation()
navigate('detail', { state: { id: row.id } })
navigate(`detail?id=${row.id}`)
}}
>
审核
......
import { useCallback, useEffect, useRef, useState } from 'react'
import { usePagedList } from '@/hooks/usePagedList'
import { getClassifyList } from './api'
import { buildClassifyQuery, type ClassifyFilters, type ClassifyQuery } from './query'
import { buildClassifyQuery, type ClassifyFilters } from './query'
async function requestClassifyList(query: ReturnType<typeof buildClassifyQuery>) {
const result: any = await getClassifyList(query)
if (!result) throw new Error('请求失败')
return { rows: result.list || [], total: result.total || 0 }
}
export function useClassifyList() {
const [filters, setFilters] = useState<ClassifyFilters>({})
const [applied, setApplied] = useState<ClassifyFilters>({})
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [rows, setRows] = useState<any[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const last = useRef<ClassifyQuery>(buildClassifyQuery(1, 10, {}))
const load = useCallback(async (query: ClassifyQuery) => {
last.current = query
setLoading(true)
setError(null)
try {
const result: any = await getClassifyList(query)
if (!result) throw new Error('请求失败')
setRows(result.list || [])
setTotal(result.total || 0)
} catch (e) {
setError(e instanceof Error ? e : new Error('请求失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load(last.current)
}, [load])
const run = (p: number, s: number, f: ClassifyFilters) => void load(buildClassifyQuery(p, s, f))
const search = () => {
setApplied(filters)
setPage(1)
run(1, pageSize, filters)
}
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset: () => {
setFilters({})
setApplied({})
setPage(1)
run(1, pageSize, {})
},
refresh: () => {
setApplied(filters)
run(page, pageSize, filters)
},
retry: () => void load(last.current),
reloadFirst: search,
changePage: (p: number) => {
setPage(p)
run(p, pageSize, applied)
},
changePageSize: (s: number) => {
setPage(1)
setPageSize(s)
run(1, s, applied)
},
}
const list = usePagedList<any, ClassifyFilters, ReturnType<typeof buildClassifyQuery>>({
initialFilters: {},
buildQuery: ({ page, pageSize, filters }) => buildClassifyQuery(page, pageSize, filters),
request: requestClassifyList,
})
return { ...list, reloadFirst: list.search }
}
......@@ -2,8 +2,8 @@ import { App, Button, DatePicker, Drawer, Form, Input, Space } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { useState } from 'react'
import { useSelector } from 'react-redux'
import { AppList, type AppListFilterItem } from '@/components/AppList'
import { usePermission } from '@/hooks/usePermission'
import { addCategory, deleteCategory, editCategory, getCategory } from '../api'
import type { ClassifyFilters } from '../query'
import { useClassifyList } from '../useClassifyList'
......@@ -32,7 +32,7 @@ const items: AppListFilterItem<ClassifyFilters>[] = [
export default function ClassifyView() {
const list = useClassifyList()
const { modal } = App.useApp()
const { operationPermissionsList = [] } = useSelector((state: any) => state.user)
const checkPermission = usePermission()
const [open, setOpen] = useState(false)
const [id, setId] = useState<any>('')
const [form] = Form.useForm()
......@@ -64,10 +64,10 @@ export default function ClassifyView() {
key: 'handle',
render: (_, row) => (
<Space>
{operationPermissionsList.includes('/books/classify/getInfoById') && (
{checkPermission('/books/classify/getInfoById') && (
<Button onClick={() => edit(row.id)}>编辑</Button>
)}
{operationPermissionsList.includes('/books/classify/del') && (
{checkPermission('/books/classify/del') && (
<Button
onClick={() =>
modal.confirm({
......@@ -107,7 +107,7 @@ export default function ClassifyView() {
onPageSizeChange: list.changePageSize,
}}
toolbar={
operationPermissionsList.includes('/books/classify/add') ? (
checkPermission('/books/classify/add') ? (
<Button
type='primary'
ghost
......@@ -138,10 +138,10 @@ export default function ClassifyView() {
</Form.Item>
<Form.Item wrapperCol={{ offset: 6, span: 16 }} style={{ textAlign: 'center' }}>
<Space size={20}>
{operationPermissionsList.includes('/books/classify/edit') && (
{checkPermission('/books/classify/edit') && (
<Button onClick={() => setOpen(false)}>取消</Button>
)}
{operationPermissionsList.includes('/books/classify/edit') && (
{checkPermission('/books/classify/edit') && (
<Button type='primary' htmlType='submit'>
提交
</Button>
......
import CktDesign from '@chuangkit/chuangkit-design'
import md5 from 'js-md5'
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { useUserStore } from '@/stores/userStore'
function buildSign(parameters) {
const signParameters = []
......@@ -21,7 +21,7 @@ function buildVersion2Sign(appId, expireTime, userFlag, appSecret) {
}
export default function DesignView() {
const { userInfo } = useSelector((state) => state.user)
const userInfo = useUserStore((state) => state.userInfo)
useEffect(() => {
const appId = '54d9adec77d0402794018d166110f3dd'
......
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect } from 'react'
import { usePagedList } from '@/hooks/usePagedList'
import { getDiscussionList } from './api'
import { buildDiscussionQuery, type DiscussionFilters, type DiscussionQuery } from './query'
export function useDiscussionList(initialBook?: any) {
const [filters, setFilters] = useState<DiscussionFilters>({ book_id: initialBook })
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [rows, setRows] = useState<any[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const last = useRef<DiscussionQuery>(buildDiscussionQuery(1, 10, { book_id: initialBook }))
const load = useCallback(async (q: DiscussionQuery) => {
last.current = q
setLoading(true)
try {
const r: any = await getDiscussionList(q)
if (!r) throw new Error('请求失败')
setRows(r.list || [])
setTotal(r.total || 0)
setError(null)
} catch (e) {
setError(e instanceof Error ? e : new Error('请求失败'))
} finally {
setLoading(false)
}
export function useDiscussionList(initialBook?: number | string) {
const initialFilters: DiscussionFilters = { book_id: initialBook }
const buildQuery = useCallback(
({ page, pageSize, filters }: { page: number; pageSize: number; filters: DiscussionFilters }) =>
buildDiscussionQuery(page, pageSize, filters),
[],
)
const request = useCallback(async (query: DiscussionQuery) => {
const result = (await getDiscussionList(query)) as { list?: any[]; total?: number } | null
if (!result) throw new Error('请求失败')
return { rows: result.list || [], total: result.total || 0 }
}, [])
const list = usePagedList<any, DiscussionFilters, DiscussionQuery>({
initialFilters,
buildQuery,
request,
autoLoad: false,
})
const { fetchPage, filters: currentFilters, pageSize } = list
useEffect(() => {
if (filters.book_id) void load(buildDiscussionQuery(page, pageSize, filters))
}, [filters.book_id, load])
const run = (p: number, s: number, f = filters) => void load(buildDiscussionQuery(p, s, f))
const search = () => {
setPage(1)
run(1, pageSize)
}
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset: () => {
const f = { book_id: filters.book_id }
setFilters(f)
setPage(1)
run(1, pageSize, f)
},
refresh: () => run(page, pageSize),
retry: () => void load(last.current),
changePage: (p: number) => {
setPage(p)
run(p, pageSize)
},
changePageSize: (s: number) => {
setPage(1)
setPageSize(s)
run(1, s)
},
}
if (initialBook) void fetchPage(1, 10, { book_id: initialBook })
}, [fetchPage, initialBook])
const reset = useCallback(() => {
const filters = { book_id: currentFilters.book_id || initialBook }
void fetchPage(1, pageSize, filters)
}, [currentFilters.book_id, fetchPage, initialBook, pageSize])
return { ...list, reset }
}
......@@ -13,8 +13,8 @@ import {
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { AppList, type AppListFilterItem } from '@/components/AppList'
import { usePermission } from '@/hooks/usePermission'
import {
changeCommentStatus,
deleteComment,
......@@ -30,7 +30,7 @@ export default function DiscussionView() {
const [systemOpen, setSystemOpen] = useState(false)
const [form] = Form.useForm()
const { modal } = App.useApp()
const { operationPermissionsList = [] } = useSelector((s: any) => s.user)
const checkPermission = usePermission()
useEffect(() => {
void getBooks().then((b: any) => b && setBooks(b))
}, [])
......@@ -117,7 +117,7 @@ export default function DiscussionView() {
{
title: '操作',
render: (_, row) =>
operationPermissionsList.includes('/books/discussion/delComment') && (
checkPermission('/books/discussion/delComment') && (
<Button
onClick={() =>
modal.confirm({
......@@ -156,7 +156,7 @@ export default function DiscussionView() {
onPageSizeChange: list.changePageSize,
}}
toolbar={
operationPermissionsList.includes('/books/discussion/getSystem') ? (
checkPermission('/books/discussion/getSystem') ? (
<Button
type='primary'
ghost
......
import { useCallback, useEffect, useRef, useState } from 'react'
import { usePagedList } from '@/hooks/usePagedList'
import { getLabelList } from './api'
import { buildLabelQuery, type LabelFilters, type LabelQuery } from './query'
import { buildLabelQuery, type LabelFilters } from './query'
async function requestLabelList(query: ReturnType<typeof buildLabelQuery>) {
const result: any = await getLabelList(query)
if (!result) throw new Error('请求失败')
return { rows: result.list || [], total: result.total || 0 }
}
export function useLabelList() {
const [filters, setFilters] = useState<LabelFilters>({})
const [applied, setApplied] = useState<LabelFilters>({})
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [rows, setRows] = useState<any[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const last = useRef<LabelQuery>(buildLabelQuery(1, 10, {}))
const load = useCallback(async (query: LabelQuery) => {
last.current = query
setLoading(true)
setError(null)
try {
const result: any = await getLabelList(query)
if (!result) throw new Error('请求失败')
setRows(result.list || [])
setTotal(result.total || 0)
} catch (e) {
setError(e instanceof Error ? e : new Error('请求失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load(last.current)
}, [load])
const run = (p: number, s: number, f: LabelFilters) => void load(buildLabelQuery(p, s, f))
const search = () => {
setApplied(filters)
setPage(1)
run(1, pageSize, filters)
}
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset: () => {
setFilters({})
setApplied({})
setPage(1)
run(1, pageSize, {})
},
refresh: () => {
setApplied(filters)
run(page, pageSize, filters)
},
retry: () => void load(last.current),
reloadFirst: search,
changePage: (p: number) => {
setPage(p)
run(p, pageSize, applied)
},
changePageSize: (s: number) => {
setPage(1)
setPageSize(s)
run(1, s, applied)
},
}
const list = usePagedList<any, LabelFilters, ReturnType<typeof buildLabelQuery>>({
initialFilters: {},
buildQuery: ({ page, pageSize, filters }) => buildLabelQuery(page, pageSize, filters),
request: requestLabelList,
})
return { ...list, reloadFirst: list.search }
}
......@@ -2,8 +2,8 @@ import { App, Button, Col, DatePicker, Drawer, Form, Input, Row, Space } from 'a
import { PlusOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { AppList, type AppListFilterItem } from '@/components/AppList'
import { usePermission } from '@/hooks/usePermission'
import { addLabel, deleteLabel, editLabel, getAllBooks, getLabel } from '../api'
import type { LabelFilters } from '../query'
import { useLabelList } from '../useLabelList'
......@@ -32,7 +32,7 @@ const items: AppListFilterItem<LabelFilters>[] = [
export default function LabelView() {
const list = useLabelList()
const { modal } = App.useApp()
const { operationPermissionsList = [] } = useSelector((state: any) => state.user)
const checkPermission = usePermission()
const [open, setOpen] = useState(false)
const [id, setId] = useState<any>('')
const [books, setBooks] = useState<any[]>([])
......@@ -77,10 +77,10 @@ export default function LabelView() {
key: 'handle',
render: (_, row) => (
<Space>
{operationPermissionsList.includes('/books/label/getInfoById') && (
{checkPermission('/books/label/getInfoById') && (
<Button onClick={() => edit(row.id)}>编辑</Button>
)}
{operationPermissionsList.includes('/books/label/del') && (
{checkPermission('/books/label/del') && (
<Button
onClick={() =>
modal.confirm({
......@@ -120,7 +120,7 @@ export default function LabelView() {
onPageSizeChange: list.changePageSize,
}}
toolbar={
operationPermissionsList.includes('/books/label/add') ? (
checkPermission('/books/label/add') ? (
<Button
type='primary'
ghost
......
import { useState, useEffect, useRef } from 'react'
import { Form, Button, Space, Input } from 'antd'
import { setTreeChapter } from '@/store/modules/user'
import { useDispatch } from 'react-redux'
import { useUserStore } from '@/stores/userStore'
import { chapterEdit, chapterAdd, sectionAdd } from '../api'
const EditChapterTitle = (props) => {
......@@ -18,7 +17,7 @@ const EditChapterTitle = (props) => {
editorLoading,
newChapterChange,
} = props
const dispatch = useDispatch()
const setTreeChapter = useUserStore((state) => state.setTreeChapter)
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [initValues, setInitValues] = useState({ title: editValue })
......@@ -53,7 +52,7 @@ const EditChapterTitle = (props) => {
} else {
data = await chapterEdit({ id: editKey, name: values.title })
}
await dispatch(setTreeChapter({ saveBookId: bookId, saveChapterId: data.id }))
setTreeChapter({ saveBookId: bookId, saveChapterId: data.id })
if (data) {
if (parentId) {
await sectionAdd({ book_id: bookId, chapter_id: data.id, content: '', word_count: 0 })
......
......@@ -7,15 +7,13 @@ import {
MenuUnfoldOutlined,
} from '@ant-design/icons'
import WangEditorCustomer from '@/components/editor/wangeditor-customer'
import { useLocation, useNavigate } from 'react-router'
import { useDispatch, useSelector } from 'react-redux'
import { setAutosaveTime } from '@/store/modules/editor'
import { setTreeChapter } from '@/store/modules/user'
import { useNavigate, useSearchParams } from 'react-router'
import { useEditorStore } from '@/stores/editorStore'
import { useUserStore } from '@/stores/userStore'
import EditChapterTitle from './components/form-chapter-title'
import EditChapterEditors from './components/EditChapterEditors'
import BindKnowledgeGraph from './components/BindKnowledgeGraph'
import EditorReviewComments from '../../review-comments/views/EditorReviewComments'
import { get } from 'lodash-es'
import md5 from 'js-md5'
import {
......@@ -27,18 +25,21 @@ import {
findNodeById,
} from '@/utils/common'
import { getAllList, getInfoByChapterId, sectionEdit, chapterDel, dragOrder } from './api'
import { getBook } from '../api'
import './index.less'
const Examine = () => {
const { modal } = App.useApp()
const location = useLocation()
const id = get(location, 'state.id', '')
const isAdminOrCreator = get(location, 'state.isAdminOrCreator', false)
const [searchParams] = useSearchParams()
const id = Number(searchParams.get('book_id'))
const { treeChapter, userInfo } = useSelector((state) => state.user)
const treeChapter = useUserStore((state) => state.treeChapter)
const userInfo = useUserStore((state) => state.userInfo)
const setTreeChapter = useUserStore((state) => state.setTreeChapter)
const setAutosaveTime = useEditorStore((state) => state.setAutosaveTime)
const [isAdminOrCreator, setIsAdminOrCreator] = useState(false)
const navigate = useNavigate()
const dispatch = useDispatch()
const [gData, setGData] = useState([])
const [chapterId, setChapterId] = useState(0)
......@@ -57,7 +58,6 @@ const Examine = () => {
const [editValue, setEditValue] = useState('')
const [loading, setLoading] = useState(false)
const [contentMd5, setContentMd5] = useState('')
// 设置编者
const [currentChapter, setCurrentChapter] = useState(null)
const [editChapterEditorsIsOpen, setEditChapterEditorsIsOpen] = useState(false)
......@@ -106,6 +106,18 @@ const Examine = () => {
const [html, setHtml] = useState('')
const [contentId, setContentId] = useState(false)
useEffect(() => {
if (!id || !userInfo?.id) {
setIsAdminOrCreator(false)
return
}
if (userInfo.type === 1) {
setIsAdminOrCreator(true)
return
}
void getBook({ id }).then(({ data }) => setIsAdminOrCreator(data?.user_id === userInfo.id))
}, [id, userInfo?.id, userInfo?.type])
// 获取目录结构
const getChapterTreeList = async (preferredChapterId) => {
setLoading(true)
......@@ -137,7 +149,7 @@ const Examine = () => {
setNowTitle(nextChapter.title)
if (!savedChapterId) {
await dispatch(setTreeChapter({ saveBookId: id, saveChapterId: nextChapter.key }))
setTreeChapter({ saveBookId: id, saveChapterId: nextChapter.key })
}
setLoading(false)
}
......@@ -169,7 +181,7 @@ const Examine = () => {
selectedEditorIds.includes(userInfo.id) || ancestorSelectedEditorIds.includes(userInfo.id)
setDisabled(!canEdit && !isAdminOrCreator)
dispatch(setTreeChapter({ saveBookId: id, saveChapterId: chapterId, ...data }))
setTreeChapter({ saveBookId: id, saveChapterId: chapterId, ...data })
const { content, id: cId } = data
if (content) {
......@@ -189,29 +201,37 @@ const Examine = () => {
const handleSelect = async (selectedKeys, info) => {
if (!info.node) return
const { key, title } = info.node
await selectChapter(key, title)
}
const selectChapter = async (key, title) => {
const nextChapterId = Number(key)
// 保持选中状态,防止重复点击导致选中状态消失
setCheckedKeys([key])
setCheckedKeys([nextChapterId])
// 如果点击的是当前已经选中的章节,且没有发生章节切换,则直接返回
if (key === chapterId) {
if (nextChapterId === Number(chapterId)) {
return
}
setLoading(true)
// 切换章节前清理保存定时器并执行一次保存
clearInterval(saveInterRef.current)
await saveContent()
try {
// 切换章节前清理保存定时器并执行一次保存
clearInterval(saveInterRef.current)
await saveContent()
// 清理旧内容
if (editorRef.current && editorRef.current.editor) {
editorRef.current.editor.clear()
}
// 清理旧内容
if (editorRef.current && editorRef.current.editor) {
editorRef.current.editor.clear()
}
// 设置新章节 ID,触发 useEffect 加载数据
setChapterId(key)
setNowTitle(title)
setLoading(false)
// 设置新章节 ID,触发 useEffect 加载数据
setChapterId(nextChapterId)
setNowTitle(title)
} finally {
setLoading(false)
}
}
useEffect(() => {
......@@ -243,7 +263,7 @@ const Examine = () => {
return
} else {
setContentMd5(newMd5)
dispatch(setAutosaveTime(Date.now()))
setAutosaveTime(Date.now())
}
}
}
......@@ -298,9 +318,7 @@ const Examine = () => {
const data = await chapterDel({ id: node.key })
if (data) {
await dispatch(
setTreeChapter({ saveBookId: id, saveChapterId: current ? current.key : null }),
)
setTreeChapter({ saveBookId: id, saveChapterId: current ? current.key : null })
await getChapterTreeList(current?.key)
}
}
......@@ -526,9 +544,6 @@ const Examine = () => {
{!isCollapse && <div className='resize-handle' onMouseDown={handleMouseDown} />}
<div className='book-content-editor' style={{ flex: 1, minWidth: 0 }}>
<div className='editor-right'>
<div className='editor-review-comment-trigger'>
<EditorReviewComments chapterId={chapterId} html={html} editorRef={editorRef} />
</div>
<Spin spinning={loading}>
<WangEditorCustomer
ref={editorRef}
......@@ -541,6 +556,17 @@ const Examine = () => {
gData={gData}
nowTitle={nowTitle}
disabled={disabled}
reviewCommentPanel={
<EditorReviewComments
bookId={id}
chapterId={chapterId}
html={html}
editorRef={editorRef}
contentId={contentId}
chapterTree={gData}
onSelectChapter={selectChapter}
/>
}
/>
</Spin>
</div>
......
......@@ -78,27 +78,11 @@
.editor-right {
height: 100%;
position: relative;
.editor-review-comment-trigger {
position: absolute;
top: 8px;
right: 12px;
z-index: 5;
}
.ant-spin-nested-loading,
.ant-spin-container {
height: 100%;
}
::highlight(editor-review-comment) {
text-decoration: underline 2px #e3bf4b;
text-underline-offset: 3px;
}
::highlight(editor-review-comment-active) {
background: #fff3c7;
text-decoration: underline 2px #d6a228;
text-underline-offset: 3px;
}
}
}
.draggable-tree {
......@@ -134,99 +118,6 @@
}
}
.editor-review-comment-highlight-switch {
display: inline-flex;
align-items: center;
gap: 8px;
color: #666;
font-size: 13px;
}
.editor-review-comment-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.editor-review-comment-card {
padding: 12px;
background: #fff;
border: 1px solid #ebe7e1;
border-radius: 7px;
&--active {
border-color: #e2b74c;
box-shadow: 0 0 0 2px rgba(226, 183, 76, 0.14);
}
header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
strong,
time {
display: block;
}
time {
margin-top: 2px;
color: #999;
font-size: 12px;
}
}
}
.editor-review-comment-quote {
display: -webkit-box;
width: 100%;
padding: 2px 0 2px 10px;
margin: 0 0 10px;
overflow: hidden;
color: #7a7f89;
font-size: 13px;
line-height: 1.55;
text-align: left;
text-overflow: ellipsis;
cursor: pointer;
background: transparent;
border: 0;
border-left: 2px solid #d8dce4;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
&:hover {
color: #aa1941;
border-left-color: #aa1941;
}
}
.editor-review-comment-content {
color: #333;
line-height: 1.7;
white-space: pre-wrap;
}
.editor-review-comment-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
.ant-image,
img {
object-fit: cover;
border-radius: 4px;
}
}
.editor-review-comment-unmatched {
margin-top: 8px;
color: #c2871b;
font-size: 12px;
}
.chapter-title-modal {
.ant-modal {
.ant-modal-close {
......
......@@ -2,14 +2,15 @@ import { useState, useEffect } from 'react'
import { Button, Form, Input, Flex, message } from 'antd'
import WangEditor from '@/components/editor'
import UploadImage from '@/components/uploadImage'
import { useLocation, useNavigate } from 'react-router'
import { useNavigate, useSearchParams } from 'react-router'
import { addBook, updateBook, getBook } from '../api'
import SelectTeacher from '@/components/selectTeacher'
const BookAddEdit = () => {
const navigate = useNavigate()
const location = useLocation()
const isEdit = !!location.state?.id
const [searchParams] = useSearchParams()
const bookId = Number(searchParams.get('id'))
const isEdit = Boolean(bookId)
const [form] = Form.useForm()
const [img, setImg] = useState('')
......@@ -19,7 +20,7 @@ const BookAddEdit = () => {
// 详情信息
const fetchInfo = async () => {
const { data } = await getBook({ id: location.state.id })
const { data } = await getBook({ id: bookId })
setImg(data.img)
setHtml(data.content)
const formFields = {
......@@ -39,7 +40,7 @@ const BookAddEdit = () => {
const params = { ...values, img, editors }
if (editors.length === 0) delete params.editors
const res = isEdit
? await updateBook({ id: location.state.id, ...params })
? await updateBook({ id: bookId, ...params })
: await addBook(params)
message.success(res.message)
navigate(-1)
......
import { useCallback, useEffect, useRef, useState } from 'react'
import { usePagedList } from '@/hooks/usePagedList'
import { getManagementList, type ManagementListResult, type ManagementRow } from './api'
import { buildManagementQuery, type ManagementFilters, type ManagementQuery } from './query'
const INITIAL_PAGE = 1
const INITIAL_PAGE_SIZE = 10
import { getManagementList, type ManagementRow } from './api'
import { buildManagementQuery, type ManagementFilters } from './query'
function isCleared(previous: ManagementFilters, next: ManagementFilters) {
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]) as Set<
......@@ -14,113 +11,11 @@ function isCleared(previous: ManagementFilters, next: ManagementFilters) {
}
export function useManagementList() {
const [filters, setFilterState] = useState<ManagementFilters>({})
const [page, setPage] = useState(INITIAL_PAGE)
const [pageSize, setPageSize] = useState(INITIAL_PAGE_SIZE)
const [rows, setRows] = useState<ManagementRow[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const requestId = useRef(0)
const lastQuery = useRef<ManagementQuery>(
buildManagementQuery({ page: INITIAL_PAGE, pageSize: INITIAL_PAGE_SIZE, filters: {} }),
)
const load = useCallback(async (query: ManagementQuery) => {
const currentRequest = ++requestId.current
lastQuery.current = query
setLoading(true)
setError(null)
try {
const result: ManagementListResult = await getManagementList(query)
if (currentRequest !== requestId.current) return
setRows(result.rows)
setTotal(result.total)
} catch (reason) {
if (currentRequest === requestId.current) {
setError(reason instanceof Error ? reason : new Error('数据获取失败!'))
}
} finally {
if (currentRequest === requestId.current) setLoading(false)
}
}, [])
const loadFor = useCallback(
(nextPage: number, nextPageSize: number, nextFilters: ManagementFilters) =>
load(buildManagementQuery({ page: nextPage, pageSize: nextPageSize, filters: nextFilters })),
[load],
)
useEffect(() => {
void load(lastQuery.current)
return () => {
requestId.current += 1
}
}, [load])
const setFilters = useCallback(
(nextFilters: ManagementFilters) => {
const shouldReload = isCleared(filters, nextFilters)
setFilterState(nextFilters)
if (shouldReload) void loadFor(page, pageSize, nextFilters)
},
[filters, loadFor, page, pageSize],
)
const search = useCallback(() => {
setPage(INITIAL_PAGE)
void loadFor(INITIAL_PAGE, pageSize, filters)
}, [filters, loadFor, pageSize])
const reset = useCallback(() => {
const emptyFilters: ManagementFilters = {}
setFilterState(emptyFilters)
setPage(INITIAL_PAGE)
void loadFor(INITIAL_PAGE, pageSize, emptyFilters)
}, [loadFor, pageSize])
const refresh = useCallback(() => {
void loadFor(page, pageSize, filters)
}, [filters, loadFor, page, pageSize])
const refreshUnfiltered = useCallback(() => {
void loadFor(page, pageSize, {})
}, [loadFor, page, pageSize])
const changePage = useCallback(
(nextPage: number) => {
setPage(nextPage)
void loadFor(nextPage, pageSize, filters)
},
[filters, loadFor, pageSize],
)
const changePageSize = useCallback(
(nextPageSize: number) => {
setPage(INITIAL_PAGE)
setPageSize(nextPageSize)
void loadFor(INITIAL_PAGE, nextPageSize, filters)
},
[filters, loadFor],
)
const retry = useCallback(() => void load(lastQuery.current), [load])
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset,
refresh,
refreshUnfiltered,
changePage,
changePageSize,
retry,
}
return usePagedList<ManagementRow, ManagementFilters, ReturnType<typeof buildManagementQuery>>({
initialFilters: {},
buildQuery: buildManagementQuery,
request: getManagementList,
shouldReloadOnFilterChange: isCleared,
useAppliedFilters: false,
})
}
......@@ -3,10 +3,11 @@ import { PlusOutlined, UploadOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { useSelector } from 'react-redux'
import { AppList, type AppListFilterItem } from '@/components/AppList'
import { exportHtmlToWord } from '@/utils/exportWordHtmlDocx'
import { usePermission } from '@/hooks/usePermission'
import { useUserStore } from '@/stores/userStore'
import {
deleteManagementBook,
......@@ -23,16 +24,6 @@ import ReleaseBookDrawer, { type ReleaseBookFormValues } from '../components/Rel
import type { ManagementFilters } from '../query'
import { useManagementList } from '../useManagementList'
interface RootState {
user: {
operationPermissionsList?: string[]
userInfo?: {
id?: number
type?: number
}
}
}
const { RangePicker } = DatePicker
const IMAGE_FALLBACK = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='
const EXPORT_REMOVE_SELECTORS = [
......@@ -47,8 +38,8 @@ const EXPORT_REMOVE_SELECTORS = [
export default function ManagementListView() {
const list = useManagementList()
const navigate = useNavigate()
const permissions = useSelector((state: RootState) => state.user.operationPermissionsList ?? [])
const userInfo = useSelector((state: RootState) => state.user.userInfo ?? {})
const checkPermission = usePermission()
const userInfo = useUserStore((state) => state.userInfo)
const [selectedId, setSelectedId] = useState<number | null>(null)
const [deleteOpen, setDeleteOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
......@@ -151,27 +142,23 @@ export default function ManagementListView() {
const isAdminOrCreator = userInfo.id === record.user_id || userInfo.type === 1
return (
<Space>
{isAdminOrCreator && permissions.includes('/books/management/getAllList') ? (
{isAdminOrCreator && checkPermission('/books/management/getAllList') ? (
<Button
type='primary'
ghost
onClick={() => navigate('/books/management/add-edit', { state: { id: record.id } })}
onClick={() => navigate(`/books/management/add-edit?id=${record.id}`)}
>
基本信息
</Button>
) : null}
{permissions.includes('/books/management/getInfoById') ? (
{checkPermission('/books/management/getInfoById') ? (
<Button
onClick={() =>
navigate('/books/management/chapter', {
state: { id: record.id, isAdminOrCreator },
})
}
onClick={() => navigate(`/books/management/chapter?book_id=${record.id}`)}
>
编辑
</Button>
) : null}
{isAdminOrCreator && permissions.includes('/books/management/export') ? (
{isAdminOrCreator && checkPermission('/books/management/export') ? (
<Button
onClick={() => {
setSelectedId(record.id)
......@@ -181,7 +168,7 @@ export default function ManagementListView() {
导出
</Button>
) : null}
{isAdminOrCreator && permissions.includes('/books/management/del') ? (
{isAdminOrCreator && checkPermission('/books/management/del') ? (
<Button
onClick={() => {
setSelectedId(record.id)
......@@ -192,7 +179,7 @@ export default function ManagementListView() {
</Button>
) : null}
{isAdminOrCreator &&
permissions.includes('/books/management/release') &&
checkPermission('/books/management/release') &&
record.audit_status === 1 ? (
<Button
onClick={() => {
......@@ -273,12 +260,12 @@ export default function ManagementListView() {
const toolbar = (
<Space>
{permissions.includes('/books/management/import') ? (
{checkPermission('/books/management/import') ? (
<Button type='primary' ghost icon={<UploadOutlined />} onClick={() => setImportOpen(true)}>
导入
</Button>
) : null}
{permissions.includes('/books/management/add') ? (
{checkPermission('/books/management/add') ? (
<Button
type='primary'
ghost
......
import { Segmented } from 'antd'
import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { Outlet, useLocation, useNavigate } from 'react-router'
interface RootState {
user: {
operationPermissionsList?: string[]
}
}
import { usePermission } from '@/hooks/usePermission'
export default function OrdersLayout() {
const permissions = useSelector((state: RootState) => state.user.operationPermissionsList ?? [])
const checkPermission = usePermission()
const navigate = useNavigate()
const location = useLocation()
const [selectedOption, setSelectedOption] = useState(
......@@ -28,16 +22,16 @@ export default function OrdersLayout() {
let options: { label: string; value: number }[] = []
if (
permissions.includes('/books/order-management/getOrderList') &&
permissions.includes('/books/order-management/salesStatistics')
checkPermission('/books/order-management/getOrderList') &&
checkPermission('/books/order-management/salesStatistics')
) {
options = [
{ label: '订单列表', value: 1 },
{ label: '销售统计', value: 2 },
]
} else if (permissions.includes('/books/order-management/getOrderList1')) {
} else if (checkPermission('/books/order-management/getOrderList1')) {
options = [{ label: '订单列表', value: 1 }]
} else if (permissions.includes('/books/order-management/salesStatistics')) {
} else if (checkPermission('/books/order-management/salesStatistics')) {
options = [{ label: '销售统计', value: 2 }]
}
......
import { useCallback, useEffect, useRef, useState } from 'react'
import { usePagedList } from '@/hooks/usePagedList'
import { getOrders, type OrderRow, type OrdersListResult } from './api'
import { buildOrdersQuery, type OrdersFilters, type OrdersQuery } from './query'
import { getOrders, type OrderRow } from './api'
import { buildOrdersQuery, type OrdersFilters } from './query'
const INITIAL_PAGE = 1
const INITIAL_PAGE_SIZE = 10
function clearedFilter(previous: OrdersFilters, next: OrdersFilters) {
function isCleared(previous: OrdersFilters, next: OrdersFilters) {
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]) as Set<keyof OrdersFilters>
return [...keys].some((key) => previous[key] !== next[key] && !next[key])
}
export function useOrdersList() {
const [filters, setFilterState] = useState<OrdersFilters>({})
const [page, setPage] = useState(INITIAL_PAGE)
const [pageSize, setPageSize] = useState(INITIAL_PAGE_SIZE)
const [rows, setRows] = useState<OrderRow[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const requestId = useRef(0)
const lastQuery = useRef<OrdersQuery>(
buildOrdersQuery({ page: INITIAL_PAGE, pageSize: INITIAL_PAGE_SIZE, filters: {} }),
)
const load = useCallback(async (query: OrdersQuery) => {
const currentRequest = ++requestId.current
lastQuery.current = query
setLoading(true)
setError(null)
try {
const result: OrdersListResult = await getOrders(query)
if (currentRequest !== requestId.current) return
setRows(result.rows)
setTotal(result.total)
} catch (reason) {
if (currentRequest === requestId.current) {
setError(reason instanceof Error ? reason : new Error('数据获取失败!'))
}
} finally {
if (currentRequest === requestId.current) setLoading(false)
}
}, [])
const loadFor = useCallback(
(nextPage: number, nextPageSize: number, nextFilters: OrdersFilters) =>
load(buildOrdersQuery({ page: nextPage, pageSize: nextPageSize, filters: nextFilters })),
[load],
)
useEffect(() => {
void load(lastQuery.current)
return () => {
requestId.current += 1
}
}, [load])
const setFilters = useCallback(
(nextFilters: OrdersFilters) => {
const shouldReload = clearedFilter(filters, nextFilters)
setFilterState(nextFilters)
if (shouldReload) void loadFor(page, pageSize, nextFilters)
},
[filters, loadFor, page, pageSize],
)
const search = useCallback(() => {
setPage(INITIAL_PAGE)
void loadFor(INITIAL_PAGE, pageSize, filters)
}, [filters, loadFor, pageSize])
const reset = useCallback(() => {
setFilterState({})
setPage(INITIAL_PAGE)
void loadFor(INITIAL_PAGE, pageSize, {})
}, [loadFor, pageSize])
const refresh = useCallback(() => {
void loadFor(page, pageSize, filters)
}, [filters, loadFor, page, pageSize])
const refreshUnfiltered = useCallback(() => {
void loadFor(page, pageSize, {})
}, [loadFor, page, pageSize])
const changePage = useCallback(
(nextPage: number) => {
setPage(nextPage)
void loadFor(nextPage, pageSize, filters)
},
[filters, loadFor, pageSize],
)
const changePageSize = useCallback(
(nextPageSize: number) => {
setPage(INITIAL_PAGE)
setPageSize(nextPageSize)
void loadFor(INITIAL_PAGE, nextPageSize, filters)
},
[filters, loadFor],
)
const retry = useCallback(() => void load(lastQuery.current), [load])
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset,
refresh,
refreshUnfiltered,
changePage,
changePageSize,
retry,
}
return usePagedList<OrderRow, OrdersFilters, ReturnType<typeof buildOrdersQuery>>({
initialFilters: {},
buildQuery: buildOrdersQuery,
request: getOrders,
shouldReloadOnFilterChange: isCleared,
useAppliedFilters: false,
})
}
import { Button, DatePicker, Input, Rate, Select } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useState } from 'react'
import { useSelector } from 'react-redux'
import { AppList, type AppListFilterItem } from '@/components/AppList'
import { usePermission } from '@/hooks/usePermission'
import { refundOrder, type OrderRow } from '../api'
import RefundDrawer from '../components/RefundDrawer'
import type { OrdersFilters } from '../query'
import { useOrdersList } from '../useOrdersList'
interface RootState {
user: { operationPermissionsList?: string[] }
}
const { RangePicker } = DatePicker
const wechatIcon = new URL('../../../../assets/images/vx.png', import.meta.url).href
const alipayIcon = new URL('../../../../assets/images/zhifu.png', import.meta.url).href
......@@ -21,7 +17,7 @@ const coinIcon = new URL('../../../../assets/images/money.png', import.meta.url)
export default function OrdersListView() {
const list = useOrdersList()
const permissions = useSelector((state: RootState) => state.user.operationPermissionsList ?? [])
const checkPermission = usePermission()
const [refundId, setRefundId] = useState<number | null>(null)
const submitRefund = async ({ refund_reason }: { refund_reason?: string }) => {
......@@ -93,7 +89,7 @@ export default function OrdersListView() {
dataIndex: 'handle',
align: 'center',
render: (_, record) =>
permissions.includes('/books/order-management/refund') && Number(record.status) === 3 ? (
checkPermission('/books/order-management/refund') && Number(record.status) === 3 ? (
<Button onClick={() => setRefundId(record.id)}>退款</Button>
) : null,
},
......
import { useCallback, useEffect, useRef, useState } from 'react'
import { usePagedList } from '@/hooks/usePagedList'
import { getQuestionList } from './api'
import { buildQuestionBankQuery, type QuestionBankFilters, type QuestionBankQuery } from './query'
import { buildQuestionBankQuery, type QuestionBankFilters } from './query'
async function requestQuestionBankList(query: ReturnType<typeof buildQuestionBankQuery>) {
const result: any = await getQuestionList(query)
if (!result) throw new Error('请求失败')
return { rows: result.list || [], total: result.total || 0 }
}
export function useQuestionBankList() {
const [filters, setFilters] = useState<QuestionBankFilters>({})
const [applied, setApplied] = useState<QuestionBankFilters>({})
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [rows, setRows] = useState<any[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const last = useRef<QuestionBankQuery>(buildQuestionBankQuery(1, 10, {}))
const load = useCallback(async (query: QuestionBankQuery) => {
last.current = query
setLoading(true)
setError(null)
try {
const result: any = await getQuestionList(query)
if (!result) throw new Error('请求失败')
setRows(result.list || [])
setTotal(result.total || 0)
} catch (e) {
setError(e instanceof Error ? e : new Error('请求失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load(last.current)
}, [load])
const run = (p: number, s: number, f: QuestionBankFilters) =>
void load(buildQuestionBankQuery(p, s, f))
const search = () => {
setApplied(filters)
setPage(1)
run(1, pageSize, filters)
}
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset: () => {
setFilters({})
setApplied({})
setPage(1)
run(1, pageSize, {})
},
refresh: () => {
setApplied(filters)
run(page, pageSize, filters)
},
retry: () => void load(last.current),
reloadFirst: search,
changePage: (p: number) => {
setPage(p)
run(p, pageSize, applied)
},
changePageSize: (s: number) => {
setPage(1)
setPageSize(s)
run(1, s, applied)
},
}
const list = usePagedList<
any,
QuestionBankFilters,
ReturnType<typeof buildQuestionBankQuery>
>({
initialFilters: {},
buildQuery: ({ page, pageSize, filters }) => buildQuestionBankQuery(page, pageSize, filters),
request: requestQuestionBankList,
})
return { ...list, reloadFirst: list.search }
}
......@@ -14,9 +14,9 @@ import {
import { PlusOutlined, UploadOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { AppList, type AppListFilterItem } from '@/components/AppList'
import { downloadFile } from '@/utils/common'
import { usePermission } from '@/hooks/usePermission'
import {
deleteQuestion,
getBookNames,
......@@ -32,7 +32,7 @@ const questionStyles = ['-', '单选题', '多选题', '判断题', '填空题',
export default function QuestionBankView() {
const list = useQuestionBankList()
const { modal } = App.useApp()
const { operationPermissionsList = [] } = useSelector((state: any) => state.user)
const checkPermission = usePermission()
const [types, setTypes] = useState<any[]>([])
const [books, setBooks] = useState<any[]>([])
const [formOpen, setFormOpen] = useState(false)
......@@ -178,10 +178,10 @@ export default function QuestionBankView() {
title: '操作',
render: (_, row) => (
<Space>
{operationPermissionsList.includes('/books/question-bank/getInfoById') && (
{checkPermission('/books/question-bank/getInfoById') && (
<Button onClick={() => edit(row.id)}>编辑</Button>
)}
{operationPermissionsList.includes('/books/question-bank/del') && (
{checkPermission('/books/question-bank/del') && (
<Button
onClick={() =>
modal.confirm({
......@@ -234,7 +234,7 @@ export default function QuestionBankView() {
}}
toolbar={
<Space>
{operationPermissionsList.includes('/books/question-bank/add') && (
{checkPermission('/books/question-bank/add') && (
<Button
type='primary'
ghost
......@@ -247,7 +247,7 @@ export default function QuestionBankView() {
添加
</Button>
)}
{operationPermissionsList.includes('/books/question-bank/import') && (
{checkPermission('/books/question-bank/import') && (
<Button
type='primary'
ghost
......
......@@ -22,14 +22,44 @@ describe('review annotation text anchors', () => {
})
})
it('does not resolve an ambiguous quote without matching context', () => {
it('uses the saved offset when repeated text still exists at that position', () => {
expect(
findTextAnchor('审核内容和审核内容', {
quote: '审核',
prefix: '',
suffix: '',
start_offset: 0,
end_offset: 2,
start_offset: 5,
end_offset: 7,
}),
).toEqual({
start_offset: 5,
end_offset: 7,
})
})
it('uses the unique quote nearest to the saved offset when context changed', () => {
expect(
findTextAnchor('前审核内容,新增审核内容', {
quote: '审核',
prefix: '旧',
suffix: '上下文',
start_offset: 8,
end_offset: 10,
}),
).toEqual({
start_offset: 8,
end_offset: 10,
})
})
it('does not resolve equidistant repeated text without matching context', () => {
expect(
findTextAnchor('审核__审核', {
quote: '审核',
prefix: '旧',
suffix: '上下文',
start_offset: 2,
end_offset: 4,
}),
).toBeNull()
})
......
......@@ -26,6 +26,16 @@ export function createTextAnchor(text: string, startOffset: number, endOffset: n
export function findTextAnchor(text: string, anchor: TextAnchor): TextAnchorRange | null {
if (!anchor.quote) return null
if (
anchor.start_offset >= 0 &&
text.slice(anchor.start_offset, anchor.start_offset + anchor.quote.length) === anchor.quote
) {
return {
start_offset: anchor.start_offset,
end_offset: anchor.start_offset + anchor.quote.length,
}
}
const candidates: number[] = []
let start = text.indexOf(anchor.quote)
while (start >= 0) {
......@@ -43,12 +53,17 @@ export function findTextAnchor(text: string, anchor: TextAnchor): TextAnchorRang
(!anchor.prefix || prefix === anchor.prefix) && (!anchor.suffix || suffix === anchor.suffix)
)
})
const resolved =
matchingContext.length === 1
? matchingContext[0]
: candidates.length === 1
? candidates[0]
: undefined
let resolved = matchingContext.length === 1 ? matchingContext[0] : undefined
if (resolved === undefined && candidates.length === 1) resolved = candidates[0]
if (resolved === undefined && candidates.length > 1) {
const distances = candidates.map((offset) => ({
offset,
distance: Math.abs(offset - anchor.start_offset),
}))
const nearestDistance = Math.min(...distances.map((item) => item.distance))
const nearest = distances.filter((item) => item.distance === nearestDistance)
if (nearest.length === 1) resolved = nearest[0].offset
}
return resolved === undefined
? null
......
import axios from '@/utils/axios'
async function post(path: string, data: Record<string, unknown>) {
const response = await axios.post(path, data)
return { data: response.data, msg: response.message || '' }
import type { AddReviewCommentPayload, ReplyReviewCommentPayload, ReviewComment } from './types'
async function post<T>(path: string, data: unknown): Promise<T> {
const response = await axios.post<T>(path, data)
return response.data
}
export const getReviewComments = async (data: Record<string, unknown>) =>
(await post('/book/teacher/review-comment/getList', data)).data
export const getReviewComments = (bookId: number, chapterId?: number) =>
post<ReviewComment[]>('/book/teacher/review-comment/getList', {
book_id: bookId,
...(chapterId ? { chapter_id: chapterId } : {}),
})
export const getCurrentReviewComments = (bookId: number, chapterId?: number) =>
post<ReviewComment[]>('/book/teacher/review-comment/current', {
book_id: bookId,
...(chapterId ? { chapter_id: chapterId } : {}),
})
export const addReviewComment = (data: AddReviewCommentPayload) =>
post<{ id: number }>('/book/teacher/review-comment/add', data)
export const deleteReviewComment = (id: number) =>
post<void>('/book/teacher/review-comment/del', { id })
export const resolveReviewComment = (id: number) =>
post<void>('/book/teacher/review-comment/resolve', { id })
export const addReviewComment = (data: Record<string, unknown>) =>
post('/book/teacher/review-comment/add', data)
export const reopenReviewComment = (id: number) =>
post<void>('/book/teacher/review-comment/reopen', { id })
export const deleteReviewComment = (id: number) => post('/book/teacher/review-comment/del', { id })
export const replyReviewComment = (data: ReplyReviewCommentPayload) =>
post<{ id: number }>('/book/teacher/review-comment/reply', data)
export const getCurrentReviewComments = async (chapterId: number) =>
(await post('/book/teacher/review-comment/current', { chapter_id: chapterId })).data
export const deleteReviewCommentReply = (id: number) =>
post<void>('/book/teacher/review-comment/reply/del', { id })
import { CheckCircleOutlined, DeleteOutlined, UndoOutlined } from '@ant-design/icons'
import { Avatar, Image, Popconfirm, Tooltip } from 'antd'
import { useUserStore } from '@/stores/userStore'
import type {
ChapterLocation,
ReplyReviewCommentPayload,
ReviewComment,
ReviewCommentActor,
ReviewCommentScope,
} from '../types'
import { formatReviewCommentTime, getReviewCommentDateTime } from '../time'
import ReviewCommentThread from './ReviewCommentThread'
import '../review-comment-card.less'
interface ReplyTarget {
id: number
name: string
}
interface ReviewCommentCardProps {
comment: ReviewComment
actor: ReviewCommentActor
scope: ReviewCommentScope
location?: ChapterLocation
active?: boolean
matched?: boolean
replyOpen: boolean
replyTarget?: ReplyTarget
onFocusQuote?: (comment: ReviewComment) => void
onSelectChapter?: (comment: ReviewComment) => void
onRemove?: (id: number) => Promise<void>
onResolve?: (id: number) => Promise<void>
onReopen?: (id: number) => Promise<void>
onOpenReply: (target?: ReplyTarget) => void
onCloseReply: () => void
onReply: (payload: ReplyReviewCommentPayload) => Promise<void>
onDeleteReply: (id: number) => Promise<void>
}
export default function ReviewCommentCard({
comment,
actor,
scope,
location,
active,
matched = true,
replyOpen,
replyTarget,
onFocusQuote,
onSelectChapter,
onRemove,
onResolve,
onReopen,
onOpenReply,
onCloseReply,
onReply,
onDeleteReply,
}: ReviewCommentCardProps) {
const userInfo = useUserStore((state) => state.userInfo)
const resolved = Number(comment.resolve_status) === 1
return (
<article
className={`review-comment-card${comment.quote ? ' review-comment-card--anchored' : ''}${active ? ' review-comment-card--active' : ''}${resolved ? ' review-comment-card--resolved' : ''}`}
>
<header className='review-comment-card__header'>
<Avatar size={30} src={comment.creator_pic}>
{(comment.creator_name || '审').slice(0, 1)}
</Avatar>
<div>
<strong>{comment.creator_name || '审核员'}</strong>
<time dateTime={getReviewCommentDateTime(comment.create_time)}>
{formatReviewCommentTime(comment.create_time)}
</time>
</div>
</header>
<div className='review-comment-card__toolbar'>
{resolved ? (
<Tooltip
title={
comment.resolved_name
? `${comment.resolved_name} · ${formatReviewCommentTime(comment.resolved_time)}`
: '已解决'
}
>
<span className='review-comment-card__status'>已解决</span>
</Tooltip>
) : null}
{actor === 'reviewer' && resolved && onReopen ? (
<Tooltip title='重新打开'>
<button
type='button'
className='review-comment-card__tool review-comment-card__tool--resolve'
aria-label='重新打开评论'
onClick={() => void onReopen(Number(comment.id))}
>
<UndoOutlined />
</button>
</Tooltip>
) : null}
{actor === 'reviewer' && !resolved && onResolve ? (
<Tooltip title='解决并隐藏'>
<button
type='button'
className='review-comment-card__tool review-comment-card__tool--resolve'
aria-label='解决并隐藏'
onClick={() => void onResolve(Number(comment.id))}
>
<CheckCircleOutlined />
</button>
</Tooltip>
) : null}
{actor === 'reviewer' && onRemove && Number(comment.created_id) === Number(userInfo.id) ? (
<Popconfirm
title='确定删除这条评论及全部回复吗?'
onConfirm={() => void onRemove(Number(comment.id))}
>
<button
type='button'
className='review-comment-card__tool review-comment-card__tool--delete'
aria-label='删除评论'
>
<DeleteOutlined />
</button>
</Popconfirm>
) : null}
</div>
<div className='review-comment-card__content'>
{scope === 'book' && (location || comment.chapter_name) ? (
<button
type='button'
className='review-comment-card__chapter'
onClick={() => onSelectChapter?.(comment)}
>
{location?.path || comment.chapter_name}
</button>
) : null}
{comment.quote ? (
<button
type='button'
className='review-comment-card__quote'
onClick={() => onFocusQuote?.(comment)}
title={comment.quote}
>
{comment.quote}
</button>
) : null}
{comment.content ? (
<div className='review-comment-card__text'>{comment.content}</div>
) : null}
{comment.images.length ? (
<Image.PreviewGroup>
<div className='review-comment-card__images'>
{comment.images.map((url) => (
<Image key={url} src={url} width={88} height={88} />
))}
</div>
</Image.PreviewGroup>
) : null}
{comment.quote && !matched ? (
<div className='review-comment-card__unmatched'>原文已变更,无法定位</div>
) : null}
</div>
<ReviewCommentThread
comment={comment}
open={replyOpen}
replyTarget={replyTarget}
disabled={resolved}
onOpen={onOpenReply}
onClose={onCloseReply}
onReply={onReply}
onDeleteReply={onDeleteReply}
/>
</article>
)
}
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论