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

feat: 新增APP配置,升级Vite并重命名Web端目录

上级 a9f6b8ac
...@@ -2,20 +2,20 @@ ...@@ -2,20 +2,20 @@
## Project Structure & Module Organization ## Project Structure & Module Organization
This pnpm monorepo contains two independent React 19 apps: `apps/admin` (JavaScript/JSX) and `apps/learning` (TypeScript). Each app owns its routes, API clients, state, assets, and tests; do not import from another app’s `src` directory. Within an app, place domain features in `src/modules/<domain>/`, reusable UI in `src/components/`, and cross-cutting hooks and utilities in `src/hooks/` and `src/utils/`. Shared code belongs in `packages/` only when both apps use it. Admin WangEditor features stay under `apps/admin/src/components/editor/wangeditor-customer/features/`. This pnpm monorepo contains two independent React 19 apps: `apps/admin` (JavaScript/JSX) and `apps/web` (TypeScript). Each app owns its routes, API clients, state, assets, and tests; do not import from another app’s `src` directory. Within an app, place domain features in `src/modules/<domain>/`, reusable UI in `src/components/`, and cross-cutting hooks and utilities in `src/hooks/` and `src/utils/`. Shared code belongs in `packages/` only when both apps use it. Admin WangEditor features stay under `apps/admin/src/components/editor/wangeditor-customer/features/`.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `pnpm dev` starts both Vite apps; use `pnpm dev:admin` or `pnpm dev:learning` for one app. - `pnpm dev` starts both Vite apps; use `pnpm dev:admin` or `pnpm dev:web` for one app.
- `pnpm build` builds Learning and then Admin; `pnpm build:learning` and `pnpm build:admin` build separately. - `pnpm build` builds Web and then Admin; `pnpm build:web` and `pnpm build:admin` build separately.
- `pnpm test` runs both app test suites; `pnpm test:learning` and `pnpm test:admin` run one suite. - `pnpm test` runs both app test suites; `pnpm test:web` and `pnpm test:admin` run one suite.
- `pnpm lint:check` runs read-only Oxlint checks. `pnpm lint:learning` and `pnpm lint:admin` apply fixes, so inspect the diff afterward. - `pnpm lint:check` runs read-only Oxlint checks. `pnpm lint:web` and `pnpm lint:admin` apply fixes, so inspect the diff afterward.
- `pnpm typecheck:learning` runs the strict Learning TypeScript check. - `pnpm typecheck:web` runs the strict Web TypeScript check.
- Format an app with `pnpm --filter @ebook/learning format` or `pnpm --filter @ebook/admin format`. - Format an app with `pnpm --filter @ebook/web format` or `pnpm --filter @ebook/admin format`.
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
Use two-space indentation, single quotes, and no semicolons; Oxfmt is authoritative. Prefer functional React components. Name components `PascalCase`, hooks `useXxx`, and utilities `camelCase`. Keep Learning TypeScript-first, avoid `any`, put server state in React Query, URL state in nuqs, and shared transient state in Zustand. Follow the surrounding JavaScript/JSX style in Admin. Both apps use Ant Design 6; check existing components and tokens before adding custom UI. Use two-space indentation, single quotes, and no semicolons; Oxfmt is authoritative. Prefer functional React components. Name components `PascalCase`, hooks `useXxx`, and utilities `camelCase`. Keep Web TypeScript-first, avoid `any`, put server state in React Query, URL state in nuqs, and shared transient state in Zustand. Follow the surrounding JavaScript/JSX style in Admin. Both apps use Ant Design 6; check existing components and tokens before adding custom UI.
## Testing Guidelines ## Testing Guidelines
......
...@@ -17,6 +17,9 @@ ...@@ -17,6 +17,9 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@chuangkit/chuangkit-design": "^2.0.8", "@chuangkit/chuangkit-design": "^2.0.8",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@ezijing/ai-react": "^1.0.40", "@ezijing/ai-react": "^1.0.40",
"@fortaine/fetch-event-source": "^3.0.6", "@fortaine/fetch-event-source": "^3.0.6",
"@remixicon/react": "^4.9.0", "@remixicon/react": "^4.9.0",
...@@ -58,7 +61,7 @@ ...@@ -58,7 +61,7 @@
"oxfmt": "^0.64.0", "oxfmt": "^0.64.0",
"oxlint": "^1.79.0", "oxlint": "^1.79.0",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.2.2", "vite": "^8.3.4",
"vite-plugin-mkcert": "^2.1.0", "vite-plugin-mkcert": "^2.1.0",
"vitest": "^4.1.10" "vitest": "^4.1.10"
} }
......
import { Button, Upload, message } from 'antd' import { Button, Spin, Tooltip, Upload, message } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { useState } from 'react' import { useState } from 'react'
import { uploadFile } from '@/utils/oss' import { uploadFile } from '@/utils/oss'
import './index.less' import './index.less'
export default function UploadImage({ value, onChange }) { export default function UploadImage({ value, onChange, compact }) {
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const uploadProps = { const uploadProps = {
...@@ -36,6 +37,22 @@ export default function UploadImage({ value, onChange }) { ...@@ -36,6 +37,22 @@ export default function UploadImage({ value, onChange }) {
}, },
} }
if (compact) {
let content = <PlusOutlined />
if (uploading) content = <Spin size='small' />
else if (value) content = <img src={value} alt='' />
return (
<Tooltip title='点击上传图标,jpg/png,不超过1M'>
<Upload {...uploadProps}>
<div className='upload-image__compact' data-filled={value ? 'true' : 'false'}>
{content}
</div>
</Upload>
</Tooltip>
)
}
return ( return (
<div className='upload-image'> <div className='upload-image'>
{value && <img src={value} width={88} height={88} style={{ borderRadius: 5 }} />} {value && <img src={value} width={88} height={88} style={{ borderRadius: 5 }} />}
......
...@@ -12,4 +12,42 @@ ...@@ -12,4 +12,42 @@
font-size: 12px; font-size: 12px;
} }
} }
&__compact {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
overflow: hidden;
color: rgb(0 0 0 / 45%);
background-color: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.2s;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&:hover {
border-color: #aa1941;
}
&[data-filled='true']:hover::after {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 12px;
content: '替换';
background-color: rgb(0 0 0 / 45%);
}
}
} }
...@@ -115,8 +115,10 @@ export default function AdvertFormDrawer({ ...@@ -115,8 +115,10 @@ export default function AdvertFormDrawer({
> >
<RangePicker /> <RangePicker />
</Form.Item> </Form.Item>
<Form.Item name='url' label='广告链接地址' rules={[{ max: 500, message: '最大长度500' }]}> <Form.Item name='url' label='广告链接地址' rules={[
<Input autoComplete='off' placeholder='请输入广告链接地址' /> { max: 500, message: '最大长度500' },
]}>
<Input autoComplete='off' placeholder='请输入网页或应用链接(可留空)' />
</Form.Item> </Form.Item>
<Form.Item wrapperCol={{ offset: 11, span: 16 }}> <Form.Item wrapperCol={{ offset: 11, span: 16 }}>
<Space size={20}> <Space size={20}>
......
import axios from '@/utils/axios'
export async function getHomeConfig() {
const response = await axios.post('/api/admin/system/portal/getInfo', {})
return response.data
}
export async function saveHomeConfig(config) {
return axios.post('/api/admin/system/portal/save', config)
}
export async function getProductsConfig() {
const response = await axios.post('/api/admin/system/portal/getProducts', {})
return response.data
}
export function saveProductsConfig(config) {
return axios.post('/api/admin/system/portal/saveProducts', config)
}
export async function getAchievementsConfig() {
const response = await axios.post('/api/admin/system/portal/getAchievements', {})
return response.data
}
export function saveAchievementsConfig(config) {
return axios.post('/api/admin/system/portal/saveAchievements', config)
}
import { Form, Input, Modal, Select, Switch } from 'antd'
import UploadImage from '@/components/uploadImage'
export default function HomeConfigEditModal({
editing,
form,
onCancel,
onOk,
entryLabel = '入口',
}) {
const isModule = editing?.kind === 'module'
const action = editing?.index === null ? '新增' : '编辑'
const title = `${action}${isModule ? '模块' : entryLabel}`
const nameLabel = isModule ? '模块名称' : `${entryLabel}名称`
return (
<Modal
title={title}
open={!!editing}
onCancel={onCancel}
onOk={onOk}
okText='确定'
cancelText='取消'
width={560}
>
<Form form={form} layout='vertical'>
{!isModule && (
<Form.Item name='icon' label='图标' className='portal-preview__icon-upload'>
<UploadImage compact />
</Form.Item>
)}
<Form.Item
name='name'
label={nameLabel}
rules={[{ required: true, whitespace: true, message: '请填写名称' }]}
>
<Input maxLength={100} />
</Form.Item>
{isModule ? (
<>
<Form.Item
name='style'
label='展示样式'
rules={[{ required: true, message: '请选择展示样式' }]}
>
<Select
options={[
{ value: 'grid', label: '图标网格' },
{ value: 'cards', label: '图片卡片' },
]}
/>
</Form.Item>
<Form.Item name='more_name' label='标题右侧名称'>
<Input />
</Form.Item>
<Form.Item name='more_target' label='标题右侧链接'>
<Input />
</Form.Item>
</>
) : (
<>
<Form.Item name='description' label='介绍'>
<Input.TextArea rows={2} maxLength={300} />
</Form.Item>
<Form.Item name='target' label='跳转链接'>
<Input />
</Form.Item>
<Form.Item name='require_login' label='需要登录' valuePropName='checked'>
<Switch />
</Form.Item>
</>
)}
<Form.Item name='enabled' label='启用' valuePropName='checked'>
<Switch />
</Form.Item>
</Form>
</Modal>
)
}
import { Alert, Button, Drawer, Input, Space } from 'antd'
import { CodeOutlined } from '@ant-design/icons'
export default function HomeConfigJsonDrawer({ open, text, error, onClose, onApply, onChange }) {
return (
<Drawer
title={
<Space>
<CodeOutlined />
<span>编辑首页配置 JSON</span>
</Space>
}
open={open}
size={880}
className='portal-json'
onClose={onClose}
destroyOnHidden
footer={
<div className='portal-json__footer'>
<Space>
<Button onClick={onClose}>取消</Button>
<Button type='primary' onClick={onApply}>
应用到表单
</Button>
</Space>
</div>
}
>
<Input.TextArea
className='portal-json__input'
aria-label='首页配置 JSON'
value={text}
onChange={(event) => onChange(event.target.value)}
spellCheck={false}
/>
{error && <Alert type='error' title='JSON 格式有误' description={error} showIcon />}
</Drawer>
)
}
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
useSortable,
sortableKeyboardCoordinates,
rectSortingStrategy,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
export function SortableList({ items, onChange, vertical = false, children }) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (!over || active.id === over.id) return
onChange(
arrayMove(
items,
items.findIndex((item) => item._key === active.id),
items.findIndex((item) => item._key === over.id),
),
)
}}
>
<SortableContext
items={items.map((item) => item._key)}
strategy={vertical ? verticalListSortingStrategy : rectSortingStrategy}
>
{children}
</SortableContext>
</DndContext>
)
}
export function SortableItem({ id, disabled, className, children }) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled })
return (
<div
ref={setNodeRef}
className={className}
style={{
transform: CSS.Transform.toString(transform),
transition,
position: 'relative',
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.8 : undefined,
}}
>
{children({ ref: setActivatorNodeRef, ...attributes, ...listeners })}
</div>
)
}
import { lazy } from 'react'
import { Navigate, useRoutes } from 'react-router'
const AppConfigView = lazy(() => import('./views/AppConfigView'))
export default function PortalRoutes() {
return useRoutes([
{ index: true, Component: AppConfigView },
{ path: 'products', element: <Navigate replace to='/setting/portal?tab=products' /> },
{ path: 'achievements', element: <Navigate replace to='/setting/portal?tab=achievements' /> },
{ path: '*', element: <Navigate replace to='/setting/portal' /> },
])
}
import { useEffect, useState } from 'react'
import { nanoid } from 'nanoid'
import { Button, Form, Input, Modal, Popconfirm, Space, Spin, Switch, Tag, message } from 'antd'
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import { usePermission } from '@/hooks/usePermission'
import { getAchievementsConfig, saveAchievementsConfig } from '../api'
import { SortableList, SortableItem } from '../components/SortableList'
import './homeConfig.less'
export default function AchievementsConfigView() {
const [categories, setCategories] = useState([])
const [editing, setEditing] = useState(null)
const [form] = Form.useForm()
const [loading, setLoading] = useState(true)
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState(false)
const allowed = usePermission()
const canSave = allowed('/setting/portal/achievements/save')
const canEdit = canSave && loaded && !saving
const title = editing?.index === null ? '新增栏目' : '编辑栏目'
useEffect(() => {
getAchievementsConfig()
.then((config) => {
setCategories(
config.categories.map((category) => ({ enabled: 1, ...category, _key: nanoid() })),
)
setLoaded(true)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const edit = (index = null) => {
form.resetFields()
const category = index === null ? { name: '', type_tag: '', enabled: 1 } : categories[index]
form.setFieldsValue({ ...category, enabled: !!category.enabled })
setEditing({ index })
}
const apply = async () => {
const values = await form.validateFields()
values.enabled = Number(values.enabled)
setCategories((current) =>
editing.index === null
? [...current, { ...values, _key: nanoid() }]
: current.map((category, index) =>
index === editing.index ? { ...category, ...values } : category,
),
)
setEditing(null)
}
const save = async () => {
setSaving(true)
try {
await saveAchievementsConfig({
categories: categories.map(({ _key, ...category }) => category),
})
message.success('已保存')
} catch {
} finally {
setSaving(false)
}
}
return (
<div className='portal-preview'>
{loading ? (
<Spin />
) : (
<div className='portal-preview__canvas'>
<SortableList items={categories} onChange={setCategories} vertical>
{categories.map((category, index) => (
<SortableItem
key={category._key}
id={category._key}
disabled={!canEdit}
className='portal-preview__module'
>
{(handle) => (
<div className='portal-preview__heading' style={{ marginBottom: 0 }}>
<h3
className={canEdit ? 'portal-preview__module-title' : undefined}
{...handle}
>
{category.name}
</h3>
{!category.enabled && <Tag>已停用</Tag>}
<span className='portal-preview__more'>{category.type_tag}</span>
{canEdit && (
<Space className='portal-preview__module-actions' size={0}>
<Button
type='text'
icon={<EditOutlined />}
aria-label='编辑栏目'
onClick={() => edit(index)}
/>
<Popconfirm
title='删除此栏目?'
onConfirm={() =>
setCategories((current) => current.filter((_, i) => i !== index))
}
>
<Button type='text' icon={<DeleteOutlined />} aria-label='删除栏目' />
</Popconfirm>
</Space>
)}
</div>
)}
</SortableItem>
))}
</SortableList>
{canEdit && (
<Button block type='dashed' icon={<PlusOutlined />} onClick={() => edit()}>
新增栏目
</Button>
)}
</div>
)}
{canSave && (
<div className='portal-preview__save'>
<Button type='primary' disabled={!loaded} loading={saving} onClick={save}>
保存
</Button>
</div>
)}
<Modal
title={title}
open={!!editing}
onCancel={() => setEditing(null)}
onOk={apply}
okText='确定'
cancelText='取消'
>
<Form form={form} layout='vertical'>
<Form.Item
name='name'
label='栏目名称'
rules={[{ required: true, whitespace: true, message: '请填写栏目名称' }]}
>
<Input />
</Form.Item>
<Form.Item name='type_tag' label='CMS 分类标识'>
<Input />
</Form.Item>
<Form.Item name='enabled' label='启用' valuePropName='checked'>
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
)
}
import { Tabs } from 'antd'
import { useSearchParams } from 'react-router'
import HomeConfigView from './HomeConfigView'
import ProductsConfigView from './ProductsConfigView'
import AchievementsConfigView from './AchievementsConfigView'
const items = [
{ key: 'home', label: '首页', children: <HomeConfigView /> },
{ key: 'products', label: '产品中心', children: <ProductsConfigView /> },
{ key: 'achievements', label: '成果中心', children: <AchievementsConfigView /> },
]
export default function AppConfigView() {
const [params, setParams] = useSearchParams()
const tab = params.get('tab') || 'home'
return (
<Tabs
activeKey={tab}
items={items}
onChange={(key) => setParams(key === 'home' ? {} : { tab: key })}
/>
)
}
import { useEffect, useState } from 'react'
import { nanoid } from 'nanoid'
import { Button, Form, Popconfirm, Select, Spin, message } from 'antd'
import { AppstoreOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import { usePermission } from '@/hooks/usePermission'
import { getProductsConfig, saveProductsConfig } from '../api'
import HomeConfigEditModal from '../components/HomeConfigEditModal'
import { SortableList, SortableItem } from '../components/SortableList'
import './homeConfig.less'
export default function ProductsConfigView() {
const [items, setItems] = useState([])
const [style, setStyle] = useState('grid')
const [loading, setLoading] = useState(true)
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState(false)
const [editing, setEditing] = useState(null)
const [form] = Form.useForm()
const allowed = usePermission()
const canEdit = allowed('/setting/portal/products/save') && loaded && !saving
useEffect(() => {
getProductsConfig()
.then((config) => {
setStyle(config.style)
setItems(
config.items.sort((a, b) => a.sort - b.sort).map((item) => ({ ...item, _key: nanoid() })),
)
setLoaded(true)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const edit = (index = null) => {
form.resetFields()
const item = index === null ? { enabled: 1, require_login: 0 } : items[index]
form.setFieldsValue({ ...item, enabled: !!item.enabled, require_login: !!item.require_login })
setEditing({ kind: 'entry', index })
}
const apply = async () => {
const values = await form.validateFields()
values.enabled = Number(values.enabled)
values.require_login = Number(values.require_login)
setItems((current) =>
editing.index === null
? [...current, { ...values, _key: nanoid() }]
: current.map((item, index) => (index === editing.index ? { ...item, ...values } : item)),
)
setEditing(null)
}
const save = async () => {
setSaving(true)
try {
await saveProductsConfig({
style,
items: items.map(({ _key, ...item }, index) => ({ ...item, sort: (index + 1) * 10 })),
})
message.success('已保存')
} catch {
} finally {
setSaving(false)
}
}
return (
<div className='portal-preview'>
{loading ? (
<Spin />
) : (
<div className='portal-preview__canvas'>
<div className='portal-preview__module'>
<div className='portal-preview__heading'>
<h3>产品中心</h3>
<Select
value={style}
onChange={setStyle}
disabled={!canEdit}
style={{ marginLeft: 'auto', width: 140 }}
options={[
{ value: 'grid', label: '图标网格' },
{ value: 'cards', label: '图片卡片' },
]}
/>
</div>
<SortableList items={items} onChange={setItems}>
<div className={`portal-preview__entries portal-preview__entries--${style}`}>
{items.map((item, index) => (
<SortableItem
key={item._key}
id={item._key}
disabled={!canEdit}
className={`portal-preview__entry ${!item.enabled ? 'portal-preview__entry--disabled' : ''}`}
>
{(handle) => (
<>
<button
className='portal-preview__tile'
{...handle}
disabled={!canEdit}
onClick={() => edit(index)}
>
<span
className={`portal-preview__icon portal-preview__icon--${index % 4}`}
>
{item.icon ? (
<img src={item.icon} alt='' draggable={false} />
) : (
<AppstoreOutlined />
)}
</span>
<span className='portal-preview__text'>
<strong>{item.name}</strong>
{style === 'cards' && item.description && (
<small>{item.description}</small>
)}
{!item.enabled && <em>已停用</em>}
</span>
</button>
{canEdit && (
<Popconfirm
title='删除此产品?'
onConfirm={() =>
setItems((current) => current.filter((_, i) => i !== index))
}
>
<Button
className='portal-preview__remove'
type='text'
icon={<DeleteOutlined />}
aria-label='删除产品'
/>
</Popconfirm>
)}
</>
)}
</SortableItem>
))}
{canEdit && (
<button className='portal-preview__add-entry' onClick={() => edit()}>
<PlusOutlined />
<span>新增产品</span>
</button>
)}
</div>
</SortableList>
</div>
</div>
)}
{allowed('/setting/portal/products/save') && (
<div className='portal-preview__save'>
<Button type='primary' disabled={!loaded} loading={saving} onClick={save}>
保存
</Button>
</div>
)}
<HomeConfigEditModal
editing={editing}
form={form}
onCancel={() => setEditing(null)}
onOk={apply}
entryLabel='产品'
/>
</div>
)
}
.portal-preview {
&__canvas {
max-width: 615px;
margin: 0 auto;
padding: 24px;
background: #f5f7fa;
border-radius: 16px;
}
&__module {
padding: 20px;
margin-bottom: 16px;
border-radius: 16px;
background: white;
&:last-child {
margin-bottom: 0;
}
&--disabled {
background: #fafafa;
border: 1px dashed #cbd0d8;
}
}
&__heading {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
line-height: 100%;
color: #253041;
padding-left: 10px;
border-left: 3px solid #aa1941;
}
}
&__module-actions {
margin-left: 8px;
}
&__entries {
display: grid;
gap: 16px 12px;
&--grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
&--cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
&__entry {
position: relative;
&--disabled .portal-preview__tile {
opacity: 0.5;
}
}
&__tile {
width: 100%;
height: 100%;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
cursor: pointer;
padding: 12px 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: #343b47;
font: inherit;
&:hover,
&:focus-visible {
border-color: #dcb6c2;
background: #fffafb;
outline: none;
}
&:disabled {
cursor: default;
}
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
border-radius: 14px;
background: #fcf3f6;
color: #aa1941;
font-size: 28px;
flex-shrink: 0;
img {
width: 42px;
height: 42px;
object-fit: contain;
}
&--1 {
background: #f7f3fc;
}
&--2 {
background: #f1f7fd;
}
&--3 {
background: #f1f9f5;
}
}
&__text {
display: flex;
flex-direction: column;
gap: 6px;
text-align: center;
strong {
font-size: 13px;
font-weight: 500;
line-height: 1.5;
overflow-wrap: anywhere;
}
small {
color: #85858e;
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
em {
font-style: normal;
color: #89919d;
font-size: 11px;
}
}
&__entries--cards &__tile {
flex-direction: row;
align-items: center;
background: #faf6f8;
padding: 18px 14px;
gap: 12px;
}
&__entries--cards &__text {
text-align: left;
}
&__entries--cards &__icon {
width: 48px;
height: 48px;
background: transparent;
}
&__remove {
position: absolute;
top: 0;
right: 0;
opacity: 0;
color: #89919d;
}
&__entry:hover &__remove,
&__entry:focus-within &__remove {
opacity: 1;
}
&__add-entry {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 100px;
border: 1px dashed #d6dbe3;
border-radius: 12px;
background: transparent;
color: #89919d;
cursor: pointer;
font: inherit;
font-size: 12px;
&:hover {
color: #aa1941;
border-color: #aa1941;
}
}
&__empty {
padding: 60px 16px;
text-align: center;
color: #89919d;
}
&__save {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 24px;
}
@media (max-width: 640px) {
&__canvas {
padding: 12px;
}
&__module {
padding: 12px;
}
&__entries--grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&__entries--cards {
grid-template-columns: 1fr;
}
}
}
.portal-json {
max-width: 100vw;
.ant-drawer-body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 24px;
min-height: 0;
}
&__input.ant-input {
flex: 1;
min-height: 240px;
resize: none;
font:
13px / 22px 'SFMono-Regular',
Consolas,
monospace;
}
&__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px 0;
}
}
.portal-preview__icon-upload {
.upload-image__compact {
width: 112px;
height: 112px;
border-radius: 12px;
font-size: 28px;
img {
padding: 12px;
object-fit: contain;
}
}
}
.portal-preview__module-title {
flex: 1;
cursor: grab;
touch-action: none;
&:active {
cursor: grabbing;
}
}
.portal-preview__tile:not(:disabled) {
touch-action: none;
&:active {
cursor: grabbing;
}
}
.portal-preview__module-actions {
opacity: 0;
pointer-events: none;
}
.portal-preview__module:hover,
.portal-preview__module:focus-within {
.portal-preview__module-actions {
opacity: 1;
pointer-events: auto;
}
}
@media (hover: none) {
.portal-preview__module-actions,
.portal-preview__add-entry,
.portal-preview__remove {
opacity: 1;
pointer-events: auto;
}
}
.portal-preview__more {
margin-left: auto;
color: #85858e;
font-size: 13px;
}
...@@ -2,6 +2,7 @@ import { lazy } from 'react' ...@@ -2,6 +2,7 @@ import { lazy } from 'react'
import { Navigate, useRoutes } from 'react-router' import { Navigate, useRoutes } from 'react-router'
import ModuleBoundary from '@/router/ModuleBoundary' import ModuleBoundary from '@/router/ModuleBoundary'
const PortalRoutes = lazy(() => import('./portal/routes'))
const SettingsLayout = lazy(() => import('./SettingsLayout')) const SettingsLayout = lazy(() => import('./SettingsLayout'))
const HelpRoutes = lazy(() => import('./help/routes')) const HelpRoutes = lazy(() => import('./help/routes'))
const AppliedRoutes = lazy(() => import('./applied/routes')) const AppliedRoutes = lazy(() => import('./applied/routes'))
...@@ -18,6 +19,7 @@ export default function SettingsRoutes() { ...@@ -18,6 +19,7 @@ export default function SettingsRoutes() {
Component: SettingsLayout, Component: SettingsLayout,
children: [ children: [
{ index: true, element: <Navigate to='/setting/applied' /> }, { index: true, element: <Navigate to='/setting/applied' /> },
{ path: 'portal/*', Component: PortalRoutes },
{ path: 'help/*', Component: HelpRoutes }, { path: 'help/*', Component: HelpRoutes },
{ path: 'applied/*', Component: AppliedRoutes }, { path: 'applied/*', Component: AppliedRoutes },
{ path: 'personalized-setting/*', Component: PersonalizedRoutes }, { path: 'personalized-setting/*', Component: PersonalizedRoutes },
......
# Learning application boundaries # Web application boundaries
Flutter `book-app` is the functional source of truth. Preserve its business behavior and API contracts while adapting interaction to desktop browsers; do not copy Flutter widgets mechanically. Flutter `book-app` is the functional source of truth. Preserve its business behavior and API contracts while adapting interaction to desktop browsers; do not copy Flutter widgets mechanically.
......
{ {
"name": "@ebook/learning", "name": "@ebook/web",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
...@@ -36,7 +36,7 @@ ...@@ -36,7 +36,7 @@
"oxfmt": "^0.64.0", "oxfmt": "^0.64.0",
"oxlint": "^1.79.0", "oxlint": "^1.79.0",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.2.2", "vite": "^8.3.4",
"vite-plugin-mkcert": "^2.1.0", "vite-plugin-mkcert": "^2.1.0",
"vitest": "^4.1.10" "vitest": "^4.1.10"
} }
......
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论