visuddhinanda 3 лет назад
Родитель
Сommit
197e8b65ec

+ 49 - 27
dashboard/src/Router.tsx

@@ -43,28 +43,37 @@ import LibraryBlogCourse from "./pages/library/blog/course";
 import LibraryBlogAnthology from "./pages/library/blog/anthology";
 import LibraryBlogTerm from "./pages/library/blog/term";
 
-import StudioHome from "./pages/studio";
+import Studio from "./pages/studio";
+import StudioHome from "./pages/studio/home";
 
 import StudioPalicanon from "./pages/studio/palicanon";
 import StudioRecent from "./pages/studio/recent";
 
 import StudioChannel from "./pages/studio/channel";
+import StudioChannelList from "./pages/studio/channel/list";
 import StudioChannelEdit from "./pages/studio/channel/edit";
 
 import StudioGroup from "./pages/studio/group";
+import StudioGroupList from "./pages/studio/group/list";
 import StudioGroupEdit from "./pages/studio/group/edit";
 import StudioGroupShow from "./pages/studio/group/show";
 
 import StudioDict from "./pages/studio/dict";
+import StudioDictList from "./pages/studio/dict/list";
+
 import StudioTerm from "./pages/studio/term";
+import StudioTermList from "./pages/studio/term/list";
 
 import StudioArticle from "./pages/studio/article";
+import StudioArticleList from "./pages/studio/article/list";
 import StudioArticleEdit from "./pages/studio/article/edit";
 
 import StudioAnthology from "./pages/studio/anthology";
+import StudioAnthologyList from "./pages/studio/anthology/list";
 import StudioAnthologyEdit from "./pages/studio/anthology/edit";
 
 import StudioAnalysis from "./pages/studio/analysis";
+import StudioAnalysisList from "./pages/studio/analysis/list";
 
 const Widget = () => {
 	return (
@@ -99,7 +108,6 @@ const Widget = () => {
 				<Route path="list" element={<LibraryCommunityList />} />
 				<Route path="recent" element={<LibraryCommunityRecent />} />
 			</Route>
-
 			<Route path="palicanon" element={<LibraryPalicanon />}>
 				<Route path="list" element={<LibraryPalicanonByPath />} />
 				<Route path="list/:root" element={<LibraryPalicanonByPath />} />
@@ -107,12 +115,10 @@ const Widget = () => {
 				<Route path="list/:root/:path/:tag" element={<LibraryPalicanonByPath />} />
 				<Route path="chapter/:id" element={<LibraryPalicanonChapter />} />
 			</Route>
-
 			<Route path="course" element={<LibraryCourse />}>
+				<Route path="list" element={<LibraryCourseList />}></Route>
 				<Route path="show/:id" element={<LibraryCourseShow />}></Route>
 				<Route path="lesson/:id" element={<LibraryLessonShow />}></Route>
-				<Route path="course/:id" element={<LibraryLessonShow />}></Route>
-				<Route path="list" element={<LibraryCourseList />}></Route>
 			</Route>
 
 			<Route path="term/:word" element={<LibraryTerm />} />
@@ -127,7 +133,13 @@ const Widget = () => {
 				<Route path=":id" element={<LibraryAnthologyShow />} />
 				<Route path=":id/by_channel/:tags" element={<LibraryAnthologyShow />} />
 			</Route>
-			<Route path="article/show/:id" element={<LibraryArticle />} />
+
+			<Route path="article" element={<LibraryArticle />}>
+				<Route path=":type/:id" element={<LibraryArticle />} />
+				<Route path=":type/:id/param/:param" element={<LibraryArticle />} />
+				<Route path=":type/:id/tran" element={<LibraryArticle />} />
+				<Route path=":type/:id/tran/param/:param" element={<LibraryArticle />} />
+			</Route>
 
 			<Route path="blog/:studio" element={<LibraryBlog />}>
 				<Route path="overview" element={<LibraryBlogOverview />} />
@@ -137,27 +149,37 @@ const Widget = () => {
 				<Route path="term" element={<LibraryBlogTerm />} />
 			</Route>
 
-			<Route path="studio/:studioname" element={<StudioHome />}></Route>
-			<Route path="studio/:studioname/palicanon" element={<StudioPalicanon />}></Route>
-			<Route path="studio/:studioname/recent" element={<StudioRecent />}></Route>
-
-			<Route path="studio/:studioname/channel" element={<StudioChannel />}></Route>
-			<Route path="studio/:studioname/channel/edit/:channelid" element={<StudioChannelEdit />} />
-
-			<Route path="studio/:studioname/group" element={<StudioGroup />}></Route>
-			<Route path="studio/:studioname/group/:groupid" element={<StudioGroupShow />} />
-			<Route path="studio/:studioname/group/edit/:groupid" element={<StudioGroupEdit />} />
-
-			<Route path="studio/:studioname/dict" element={<StudioDict />} />
-			<Route path="studio/:studioname/term" element={<StudioTerm />} />
-
-			<Route path="studio/:studioname/article" element={<StudioArticle />}></Route>
-			<Route path="studio/:studioname/article/edit/:articleid" element={<StudioArticleEdit />} />
-
-			<Route path="studio/:studioname/anthology" element={<StudioAnthology />}></Route>
-			<Route path="studio/:studioname/anthology/edit/:anthology_id" element={<StudioAnthologyEdit />} />
-
-			<Route path="studio/:studioname/analysis" element={<StudioAnalysis />}></Route>
+			<Route path="studio/:studioname" element={<Studio />}>
+				<Route path="home" element={<StudioHome />} />
+				<Route path="palicanon" element={<StudioPalicanon />}></Route>
+				<Route path="recent" element={<StudioRecent />}></Route>
+				<Route path="channel" element={<StudioChannel />}>
+					<Route path="list" element={<StudioChannelList />} />
+					<Route path=":channelid/edit" element={<StudioChannelEdit />} />
+				</Route>
+				<Route path="group" element={<StudioGroup />}>
+					<Route path="list" element={<StudioGroupList />} />
+					<Route path=":groupid" element={<StudioGroupShow />} />
+					<Route path=":groupid/edit" element={<StudioGroupEdit />} />
+				</Route>
+				<Route path="dict" element={<StudioDict />}>
+					<Route path="list" element={<StudioDictList />} />
+				</Route>
+				<Route path="term" element={<StudioTerm />}>
+					<Route path="list" element={<StudioTermList />} />
+				</Route>
+				<Route path="article" element={<StudioArticle />}>
+					<Route path="list" element={<StudioArticleList />} />
+					<Route path=":articleid/edit" element={<StudioArticleEdit />} />
+				</Route>
+				<Route path="anthology" element={<StudioAnthology />}>
+					<Route path="list" element={<StudioAnthologyList />}></Route>
+					<Route path=":anthology_id/edit" element={<StudioAnthologyEdit />} />
+				</Route>
+				<Route path="analysis" element={<StudioAnalysis />}>
+					<Route path="list" element={<StudioAnalysisList />} />
+				</Route>
+			</Route>
 		</Routes>
 	);
 };

+ 7 - 52
dashboard/src/pages/studio/analysis/index.tsx

@@ -1,62 +1,17 @@
-import { useParams } from "react-router-dom";
-import { useIntl } from "react-intl";
+import { Outlet } from "react-router-dom";
 import { Layout } from "antd";
-import { useState, useEffect } from "react";
-import { Line } from "@ant-design/plots";
 
-import { Calendar, momentLocalizer } from "react-big-calendar";
-import moment from "moment";
-
-import HeadBar from "../../../components/studio/HeadBar";
 import LeftSider from "../../../components/studio/LeftSider";
-import Footer from "../../../components/studio/Footer";
-const { Content } = Layout;
-const localizer = momentLocalizer(moment); // or globalizeLocalizer
-const Widget = () => {
-	const intl = useIntl(); //i18n
-	const { studioname } = useParams(); //url 参数
-	const [data, setData] = useState([]);
 
-	useEffect(() => {
-		asyncFetch();
-	}, []);
+const { Content } = Layout;
 
-	const myEventsList = [{ start: "2022-10-1", end: "2022-10-2" }];
-	const asyncFetch = () => {
-		fetch("https://gw.alipayobjects.com/os/bmw-prod/1d565782-dde4-4bb6-8946-ea6a38ccf184.json")
-			.then((response) => response.json())
-			.then((json) => setData(json))
-			.catch((error) => {
-				console.log("fetch data failed", error);
-			});
-	};
-	const config = {
-		data,
-		xField: "Date",
-		yField: "scales",
-		xAxis: {
-			tickCount: 5,
-		},
-		slider: {
-			start: 0.1,
-			end: 0.5,
-		},
-	};
+const Widget = () => {
 	return (
 		<Layout>
-			<HeadBar />
-			<Layout>
-				<LeftSider selectedKeys="analysis" />
-				<Content>
-					<h2>
-						studio/{studioname}/{intl.formatMessage({ id: "columns.studio.analysis.title" })}/行为分析首页
-					</h2>
-					<Line {...config} />
-
-					<Calendar localizer={localizer} events={myEventsList} startAccessor="start" endAccessor="end" />
-				</Content>
-			</Layout>
-			<Footer />
+			<LeftSider selectedKeys="analysis" />
+			<Content>
+				<Outlet />
+			</Content>
 		</Layout>
 	);
 };

+ 53 - 0
dashboard/src/pages/studio/analysis/list.tsx

@@ -0,0 +1,53 @@
+import { useParams } from "react-router-dom";
+import { useIntl } from "react-intl";
+
+import { useState, useEffect } from "react";
+import { Line } from "@ant-design/plots";
+
+import { Calendar, momentLocalizer } from "react-big-calendar";
+import moment from "moment";
+
+const localizer = momentLocalizer(moment); // or globalizeLocalizer
+const Widget = () => {
+	const intl = useIntl(); //i18n
+	const { studioname } = useParams(); //url 参数
+	const [data, setData] = useState([]);
+
+	useEffect(() => {
+		asyncFetch();
+	}, []);
+
+	const myEventsList = [{ start: "2022-10-1", end: "2022-10-2" }];
+	const asyncFetch = () => {
+		fetch("https://gw.alipayobjects.com/os/bmw-prod/1d565782-dde4-4bb6-8946-ea6a38ccf184.json")
+			.then((response) => response.json())
+			.then((json) => setData(json))
+			.catch((error) => {
+				console.log("fetch data failed", error);
+			});
+	};
+	const config = {
+		data,
+		xField: "Date",
+		yField: "scales",
+		xAxis: {
+			tickCount: 5,
+		},
+		slider: {
+			start: 0.1,
+			end: 0.5,
+		},
+	};
+	return (
+		<>
+			<h2>
+				studio/{studioname}/{intl.formatMessage({ id: "columns.studio.analysis.title" })}/行为分析首页
+			</h2>
+			<Line {...config} />
+
+			<Calendar localizer={localizer} events={myEventsList} startAccessor="start" endAccessor="end" />
+		</>
+	);
+};
+
+export default Widget;

+ 61 - 81
dashboard/src/pages/studio/anthology/edit.tsx

@@ -1,19 +1,12 @@
 import { useParams } from "react-router-dom";
 import { useIntl } from "react-intl";
 
-import { Layout } from "antd";
-
 import { ProForm, ProFormText, ProFormSelect, ProFormTextArea } from "@ant-design/pro-components";
 import { message } from "antd";
 
-import HeadBar from "../../../components/studio/HeadBar";
-import LeftSider from "../../../components/studio/LeftSider";
-import Footer from "../../../components/studio/Footer";
 import EditableTree from "../../../components/studio/EditableTree";
 import type { ListNodeData } from "../../../components/studio/EditableTree";
 
-const { Content } = Layout;
-
 interface IFormData {
 	title: string;
 	subtitle: string;
@@ -52,83 +45,70 @@ const Widget = () => {
 		},
 	];
 	return (
-		<Layout>
-			<HeadBar />
-			<Layout>
-				<LeftSider selectedKeys="anthology" />
-				<Content>
-					<h2>
-						studio/{studioname}/{intl.formatMessage({ id: "columns.studio.anthology.title" })}/anthology/
-						{anthology_id}
-					</h2>
+		<>
+			<h2>
+				studio/{studioname}/{intl.formatMessage({ id: "columns.studio.anthology.title" })}/anthology/
+				{anthology_id}
+			</h2>
 
-					<ProForm<IFormData>
-						onFinish={async (values: IFormData) => {
-							// TODO
-							values.studio = "aaaa";
-							console.log(values);
-							message.success(intl.formatMessage({ id: "flashes.success" }));
-						}}
-					>
-						<ProForm.Group>
-							<ProFormText
-								width="md"
-								name="title"
-								required
-								label={intl.formatMessage({ id: "forms.fields.title.label" })}
-								rules={[
-									{
-										required: true,
-										message: intl.formatMessage({ id: "forms.create.message.no.title" }),
-									},
-								]}
-							/>
-						</ProForm.Group>
-						<ProForm.Group>
-							<ProFormText
-								width="md"
-								name="subtitle"
-								label={intl.formatMessage({ id: "forms.fields.subtitle.label" })}
-							/>
-						</ProForm.Group>
-						<ProForm.Group>
-							<ProFormTextArea
-								name="summary"
-								label={intl.formatMessage({ id: "forms.fields.summary.label" })}
-							/>
-						</ProForm.Group>
-						<ProForm.Group>
-							<ProFormSelect
-								options={[
-									{ value: "zh-Hans", label: "简体中文" },
-									{ value: "zh-Hant", label: "繁体中文" },
-									{ value: "en-US", label: "English" },
-								]}
-								width="md"
-								name="lang"
-								rules={[
-									{
-										required: true,
-										message: intl.formatMessage({ id: "forms.create.message.no.lang" }),
-									},
-								]}
-								label={intl.formatMessage({ id: "channel.lang" })}
-							/>
-						</ProForm.Group>
+			<ProForm<IFormData>
+				onFinish={async (values: IFormData) => {
+					// TODO
+					values.studio = "aaaa";
+					console.log(values);
+					message.success(intl.formatMessage({ id: "flashes.success" }));
+				}}
+			>
+				<ProForm.Group>
+					<ProFormText
+						width="md"
+						name="title"
+						required
+						label={intl.formatMessage({ id: "forms.fields.title.label" })}
+						rules={[
+							{
+								required: true,
+								message: intl.formatMessage({ id: "forms.create.message.no.title" }),
+							},
+						]}
+					/>
+				</ProForm.Group>
+				<ProForm.Group>
+					<ProFormText
+						width="md"
+						name="subtitle"
+						label={intl.formatMessage({ id: "forms.fields.subtitle.label" })}
+					/>
+				</ProForm.Group>
+				<ProForm.Group>
+					<ProFormTextArea name="summary" label={intl.formatMessage({ id: "forms.fields.summary.label" })} />
+				</ProForm.Group>
+				<ProForm.Group>
+					<ProFormSelect
+						options={[
+							{ value: "zh-Hans", label: "简体中文" },
+							{ value: "zh-Hant", label: "繁体中文" },
+							{ value: "en-US", label: "English" },
+						]}
+						width="md"
+						name="lang"
+						rules={[
+							{
+								required: true,
+								message: intl.formatMessage({ id: "forms.create.message.no.lang" }),
+							},
+						]}
+						label={intl.formatMessage({ id: "channel.lang" })}
+					/>
+				</ProForm.Group>
 
-						<ProForm.Group>
-							<ProFormTextArea
-								name="toc"
-								label={intl.formatMessage({ id: "forms.fields.content.label" })}
-							/>
-						</ProForm.Group>
-					</ProForm>
+				<ProForm.Group>
+					<ProFormTextArea name="toc" label={intl.formatMessage({ id: "forms.fields.content.label" })} />
+				</ProForm.Group>
+			</ProForm>
 
-					<EditableTree treeData={listdata} />
-				</Content>
-			</Layout>
-			<Footer />
-		</Layout>
+			<EditableTree treeData={listdata} />
+		</>
 	);
 };
 

+ 5 - 186
dashboard/src/pages/studio/anthology/index.tsx

@@ -1,202 +1,21 @@
-import { useParams } from "react-router-dom";
-import { ProTable } from "@ant-design/pro-components";
-import { useIntl } from "react-intl";
-import { Link } from "react-router-dom";
-import { Layout, Space, Table } from "antd";
-import { PlusOutlined } from "@ant-design/icons";
-import type { MenuProps } from "antd";
-import { Button, Dropdown, Menu, Popover } from "antd";
-import { SearchOutlined } from "@ant-design/icons";
-import HeadBar from "../../../components/studio/HeadBar";
+import { Outlet } from "react-router-dom";
+import { Layout } from "antd";
+
 import LeftSider from "../../../components/studio/LeftSider";
-import Footer from "../../../components/studio/Footer";
-import AnthologyCreate from "../../../components/studio/anthology/AnthologyCreate";
 
 const { Content } = Layout;
 
-const onMenuClick: MenuProps["onClick"] = (e) => {
-	console.log("click", e);
-};
-
-const menu = (
-	<Menu
-		onClick={onMenuClick}
-		items={[
-			{
-				key: "1",
-				label: "在藏经阁中打开",
-				icon: <SearchOutlined />,
-			},
-			{
-				key: "2",
-				label: "分享",
-				icon: <SearchOutlined />,
-			},
-			{
-				key: "3",
-				label: "详情",
-				icon: <SearchOutlined />,
-			},
-		]}
-	/>
-);
-
-interface IItem {
-	id: number;
-	title: string;
-	subtitle: string;
-	tag: string;
-	articles: number;
-	createdAt: number;
-}
-
 const Widget = () => {
-	const intl = useIntl();
-	const { studioname } = useParams();
-	const anthologyCreate = <AnthologyCreate studio={studioname} />;
+
 	return (
 		<Layout>
-			<HeadBar />
 			<Layout>
 				<LeftSider selectedKeys="anthology" />
 				<Content>
-					<Layout>{studioname}</Layout>
-					<ProTable<IItem>
-						columns={[
-							{
-								title: intl.formatMessage({ id: "dict.fields.sn.label" }),
-								dataIndex: "id",
-								key: "id",
-								width: 50,
-								search: false,
-							},
-							{
-								title: intl.formatMessage({ id: "forms.fields.title.label" }),
-								dataIndex: "title",
-								key: "title",
-								tip: "过长会自动收缩",
-								ellipsis: true,
-								render: (text, row, index, action) => {
-									return (
-										<div>
-											<div>
-												<Link to="edit/12345">{row.title}</Link>
-											</div>
-											<div>{row.subtitle}</div>
-										</div>
-									);
-								},
-							},
-							{
-								title: intl.formatMessage({ id: "forms.fields.power.label" }),
-								dataIndex: "tag",
-								key: "tag",
-								width: 100,
-								search: false,
-								filters: true,
-								onFilter: true,
-								valueEnum: {
-									all: { text: "全部", status: "Default" },
-									30: { text: "拥有者", status: "Success" },
-									20: { text: "可编辑", status: "Processing" },
-									10: { text: "只读", status: "Default" },
-								},
-							},
-							{
-								title: intl.formatMessage({ id: "article.fields.article.count.label" }),
-								dataIndex: "articles",
-								key: "articles",
-								width: 100,
-								search: false,
-								sorter: (a, b) => a.articles - b.articles,
-							},
-							{
-								title: intl.formatMessage({ id: "forms.fields.created-at.label" }),
-								key: "created-at",
-								width: 100,
-								search: false,
-								dataIndex: "createdAt",
-								valueType: "date",
-								sorter: (a, b) => a.createdAt - b.createdAt,
-							},
-							{
-								title: "操作",
-								key: "option",
-								width: 120,
-								valueType: "option",
-								render: () => [
-									<Dropdown.Button type="link" overlay={menu}>
-										编辑
-									</Dropdown.Button>,
-								],
-							},
-						]}
-						rowSelection={{
-							// 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
-							// 注释该行则默认不显示下拉选项
-							selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
-						}}
-						tableAlertRender={({ selectedRowKeys, selectedRows, onCleanSelected }) => (
-							<Space size={24}>
-								<span>
-									已选 {selectedRowKeys.length} 项
-									<Button type="link" style={{ marginInlineStart: 8 }} onClick={onCleanSelected}>
-										取消选择
-									</Button>
-								</span>
-							</Space>
-						)}
-						tableAlertOptionRender={() => {
-							return (
-								<Space size={16}>
-									<Button type="link">批量删除</Button>
-								</Space>
-							);
-						}}
-						request={async (params = {}, sorter, filter) => {
-							// TODO
-							console.log(params, sorter, filter);
-
-							const size = params.pageSize || 20;
-							return {
-								total: 1 << 12,
-								success: true,
-								data: Array.from(Array(size).keys()).map((x) => {
-									const id = ((params.current || 1) - 1) * size + x + 1;
+				<Outlet />
 
-									var it: IItem = {
-										id,
-										title: `title ${id}`,
-										subtitle: `subtitle ${id}`,
-										tag: ((Math.floor(Math.random() * 3) + 1) * 10).toString(),
-										articles: Math.floor(Math.random() * 40),
-										createdAt: Date.now() - Math.floor(Math.random() * 2000000000),
-									};
-									return it;
-								}),
-							};
-						}}
-						rowKey="id"
-						bordered
-						pagination={{
-							showQuickJumper: true,
-							showSizeChanger: true,
-						}}
-						search={false}
-						options={{
-							search: true,
-						}}
-						toolBarRender={() => [
-							<Popover content={anthologyCreate} title="new article" placement="bottomRight">
-								<Button key="button" icon={<PlusOutlined />} type="primary">
-									{intl.formatMessage({ id: "buttons.create" })}
-								</Button>
-							</Popover>,
-						]}
-					/>
 				</Content>
 			</Layout>
-			<Footer />
 		</Layout>
 	);
 };

+ 193 - 0
dashboard/src/pages/studio/anthology/list.tsx

@@ -0,0 +1,193 @@
+import { useParams } from "react-router-dom";
+import { ProTable } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { Link } from "react-router-dom";
+import { Layout, Space, Table } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+import type { MenuProps } from "antd";
+import { Button, Dropdown, Menu, Popover } from "antd";
+import { SearchOutlined } from "@ant-design/icons";
+
+import AnthologyCreate from "../../../components/studio/anthology/AnthologyCreate";
+
+const onMenuClick: MenuProps["onClick"] = (e) => {
+	console.log("click", e);
+};
+
+const menu = (
+	<Menu
+		onClick={onMenuClick}
+		items={[
+			{
+				key: "1",
+				label: "在藏经阁中打开",
+				icon: <SearchOutlined />,
+			},
+			{
+				key: "2",
+				label: "分享",
+				icon: <SearchOutlined />,
+			},
+			{
+				key: "3",
+				label: "详情",
+				icon: <SearchOutlined />,
+			},
+		]}
+	/>
+);
+
+interface IItem {
+	id: number;
+	title: string;
+	subtitle: string;
+	tag: string;
+	articles: number;
+	createdAt: number;
+}
+
+const Widget = () => {
+	const intl = useIntl();
+	const { studioname } = useParams();
+	const anthologyCreate = <AnthologyCreate studio={studioname} />;
+	return (
+		<>
+			<Layout>{studioname}</Layout>
+			<ProTable<IItem>
+				columns={[
+					{
+						title: intl.formatMessage({ id: "dict.fields.sn.label" }),
+						dataIndex: "id",
+						key: "id",
+						width: 50,
+						search: false,
+					},
+					{
+						title: intl.formatMessage({ id: "forms.fields.title.label" }),
+						dataIndex: "title",
+						key: "title",
+						tip: "过长会自动收缩",
+						ellipsis: true,
+						render: (text, row, index, action) => {
+							return (
+								<div>
+									<div>
+										<Link to="edit/12345">{row.title}</Link>
+									</div>
+									<div>{row.subtitle}</div>
+								</div>
+							);
+						},
+					},
+					{
+						title: intl.formatMessage({ id: "forms.fields.power.label" }),
+						dataIndex: "tag",
+						key: "tag",
+						width: 100,
+						search: false,
+						filters: true,
+						onFilter: true,
+						valueEnum: {
+							all: { text: "全部", status: "Default" },
+							30: { text: "拥有者", status: "Success" },
+							20: { text: "可编辑", status: "Processing" },
+							10: { text: "只读", status: "Default" },
+						},
+					},
+					{
+						title: intl.formatMessage({ id: "article.fields.article.count.label" }),
+						dataIndex: "articles",
+						key: "articles",
+						width: 100,
+						search: false,
+						sorter: (a, b) => a.articles - b.articles,
+					},
+					{
+						title: intl.formatMessage({ id: "forms.fields.created-at.label" }),
+						key: "created-at",
+						width: 100,
+						search: false,
+						dataIndex: "createdAt",
+						valueType: "date",
+						sorter: (a, b) => a.createdAt - b.createdAt,
+					},
+					{
+						title: "操作",
+						key: "option",
+						width: 120,
+						valueType: "option",
+						render: (text, row, index, action) => [
+							<Dropdown.Button type="link" key={index} overlay={menu}>
+								编辑
+							</Dropdown.Button>,
+						],
+					},
+				]}
+				rowSelection={{
+					// 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+					// 注释该行则默认不显示下拉选项
+					selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+				}}
+				tableAlertRender={({ selectedRowKeys, selectedRows, onCleanSelected }) => (
+					<Space size={24}>
+						<span>
+							已选 {selectedRowKeys.length} 项
+							<Button type="link" style={{ marginInlineStart: 8 }} onClick={onCleanSelected}>
+								取消选择
+							</Button>
+						</span>
+					</Space>
+				)}
+				tableAlertOptionRender={() => {
+					return (
+						<Space size={16}>
+							<Button type="link">批量删除</Button>
+						</Space>
+					);
+				}}
+				request={async (params = {}, sorter, filter) => {
+					// TODO
+					console.log(params, sorter, filter);
+
+					const size = params.pageSize || 20;
+					return {
+						total: 1 << 12,
+						success: true,
+						data: Array.from(Array(size).keys()).map((x) => {
+							const id = ((params.current || 1) - 1) * size + x + 1;
+
+							var it: IItem = {
+								id,
+								title: `title ${id}`,
+								subtitle: `subtitle ${id}`,
+								tag: ((Math.floor(Math.random() * 3) + 1) * 10).toString(),
+								articles: Math.floor(Math.random() * 40),
+								createdAt: Date.now() - Math.floor(Math.random() * 2000000000),
+							};
+							return it;
+						}),
+					};
+				}}
+				rowKey="id"
+				bordered
+				pagination={{
+					showQuickJumper: true,
+					showSizeChanger: true,
+				}}
+				search={false}
+				options={{
+					search: true,
+				}}
+				toolBarRender={() => [
+					<Popover content={anthologyCreate} title="new article" placement="bottomRight">
+						<Button key="button" icon={<PlusOutlined />} type="primary">
+							{intl.formatMessage({ id: "buttons.create" })}
+						</Button>
+					</Popover>,
+				]}
+			/>
+		</>
+	);
+};
+
+export default Widget;

+ 60 - 79
dashboard/src/pages/studio/article/edit.tsx

@@ -1,14 +1,8 @@
 import { useParams } from "react-router-dom";
 import { useIntl } from "react-intl";
-import { Layout } from "antd";
 import { ProForm, ProFormText, ProFormSelect, ProFormTextArea } from "@ant-design/pro-components";
 import { message } from "antd";
 
-import HeadBar from "../../../components/studio/HeadBar";
-import LeftSider from "../../../components/studio/LeftSider";
-import Footer from "../../../components/studio/Footer";
-const { Content } = Layout;
-
 interface IFormData {
 	title: string;
 	subtitle: string;
@@ -22,81 +16,68 @@ const Widget = () => {
 	const intl = useIntl();
 	const { studioname, articleid } = useParams(); //url 参数
 	return (
-		<Layout>
-			<HeadBar />
-			<Layout>
-				<LeftSider selectedKeys="article" />
-				<Content>
-					<h2>
-						studio/{studioname}/{intl.formatMessage({ id: "columns.studio.article.title" })}/edit/
-						{articleid}
-					</h2>
+		<>
+			<h2>
+				studio/{studioname}/{intl.formatMessage({ id: "columns.studio.article.title" })}/edit/
+				{articleid}
+			</h2>
 
-					<ProForm<IFormData>
-						onFinish={async (values: IFormData) => {
-							// TODO
-							values.studio = "aaaa";
-							console.log(values);
-							message.success(intl.formatMessage({ id: "flashes.success" }));
-						}}
-					>
-						<ProForm.Group>
-							<ProFormText
-								width="md"
-								name="title"
-								required
-								label={intl.formatMessage({ id: "forms.fields.title.label" })}
-								rules={[
-									{
-										required: true,
-										message: intl.formatMessage({ id: "forms.create.message.no.title" }),
-									},
-								]}
-							/>
-						</ProForm.Group>
-						<ProForm.Group>
-							<ProFormText
-								width="md"
-								name="subtitle"
-								label={intl.formatMessage({ id: "forms.fields.subtitle.label" })}
-							/>
-						</ProForm.Group>
-						<ProForm.Group>
-							<ProFormTextArea
-								name="summary"
-								label={intl.formatMessage({ id: "forms.fields.summary.label" })}
-							/>
-						</ProForm.Group>
-						<ProForm.Group>
-							<ProFormSelect
-								options={[
-									{ value: "zh-Hans", label: "简体中文" },
-									{ value: "zh-Hant", label: "繁体中文" },
-									{ value: "en-US", label: "English" },
-								]}
-								width="md"
-								name="lang"
-								rules={[
-									{
-										required: true,
-										message: intl.formatMessage({ id: "forms.create.message.no.lang" }),
-									},
-								]}
-								label={intl.formatMessage({ id: "channel.lang" })}
-							/>
-						</ProForm.Group>
+			<ProForm<IFormData>
+				onFinish={async (values: IFormData) => {
+					// TODO
+					values.studio = "aaaa";
+					console.log(values);
+					message.success(intl.formatMessage({ id: "flashes.success" }));
+				}}
+			>
+				<ProForm.Group>
+					<ProFormText
+						width="md"
+						name="title"
+						required
+						label={intl.formatMessage({ id: "forms.fields.title.label" })}
+						rules={[
+							{
+								required: true,
+								message: intl.formatMessage({ id: "forms.create.message.no.title" }),
+							},
+						]}
+					/>
+				</ProForm.Group>
+				<ProForm.Group>
+					<ProFormText
+						width="md"
+						name="subtitle"
+						label={intl.formatMessage({ id: "forms.fields.subtitle.label" })}
+					/>
+				</ProForm.Group>
+				<ProForm.Group>
+					<ProFormTextArea name="summary" label={intl.formatMessage({ id: "forms.fields.summary.label" })} />
+				</ProForm.Group>
+				<ProForm.Group>
+					<ProFormSelect
+						options={[
+							{ value: "zh-Hans", label: "简体中文" },
+							{ value: "zh-Hant", label: "繁体中文" },
+							{ value: "en-US", label: "English" },
+						]}
+						width="md"
+						name="lang"
+						rules={[
+							{
+								required: true,
+								message: intl.formatMessage({ id: "forms.create.message.no.lang" }),
+							},
+						]}
+						label={intl.formatMessage({ id: "channel.lang" })}
+					/>
+				</ProForm.Group>
 
-						<ProForm.Group>
-							<ProFormTextArea
-								name="content"
-								label={intl.formatMessage({ id: "forms.fields.content.label" })}
-							/>
-						</ProForm.Group>
-					</ProForm>
-				</Content>
-			</Layout>
-			<Footer />
-		</Layout>
+				<ProForm.Group>
+					<ProFormTextArea name="content" label={intl.formatMessage({ id: "forms.fields.content.label" })} />
+				</ProForm.Group>
+			</ProForm>
+		</>
 	);
 };
 

+ 3 - 138
dashboard/src/pages/studio/article/index.tsx

@@ -1,154 +1,19 @@
-import { useParams, Link } from "react-router-dom";
-import { useIntl } from "react-intl";
-import { useState } from "react";
+import { Outlet } from "react-router-dom";
+import { Layout } from "antd";
 
-import { Space, Layout, Breadcrumb, Button, Tag, Popover } from "antd";
-import { ProList } from "@ant-design/pro-components";
-import { CheckCircleOutlined, PlusOutlined } from "@ant-design/icons";
-
-import HeadBar from "../../../components/studio/HeadBar";
 import LeftSider from "../../../components/studio/LeftSider";
-import Footer from "../../../components/studio/Footer";
-import ArticleCreate from "../../../components/studio/article/ArticleCreate";
 
 const { Content } = Layout;
 
-const defaultData = [
-	{
-		id: "1",
-		title: "Nissaya Book List",
-		name: "Nissaya Book List",
-		tag: [{ title: "公开", color: "success", icon: <CheckCircleOutlined /> }],
-		description: "文章概要",
-	},
-	{
-		id: "2",
-		title: "初级巴利语入门",
-		name: "初级巴利语入门",
-		tag: [{ title: "公开", color: "success", icon: <CheckCircleOutlined /> }],
-		description: "文章概要",
-	},
-	{
-		id: "3",
-		title: "何人有资格接受卡提那衣",
-		name: "何人有资格接受卡提那衣",
-		tag: [{ title: "公开", color: "success", icon: <CheckCircleOutlined /> }],
-		description: "文章概要",
-	},
-	{
-		id: "4",
-		title: "Adhiṭṭhana 定名/决意",
-		name: "Adhiṭṭhana 定名/决意",
-		tag: [{ title: "公开", color: "success", icon: <CheckCircleOutlined /> }],
-		description: "文章概要",
-	},
-];
-type DataItem = typeof defaultData[number];
-
 const Widget = () => {
-	const intl = useIntl(); //i18n
-	const { studioname } = useParams(); //url 参数
-	const [dataSource, setDataSource] = useState<DataItem[]>(defaultData);
-	const articleCreate = <ArticleCreate studio={studioname} />;
-	const linkRead = `/article/show/12345`;
-	const linkStudio = `/studio/${studioname}`;
-
 	return (
 		<Layout>
-			<HeadBar />
 			<Layout>
 				<LeftSider selectedKeys="article" />
 				<Content>
-					<Breadcrumb>
-						<Breadcrumb.Item>
-							<Link to={linkStudio}>{intl.formatMessage({ id: "columns.studio.title" })}</Link>
-						</Breadcrumb.Item>
-						<Breadcrumb.Item>
-							{intl.formatMessage({ id: "columns.studio.collaboration.title" })}
-						</Breadcrumb.Item>
-						<Breadcrumb.Item>{intl.formatMessage({ id: "columns.studio.article.title" })}</Breadcrumb.Item>
-					</Breadcrumb>
-					<Layout>
-						<ProList<DataItem>
-							rowKey="id"
-							headerTitle="文章"
-							dataSource={dataSource}
-							showActions="hover"
-							editable={{
-								onSave: async (key, record, originRow) => {
-									console.log(key, record, originRow);
-									return true;
-								},
-							}}
-							onDataSourceChange={setDataSource}
-							metas={{
-								title: {
-									dataIndex: "name",
-									render: (text, row, index, action) => {
-										const linkEdit = `/studio/${studioname}/article/edit/${row.id}`;
-
-										return <Link to={linkEdit}>{row.name}</Link>;
-									},
-								},
-								description: {
-									dataIndex: "description",
-									search: false,
-								},
-								content: {
-									dataIndex: "content",
-									editable: false,
-									search: false,
-								},
-								subTitle: {
-									search: false,
-									render: (text, row, index, action) => {
-										const showtag = row.tag.map((item, key) => {
-											return (
-												<Tag color={item.color} icon={item.icon}>
-													{item.title}
-												</Tag>
-											);
-										});
-										return <Space size={0}>{showtag}</Space>;
-									},
-								},
-								actions: {
-									render: (text, row, index, action) => {
-										const linkEdit = `/studio/${studioname}/article/edit/${row.id}`;
-
-										return [
-											<Link to={linkEdit}>编辑</Link>,
-											<Link to={linkRead}>阅读</Link>,
-											<Button onClick={() => {}} key="link" danger>
-												删除
-											</Button>,
-											<Button onClick={() => {}} key="link">
-												分享
-											</Button>,
-										];
-									},
-								},
-							}}
-							search={{
-								filterType: "light",
-							}}
-							bordered
-							pagination={{
-								showQuickJumper: true,
-								showSizeChanger: true,
-							}}
-							toolBarRender={() => [
-								<Popover content={articleCreate} title="new article" placement="bottomRight">
-									<Button key="button" icon={<PlusOutlined />} type="primary">
-										{intl.formatMessage({ id: "buttons.create" })}
-									</Button>
-								</Popover>,
-							]}
-						/>
-					</Layout>
+					<Outlet />
 				</Content>
 			</Layout>
-			<Footer />
 		</Layout>
 	);
 };

+ 142 - 0
dashboard/src/pages/studio/article/list.tsx

@@ -0,0 +1,142 @@
+import { useParams, Link } from "react-router-dom";
+import { useIntl } from "react-intl";
+import { useState } from "react";
+
+import { Space, Layout, Breadcrumb, Button, Tag, Popover } from "antd";
+import { ProList } from "@ant-design/pro-components";
+import { CheckCircleOutlined, PlusOutlined } from "@ant-design/icons";
+
+import ArticleCreate from "../../../components/studio/article/ArticleCreate";
+
+const defaultData = [
+	{
+		id: "1",
+		title: "Nissaya Book List",
+		name: "Nissaya Book List",
+		tag: [{ title: "公开", color: "success", icon: <CheckCircleOutlined /> }],
+		description: "文章概要",
+	},
+	{
+		id: "2",
+		title: "初级巴利语入门",
+		name: "初级巴利语入门",
+		tag: [{ title: "公开", color: "success", icon: <CheckCircleOutlined /> }],
+		description: "文章概要",
+	},
+	{
+		id: "3",
+		title: "何人有资格接受卡提那衣",
+		name: "何人有资格接受卡提那衣",
+		tag: [{ title: "公开", color: "success", icon: <CheckCircleOutlined /> }],
+		description: "文章概要",
+	},
+	{
+		id: "4",
+		title: "Adhiṭṭhana 定名/决意",
+		name: "Adhiṭṭhana 定名/决意",
+		tag: [{ title: "公开", color: "success", icon: <CheckCircleOutlined /> }],
+		description: "文章概要",
+	},
+];
+type DataItem = typeof defaultData[number];
+
+const Widget = () => {
+	const intl = useIntl(); //i18n
+	const { studioname } = useParams(); //url 参数
+	const [dataSource, setDataSource] = useState<DataItem[]>(defaultData);
+	const articleCreate = <ArticleCreate studio={studioname} />;
+	const linkRead = `/article/show/12345`;
+	const linkStudio = `/studio/${studioname}`;
+
+	return (
+		<>
+			<Breadcrumb>
+				<Breadcrumb.Item>
+					<Link to={linkStudio}>{intl.formatMessage({ id: "columns.studio.title" })}</Link>
+				</Breadcrumb.Item>
+				<Breadcrumb.Item>{intl.formatMessage({ id: "columns.studio.collaboration.title" })}</Breadcrumb.Item>
+				<Breadcrumb.Item>{intl.formatMessage({ id: "columns.studio.article.title" })}</Breadcrumb.Item>
+			</Breadcrumb>
+			<Layout>
+				<ProList<DataItem>
+					rowKey="id"
+					headerTitle="文章"
+					dataSource={dataSource}
+					showActions="hover"
+					editable={{
+						onSave: async (key, record, originRow) => {
+							console.log(key, record, originRow);
+							return true;
+						},
+					}}
+					onDataSourceChange={setDataSource}
+					metas={{
+						title: {
+							dataIndex: "name",
+							render: (text, row, index, action) => {
+								const linkEdit = `/studio/${studioname}/article/edit/${row.id}`;
+
+								return <Link to={linkEdit}>{row.name}</Link>;
+							},
+						},
+						description: {
+							dataIndex: "description",
+							search: false,
+						},
+						content: {
+							dataIndex: "content",
+							editable: false,
+							search: false,
+						},
+						subTitle: {
+							search: false,
+							render: (text, row, index, action) => {
+								const showtag = row.tag.map((item, key) => {
+									return (
+										<Tag color={item.color} icon={item.icon}>
+											{item.title}
+										</Tag>
+									);
+								});
+								return <Space size={0}>{showtag}</Space>;
+							},
+						},
+						actions: {
+							render: (text, row, index, action) => {
+								const linkEdit = `/studio/${studioname}/article/edit/${row.id}`;
+
+								return [
+									<Link to={linkEdit}>编辑</Link>,
+									<Link to={linkRead}>阅读</Link>,
+									<Button onClick={() => {}} key="link" danger>
+										删除
+									</Button>,
+									<Button onClick={() => {}} key="link">
+										分享
+									</Button>,
+								];
+							},
+						},
+					}}
+					search={{
+						filterType: "light",
+					}}
+					bordered
+					pagination={{
+						showQuickJumper: true,
+						showSizeChanger: true,
+					}}
+					toolBarRender={() => [
+						<Popover content={articleCreate} title="new article" placement="bottomRight">
+							<Button key="button" icon={<PlusOutlined />} type="primary">
+								{intl.formatMessage({ id: "buttons.create" })}
+							</Button>
+						</Popover>,
+					]}
+				/>
+			</Layout>
+		</>
+	);
+};
+
+export default Widget;

+ 70 - 77
dashboard/src/pages/studio/channel/edit.tsx

@@ -20,85 +20,78 @@ const Widget = () => {
 	const { channelid } = useParams(); //url 参数
 
 	return (
-		<Layout>
-			<HeadBar />
-			<Layout>
-				<LeftSider selectedKeys="channel" />
-				<Content>
-					<Space>{channelid}</Space>
-					<ProForm<IFormData>
-						onFinish={async (values: IFormData) => {
-							// TODO
-							values.studio = "aaaa";
-							console.log(values);
-							message.success(intl.formatMessage({ id: "flashes.success" }));
-						}}
-					>
-						<ProForm.Group>
-							<ProFormText
-								width="md"
-								name="name"
-								required
-								label={intl.formatMessage({ id: "channel.name" })}
-								rules={[
-									{
-										required: true,
-										message: intl.formatMessage({ id: "channel.create.message.noname" }),
-									},
-								]}
-							/>
-						</ProForm.Group>
+		<>
+			<Space>{channelid}</Space>
+			<ProForm<IFormData>
+				onFinish={async (values: IFormData) => {
+					// TODO
+					values.studio = "aaaa";
+					console.log(values);
+					message.success(intl.formatMessage({ id: "flashes.success" }));
+				}}
+			>
+				<ProForm.Group>
+					<ProFormText
+						width="md"
+						name="name"
+						required
+						label={intl.formatMessage({ id: "channel.name" })}
+						rules={[
+							{
+								required: true,
+								message: intl.formatMessage({ id: "channel.create.message.noname" }),
+							},
+						]}
+					/>
+				</ProForm.Group>
 
-						<ProForm.Group>
-							<ProFormSelect
-								options={[
-									{
-										value: "translation",
-										label: intl.formatMessage({ id: "channel.type.translation.title" }),
-									},
-									{
-										value: "nissaya",
-										label: intl.formatMessage({ id: "channel.type.nissaya.title" }),
-									},
-								]}
-								width="md"
-								name="type"
-								rules={[
-									{
-										required: true,
-										message: intl.formatMessage({ id: "channel.create.message.noname" }),
-									},
-								]}
-								label={intl.formatMessage({ id: "channel.type" })}
-							/>
-						</ProForm.Group>
-						<ProForm.Group>
-							<ProFormSelect
-								options={[
-									{ value: "zh-Hans", label: "简体中文" },
-									{ value: "zh-Hant", label: "繁体中文" },
-									{ value: "en-US", label: "English" },
-								]}
-								width="md"
-								name="lang"
-								rules={[
-									{
-										required: true,
-										message: intl.formatMessage({ id: "channel.create.message.noname" }),
-									},
-								]}
-								label={intl.formatMessage({ id: "channel.lang" })}
-							/>
-						</ProForm.Group>
+				<ProForm.Group>
+					<ProFormSelect
+						options={[
+							{
+								value: "translation",
+								label: intl.formatMessage({ id: "channel.type.translation.title" }),
+							},
+							{
+								value: "nissaya",
+								label: intl.formatMessage({ id: "channel.type.nissaya.title" }),
+							},
+						]}
+						width="md"
+						name="type"
+						rules={[
+							{
+								required: true,
+								message: intl.formatMessage({ id: "channel.create.message.noname" }),
+							},
+						]}
+						label={intl.formatMessage({ id: "channel.type" })}
+					/>
+				</ProForm.Group>
+				<ProForm.Group>
+					<ProFormSelect
+						options={[
+							{ value: "zh-Hans", label: "简体中文" },
+							{ value: "zh-Hant", label: "繁体中文" },
+							{ value: "en-US", label: "English" },
+						]}
+						width="md"
+						name="lang"
+						rules={[
+							{
+								required: true,
+								message: intl.formatMessage({ id: "channel.create.message.noname" }),
+							},
+						]}
+						label={intl.formatMessage({ id: "channel.lang" })}
+					/>
+				</ProForm.Group>
 
-						<ProForm.Group>
-							<ProFormTextArea name="summary" label="简介" />
-						</ProForm.Group>
-					</ProForm>
-				</Content>
-			</Layout>
-			<Footer />
-		</Layout>
+				<ProForm.Group>
+					<ProFormTextArea name="summary" label="简介" />
+				</ProForm.Group>
+			</ProForm>
+		</>
 	);
 };
 

+ 3 - 200
dashboard/src/pages/studio/channel/index.tsx

@@ -1,216 +1,19 @@
-import { useParams } from "react-router-dom";
-import { ProTable } from "@ant-design/pro-components";
-import { useIntl } from "react-intl";
-import { Link } from "react-router-dom";
-import { Layout, Space, Table } from "antd";
-import { PlusOutlined } from "@ant-design/icons";
-import type { MenuProps } from "antd";
-import { Button, Dropdown, Menu, Popover } from "antd";
-import { SearchOutlined } from "@ant-design/icons";
+import { Outlet } from "react-router-dom";
+import { Layout } from "antd";
 
-import HeadBar from "../../../components/studio/HeadBar";
 import LeftSider from "../../../components/studio/LeftSider";
-import Footer from "../../../components/studio/Footer";
-import ChannelCreate from "../../../components/studio/channel/ChannelCreate";
 
 const { Content } = Layout;
 
-const onMenuClick: MenuProps["onClick"] = (e) => {
-	console.log("click", e);
-};
-
-const menu = (
-	<Menu
-		onClick={onMenuClick}
-		items={[
-			{
-				key: "1",
-				label: "在藏经阁中打开",
-				icon: <SearchOutlined />,
-			},
-			{
-				key: "2",
-				label: "分享",
-				icon: <SearchOutlined />,
-			},
-			{
-				key: "3",
-				label: "删除",
-				icon: <SearchOutlined />,
-			},
-		]}
-	/>
-);
-
-const EType = ["translation", "nissaya", "commentray", "original", "general"];
-
-interface IItem {
-	id: number;
-	title: string;
-	subtitle: string;
-	type: string;
-	publicity: number;
-	createdAt: number;
-}
-
 const Widget = () => {
-	const intl = useIntl();
-	const { studioname } = useParams();
-	const channelCreate = <ChannelCreate studio={studioname} />;
 	return (
 		<Layout>
-			<HeadBar />
 			<Layout>
 				<LeftSider selectedKeys="channel" />
 				<Content>
-					<Layout>{studioname}</Layout>
-					<ProTable<IItem>
-						columns={[
-							{
-								title: intl.formatMessage({ id: "dict.fields.sn.label" }),
-								dataIndex: "id",
-								key: "id",
-								width: 50,
-								search: false,
-							},
-							{
-								title: intl.formatMessage({ id: "forms.fields.title.label" }),
-								dataIndex: "title",
-								key: "title",
-								tip: "过长会自动收缩",
-								ellipsis: true,
-								render: (text, row, index, action) => {
-									return (
-										<div>
-											<div>
-												<Link to="edit/12345">{row.title}</Link>
-											</div>
-											<div>{row.subtitle}</div>
-										</div>
-									);
-								},
-							},
-							{
-								title: intl.formatMessage({ id: "forms.fields.type.label" }),
-								dataIndex: "type",
-								key: "type",
-								width: 100,
-								search: false,
-								filters: true,
-								onFilter: true,
-								valueEnum: {
-									all: { text: "全部", status: "Default" },
-									translation: { text: "译文", status: "Success" },
-									nissaya: { text: "逐词解析", status: "Processing" },
-									commentray: { text: "注疏", status: "Default" },
-									original: { text: "原文", status: "Default" },
-									general: { text: "通用", status: "Default" },
-								},
-							},
-							{
-								title: intl.formatMessage({ id: "forms.fields.publicity.label" }),
-								dataIndex: "publicity",
-								key: "publicity",
-								width: 100,
-								search: false,
-								filters: true,
-								onFilter: true,
-								valueEnum: {
-									all: { text: "全部", status: "Default" },
-									0: { text: "禁用", status: "Success" },
-									10: { text: "私有", status: "Processing" },
-									20: { text: "链接阅读", status: "Default" },
-									30: { text: "公开阅读", status: "Default" },
-									40: { text: "公开可编辑", status: "Default" },
-								},
-							},
-							{
-								title: intl.formatMessage({ id: "forms.fields.created-at.label" }),
-								key: "created-at",
-								width: 100,
-								search: false,
-								dataIndex: "createdAt",
-								valueType: "date",
-								sorter: (a, b) => a.createdAt - b.createdAt,
-							},
-							{
-								title: "操作",
-								key: "option",
-								width: 120,
-								valueType: "option",
-								render: () => [
-									<Dropdown.Button type="link" overlay={menu}>
-										编辑
-									</Dropdown.Button>,
-								],
-							},
-						]}
-						rowSelection={{
-							// 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
-							// 注释该行则默认不显示下拉选项
-							selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
-						}}
-						tableAlertRender={({ selectedRowKeys, selectedRows, onCleanSelected }) => (
-							<Space size={24}>
-								<span>
-									已选 {selectedRowKeys.length} 项
-									<Button type="link" style={{ marginInlineStart: 8 }} onClick={onCleanSelected}>
-										取消选择
-									</Button>
-								</span>
-							</Space>
-						)}
-						tableAlertOptionRender={() => {
-							return (
-								<Space size={16}>
-									<Button type="link">批量删除</Button>
-								</Space>
-							);
-						}}
-						request={async (params = {}, sorter, filter) => {
-							// TODO
-							console.log(params, sorter, filter);
-
-							const size = params.pageSize || 20;
-							return {
-								total: 1 << 12,
-								success: true,
-								data: Array.from(Array(size).keys()).map((x) => {
-									const id = ((params.current || 1) - 1) * size + x + 1;
-
-									var it: IItem = {
-										id,
-										title: `title ${id}`,
-										subtitle: `subtitle ${id}`,
-										type: EType[Math.floor(Math.random() * 4)],
-										publicity: (Math.floor(Math.random() * 3) + 1) * 10,
-										createdAt: Date.now() - Math.floor(Math.random() * 2000000000),
-									};
-									return it;
-								}),
-							};
-						}}
-						rowKey="id"
-						bordered
-						pagination={{
-							showQuickJumper: true,
-							showSizeChanger: true,
-						}}
-						search={false}
-						options={{
-							search: true,
-						}}
-						toolBarRender={() => [
-							<Popover content={channelCreate} title="new channel" placement="bottomRight">
-								<Button key="button" icon={<PlusOutlined />} type="primary">
-									{intl.formatMessage({ id: "buttons.create" })}
-								</Button>
-							</Popover>,
-						]}
-					/>
+					<Outlet />
 				</Content>
 			</Layout>
-			<Footer />
 		</Layout>
 	);
 };

+ 207 - 0
dashboard/src/pages/studio/channel/list.tsx

@@ -0,0 +1,207 @@
+import { useParams } from "react-router-dom";
+import { ProTable } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { Link } from "react-router-dom";
+import { Layout, Space, Table } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+import type { MenuProps } from "antd";
+import { Button, Dropdown, Menu, Popover } from "antd";
+import { SearchOutlined } from "@ant-design/icons";
+
+import ChannelCreate from "../../../components/studio/channel/ChannelCreate";
+
+const onMenuClick: MenuProps["onClick"] = (e) => {
+	console.log("click", e);
+};
+
+const menu = (
+	<Menu
+		onClick={onMenuClick}
+		items={[
+			{
+				key: "1",
+				label: "在藏经阁中打开",
+				icon: <SearchOutlined />,
+			},
+			{
+				key: "2",
+				label: "分享",
+				icon: <SearchOutlined />,
+			},
+			{
+				key: "3",
+				label: "删除",
+				icon: <SearchOutlined />,
+			},
+		]}
+	/>
+);
+
+const EType = ["translation", "nissaya", "commentray", "original", "general"];
+
+interface IItem {
+	id: number;
+	title: string;
+	subtitle: string;
+	type: string;
+	publicity: number;
+	createdAt: number;
+}
+
+const Widget = () => {
+	const intl = useIntl();
+	const { studioname } = useParams();
+	const channelCreate = <ChannelCreate studio={studioname} />;
+	return (
+		<>
+			<Layout>{studioname}</Layout>
+			<ProTable<IItem>
+				columns={[
+					{
+						title: intl.formatMessage({ id: "dict.fields.sn.label" }),
+						dataIndex: "id",
+						key: "id",
+						width: 50,
+						search: false,
+					},
+					{
+						title: intl.formatMessage({ id: "forms.fields.title.label" }),
+						dataIndex: "title",
+						key: "title",
+						tip: "过长会自动收缩",
+						ellipsis: true,
+						render: (text, row, index, action) => {
+							const link = `/studio/${studioname}/channel/${row.id}/edit`;
+							return (
+								<div>
+									<div>
+										<Link to={link}>{row.title}</Link>
+									</div>
+									<div>{row.subtitle}</div>
+								</div>
+							);
+						},
+					},
+					{
+						title: intl.formatMessage({ id: "forms.fields.type.label" }),
+						dataIndex: "type",
+						key: "type",
+						width: 100,
+						search: false,
+						filters: true,
+						onFilter: true,
+						valueEnum: {
+							all: { text: "全部", status: "Default" },
+							translation: { text: "译文", status: "Success" },
+							nissaya: { text: "逐词解析", status: "Processing" },
+							commentray: { text: "注疏", status: "Default" },
+							original: { text: "原文", status: "Default" },
+							general: { text: "通用", status: "Default" },
+						},
+					},
+					{
+						title: intl.formatMessage({ id: "forms.fields.publicity.label" }),
+						dataIndex: "publicity",
+						key: "publicity",
+						width: 100,
+						search: false,
+						filters: true,
+						onFilter: true,
+						valueEnum: {
+							all: { text: "全部", status: "Default" },
+							0: { text: "禁用", status: "Success" },
+							10: { text: "私有", status: "Processing" },
+							20: { text: "链接阅读", status: "Default" },
+							30: { text: "公开阅读", status: "Default" },
+							40: { text: "公开可编辑", status: "Default" },
+						},
+					},
+					{
+						title: intl.formatMessage({ id: "forms.fields.created-at.label" }),
+						key: "created-at",
+						width: 100,
+						search: false,
+						dataIndex: "createdAt",
+						valueType: "date",
+						sorter: (a, b) => a.createdAt - b.createdAt,
+					},
+					{
+						title: "操作",
+						key: "option",
+						width: 120,
+						valueType: "option",
+						render: (text, row, index, action) => [
+							<Dropdown.Button key={index} type="link" overlay={menu}>
+								编辑
+							</Dropdown.Button>,
+						],
+					},
+				]}
+				rowSelection={{
+					// 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+					// 注释该行则默认不显示下拉选项
+					selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+				}}
+				tableAlertRender={({ selectedRowKeys, selectedRows, onCleanSelected }) => (
+					<Space size={24}>
+						<span>
+							已选 {selectedRowKeys.length} 项
+							<Button type="link" style={{ marginInlineStart: 8 }} onClick={onCleanSelected}>
+								取消选择
+							</Button>
+						</span>
+					</Space>
+				)}
+				tableAlertOptionRender={() => {
+					return (
+						<Space size={16}>
+							<Button type="link">批量删除</Button>
+						</Space>
+					);
+				}}
+				request={async (params = {}, sorter, filter) => {
+					// TODO
+					console.log(params, sorter, filter);
+
+					const size = params.pageSize || 20;
+					return {
+						total: 1 << 12,
+						success: true,
+						data: Array.from(Array(size).keys()).map((x) => {
+							const id = ((params.current || 1) - 1) * size + x + 1;
+
+							var it: IItem = {
+								id,
+								title: `title ${id}`,
+								subtitle: `subtitle ${id}`,
+								type: EType[Math.floor(Math.random() * 4)],
+								publicity: (Math.floor(Math.random() * 3) + 1) * 10,
+								createdAt: Date.now() - Math.floor(Math.random() * 2000000000),
+							};
+							return it;
+						}),
+					};
+				}}
+				rowKey="id"
+				bordered
+				pagination={{
+					showQuickJumper: true,
+					showSizeChanger: true,
+				}}
+				search={false}
+				options={{
+					search: true,
+				}}
+				toolBarRender={() => [
+					<Popover content={channelCreate} title="new channel" placement="bottomRight">
+						<Button key="button" icon={<PlusOutlined />} type="primary">
+							{intl.formatMessage({ id: "buttons.create" })}
+						</Button>
+					</Popover>,
+				]}
+			/>
+		</>
+	);
+};
+
+export default Widget;

+ 3 - 179
dashboard/src/pages/studio/dict/index.tsx

@@ -1,195 +1,19 @@
-import { useParams } from "react-router-dom";
-import { ProTable } from "@ant-design/pro-components";
-import { useIntl } from "react-intl";
-import { Link } from "react-router-dom";
-import { Button, Layout, Space, Table, Popover } from "antd";
-import { PlusOutlined } from "@ant-design/icons";
+import { Outlet } from "react-router-dom";
+import { Layout } from "antd";
 
-import HeadBar from "../../../components/studio/HeadBar";
 import LeftSider from "../../../components/studio/LeftSider";
-import Footer from "../../../components/studio/Footer";
-import DictCreate from "../../../components/studio/dict/DictCreate";
 
 const { Content } = Layout;
 
-interface IItem {
-	id: number;
-	word: string;
-	type: string;
-	grammar: string;
-	parent: string;
-	meaning: string;
-	note: string;
-	factors: string;
-	createdAt: number;
-}
-
-const valueEnum = {
-	0: "n",
-	1: "ti",
-	2: "v",
-	3: "ind",
-};
-
 const Widget = () => {
-	const intl = useIntl();
-	const { studioname } = useParams();
-
-	const dictCreate = <DictCreate studio={studioname} />;
-
 	return (
 		<Layout>
-			<HeadBar />
 			<Layout>
 				<LeftSider selectedKeys="userdict" />
 				<Content>
-					<Layout>{studioname}</Layout>
-					<ProTable<IItem>
-						columns={[
-							{
-								title: intl.formatMessage({ id: "dict.fields.sn.label" }),
-								dataIndex: "id",
-								key: "id",
-								width: 80,
-								search: false,
-							},
-							{
-								title: intl.formatMessage({ id: "dict.fields.word.label" }),
-								dataIndex: "word",
-								key: "word",
-								render: (_) => <Link to="">{_}</Link>,
-								tip: "单词过长会自动收缩",
-								ellipsis: true,
-							},
-							{
-								title: intl.formatMessage({ id: "dict.fields.type.label" }),
-								dataIndex: "type",
-								key: "type",
-								search: false,
-								filters: true,
-								onFilter: true,
-								valueEnum: {
-									all: { text: "全部", status: "Default" },
-									n: { text: "名词", status: "Default" },
-									ti: { text: "三性", status: "Processing" },
-									v: { text: "动词", status: "Success" },
-									ind: { text: "不变词", status: "Success" },
-								},
-							},
-							{
-								title: intl.formatMessage({ id: "dict.fields.grammar.label" }),
-								dataIndex: "grammar",
-								key: "grammar",
-								search: false,
-							},
-							{
-								title: intl.formatMessage({ id: "dict.fields.parent.label" }),
-								dataIndex: "parent",
-								key: "parent",
-							},
-							{
-								title: intl.formatMessage({ id: "dict.fields.meaning.label" }),
-								dataIndex: "meaning",
-								key: "meaning",
-								tip: "意思过长会自动收缩",
-								ellipsis: true,
-							},
-							{
-								title: intl.formatMessage({ id: "dict.fields.note.label" }),
-								dataIndex: "note",
-								key: "note",
-								search: false,
-								tip: "注释过长会自动收缩",
-								ellipsis: true,
-							},
-							{
-								title: intl.formatMessage({ id: "dict.fields.factors.label" }),
-								dataIndex: "factors",
-								key: "factors",
-								search: false,
-							},
-							{
-								title: intl.formatMessage({ id: "forms.fields.created-at.label" }),
-								key: "created-at",
-								width: 200,
-
-								search: false,
-								dataIndex: "createdAt",
-								valueType: "date",
-								sorter: (a, b) => a.createdAt - b.createdAt,
-							},
-						]}
-						rowSelection={{
-							// 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
-							// 注释该行则默认不显示下拉选项
-							selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
-						}}
-						tableAlertRender={({ selectedRowKeys, selectedRows, onCleanSelected }) => (
-							<Space size={24}>
-								<span>
-									已选 {selectedRowKeys.length} 项
-									<Button type="link" style={{ marginInlineStart: 8 }} onClick={onCleanSelected}>
-										取消选择
-									</Button>
-								</span>
-							</Space>
-						)}
-						tableAlertOptionRender={() => {
-							return (
-								<Space size={16}>
-									<Button type="link">批量删除</Button>
-									<Button type="link">导出数据</Button>
-								</Space>
-							);
-						}}
-						request={async (params = {}, sorter, filter) => {
-							// TODO
-							console.log(params, sorter, filter);
-
-							const size = params.pageSize || 20;
-							return {
-								total: 1 << 12,
-								success: true,
-								data: Array.from(Array(size).keys()).map((x) => {
-									const id = ((params.current || 1) - 1) * size + x + 1;
-
-									var it: IItem = {
-										id,
-										word: `word ${id}`,
-										type: valueEnum[2],
-										grammar: "阳-单-属",
-										parent: `parent ${id}`,
-										meaning: `meaning ${id}`,
-										note: `note ${id}`,
-										factors: `factors ${id}`,
-										createdAt: Date.now() - Math.floor(Math.random() * 200000),
-									};
-									return it;
-								}),
-							};
-						}}
-						rowKey="id"
-						bordered
-						pagination={{
-							showQuickJumper: true,
-							showSizeChanger: true,
-						}}
-						search={false}
-						options={{
-							search: true,
-						}}
-						headerTitle=""
-						toolBarRender={() => [
-							<Popover content={dictCreate} title="new channel" placement="bottomRight">
-								<Button key="button" icon={<PlusOutlined />} type="primary">
-									{intl.formatMessage({ id: "buttons.create" })}
-								</Button>
-							</Popover>,
-						]}
-					/>
+					<Outlet />
 				</Content>
 			</Layout>
-			<Footer />
 		</Layout>
 	);
 };

+ 185 - 0
dashboard/src/pages/studio/dict/list.tsx

@@ -0,0 +1,185 @@
+import { useParams } from "react-router-dom";
+import { ProTable } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { Link } from "react-router-dom";
+import { Button, Layout, Space, Table, Popover } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+
+import DictCreate from "../../../components/studio/dict/DictCreate";
+
+interface IItem {
+	id: number;
+	word: string;
+	type: string;
+	grammar: string;
+	parent: string;
+	meaning: string;
+	note: string;
+	factors: string;
+	createdAt: number;
+}
+
+const valueEnum = {
+	0: "n",
+	1: "ti",
+	2: "v",
+	3: "ind",
+};
+
+const Widget = () => {
+	const intl = useIntl();
+	const { studioname } = useParams();
+
+	const dictCreate = <DictCreate studio={studioname} />;
+
+	return (
+		<>
+			<Layout>{studioname}</Layout>
+			<ProTable<IItem>
+				columns={[
+					{
+						title: intl.formatMessage({ id: "dict.fields.sn.label" }),
+						dataIndex: "id",
+						key: "id",
+						width: 80,
+						search: false,
+					},
+					{
+						title: intl.formatMessage({ id: "dict.fields.word.label" }),
+						dataIndex: "word",
+						key: "word",
+						render: (_) => <Link to="">{_}</Link>,
+						tip: "单词过长会自动收缩",
+						ellipsis: true,
+					},
+					{
+						title: intl.formatMessage({ id: "dict.fields.type.label" }),
+						dataIndex: "type",
+						key: "type",
+						search: false,
+						filters: true,
+						onFilter: true,
+						valueEnum: {
+							all: { text: "全部", status: "Default" },
+							n: { text: "名词", status: "Default" },
+							ti: { text: "三性", status: "Processing" },
+							v: { text: "动词", status: "Success" },
+							ind: { text: "不变词", status: "Success" },
+						},
+					},
+					{
+						title: intl.formatMessage({ id: "dict.fields.grammar.label" }),
+						dataIndex: "grammar",
+						key: "grammar",
+						search: false,
+					},
+					{
+						title: intl.formatMessage({ id: "dict.fields.parent.label" }),
+						dataIndex: "parent",
+						key: "parent",
+					},
+					{
+						title: intl.formatMessage({ id: "dict.fields.meaning.label" }),
+						dataIndex: "meaning",
+						key: "meaning",
+						tip: "意思过长会自动收缩",
+						ellipsis: true,
+					},
+					{
+						title: intl.formatMessage({ id: "dict.fields.note.label" }),
+						dataIndex: "note",
+						key: "note",
+						search: false,
+						tip: "注释过长会自动收缩",
+						ellipsis: true,
+					},
+					{
+						title: intl.formatMessage({ id: "dict.fields.factors.label" }),
+						dataIndex: "factors",
+						key: "factors",
+						search: false,
+					},
+					{
+						title: intl.formatMessage({ id: "forms.fields.created-at.label" }),
+						key: "created-at",
+						width: 200,
+
+						search: false,
+						dataIndex: "createdAt",
+						valueType: "date",
+						sorter: (a, b) => a.createdAt - b.createdAt,
+					},
+				]}
+				rowSelection={{
+					// 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+					// 注释该行则默认不显示下拉选项
+					selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+				}}
+				tableAlertRender={({ selectedRowKeys, selectedRows, onCleanSelected }) => (
+					<Space size={24}>
+						<span>
+							已选 {selectedRowKeys.length} 项
+							<Button type="link" style={{ marginInlineStart: 8 }} onClick={onCleanSelected}>
+								取消选择
+							</Button>
+						</span>
+					</Space>
+				)}
+				tableAlertOptionRender={() => {
+					return (
+						<Space size={16}>
+							<Button type="link">批量删除</Button>
+							<Button type="link">导出数据</Button>
+						</Space>
+					);
+				}}
+				request={async (params = {}, sorter, filter) => {
+					// TODO
+					console.log(params, sorter, filter);
+
+					const size = params.pageSize || 20;
+					return {
+						total: 1 << 12,
+						success: true,
+						data: Array.from(Array(size).keys()).map((x) => {
+							const id = ((params.current || 1) - 1) * size + x + 1;
+
+							var it: IItem = {
+								id,
+								word: `word ${id}`,
+								type: valueEnum[2],
+								grammar: "阳-单-属",
+								parent: `parent ${id}`,
+								meaning: `meaning ${id}`,
+								note: `note ${id}`,
+								factors: `factors ${id}`,
+								createdAt: Date.now() - Math.floor(Math.random() * 200000),
+							};
+							return it;
+						}),
+					};
+				}}
+				rowKey="id"
+				bordered
+				pagination={{
+					showQuickJumper: true,
+					showSizeChanger: true,
+				}}
+				search={false}
+				options={{
+					search: true,
+				}}
+				headerTitle=""
+				toolBarRender={() => [
+					<Popover content={dictCreate} title="new channel" placement="bottomRight">
+						<Button key="button" icon={<PlusOutlined />} type="primary">
+							{intl.formatMessage({ id: "buttons.create" })}
+						</Button>
+					</Popover>,
+				]}
+			/>
+		</>
+	);
+};
+
+export default Widget;

+ 156 - 0
dashboard/src/pages/studio/group/list.tsx

@@ -0,0 +1,156 @@
+import { useParams, Link } from "react-router-dom";
+import { useIntl } from "react-intl";
+import { useState } from "react";
+import { ProList } from "@ant-design/pro-components";
+import { Space, Tag, Button, Layout, Breadcrumb, Popover } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+
+import HeadBar from "../../../components/studio/HeadBar";
+import LeftSider from "../../../components/studio/LeftSider";
+import Footer from "../../../components/studio/Footer";
+import GroupCreate from "../../../components/studio/group/GroupCreate";
+
+const { Content } = Layout;
+
+const defaultData = [
+	{
+		id: "1",
+		name: "IAPT巴利语学习营",
+		tag: [
+			{ title: "巴利语", color: "blue" },
+			{ title: "大金塔", color: "yellow" },
+			{ title: "拥有者", color: "success" },
+		],
+		image: "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
+		description: "我是一条测试的描述",
+	},
+	{
+		id: "2",
+		name: "初级巴利语入门",
+		tag: [
+			{ title: "巴利语", color: "blue" },
+			{ title: "管理员", color: "processing" },
+		],
+		image: "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
+		description: "我是一条测试的描述",
+	},
+	{
+		id: "3",
+		name: "大金塔寺学习小组",
+		tag: [
+			{ title: "大金塔", color: "yellow" },
+			{ title: "成员", color: "default" },
+		],
+		image: "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
+		description: "我是一条测试的描述",
+	},
+	{
+		id: "4",
+		name: "趣向涅槃之道第一册翻译组",
+		tag: [
+			{ title: "大金塔", color: "yellow" },
+			{ title: "成员", color: "default" },
+		],
+		image: "https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg",
+		description: "我是一条测试的描述",
+	},
+];
+type DataItem = typeof defaultData[number];
+
+const Widget = () => {
+	const intl = useIntl(); //i18n
+	const { studioname } = useParams(); //url 参数
+	const [dataSource, setDataSource] = useState<DataItem[]>(defaultData);
+	const linkStudio = `/studio/${studioname}`;
+	const linkGroup = `${linkStudio}/group`;
+	return (
+		<Layout>
+			<HeadBar />
+			<Layout>
+				<LeftSider selectedKeys="group" />
+				<Content>
+					<Breadcrumb>
+						<Breadcrumb.Item>
+							<Link to={linkStudio}>{intl.formatMessage({ id: "columns.studio.title" })}</Link>
+						</Breadcrumb.Item>
+						<Breadcrumb.Item>
+							{intl.formatMessage({ id: "columns.studio.collaboration.title" })}
+						</Breadcrumb.Item>
+						<Breadcrumb.Item>
+							<Link to={linkGroup}>{intl.formatMessage({ id: "columns.studio.group.title" })}</Link>
+						</Breadcrumb.Item>
+						<Breadcrumb.Item>列表</Breadcrumb.Item>
+					</Breadcrumb>
+					<Layout>
+						<ProList<DataItem>
+							rowKey="id"
+							headerTitle="群组列表"
+							dataSource={dataSource}
+							showActions="hover"
+							editable={{
+								onSave: async (key, record, originRow) => {
+									console.log(key, record, originRow);
+									return true;
+								},
+							}}
+							onDataSourceChange={setDataSource}
+							toolBarRender={() => [
+								<Popover content={<GroupCreate studio={studioname} />} placement="bottomRight">
+									<Button key="button" icon={<PlusOutlined />} type="primary">
+										{intl.formatMessage({ id: "buttons.create" })}
+									</Button>
+								</Popover>,
+							]}
+							metas={{
+								title: {
+									dataIndex: "name",
+									render: (text, row, index, action) => {
+										return <Link to={row.id}>{row.name}</Link>;
+									},
+								},
+								avatar: {
+									dataIndex: "image",
+									editable: false,
+								},
+								description: {
+									dataIndex: "description",
+								},
+								content: {
+									dataIndex: "content",
+									editable: false,
+								},
+								subTitle: {
+									render: (text, row, index, action) => {
+										const showtag = row.tag.map((item, id) => {
+											return (
+												<Tag color={item.color} key={id}>
+													{item.title}
+												</Tag>
+											);
+										});
+										return <Space size={0}>{showtag}</Space>;
+									},
+								},
+								actions: {
+									render: (text, row, index, action) => [
+										<Button
+											onClick={() => {
+												action?.startEditable(row.id);
+											}}
+											key="link"
+										>
+											编辑
+										</Button>,
+									],
+								},
+							}}
+						/>
+					</Layout>
+				</Content>
+			</Layout>
+			<Footer />
+		</Layout>
+	);
+};
+
+export default Widget;

+ 27 - 0
dashboard/src/pages/studio/home.tsx

@@ -0,0 +1,27 @@
+import { useParams, Link } from "react-router-dom";
+import { useIntl } from "react-intl";
+import { Layout } from "antd";
+
+import LeftSider from "../../components/studio/LeftSider";
+
+const { Content } = Layout;
+
+const Widget = () => {
+	const intl = useIntl(); //i18n
+	const { studioname } = useParams(); //url 参数
+	return (
+		<Layout>
+			<LeftSider />
+			<Content>
+				<h2>
+					{intl.formatMessage({ id: "columns.studio.title" })}/{studioname}/首页
+				</h2>
+				<div>
+					<Link to=""> </Link>
+				</div>
+			</Content>
+		</Layout>
+	);
+};
+
+export default Widget;

+ 2 - 15
dashboard/src/pages/studio/index.tsx

@@ -1,25 +1,12 @@
-import { useParams, Link } from "react-router-dom";
-import { useIntl } from "react-intl";
-import { Space } from "antd";
+import { Outlet } from "react-router-dom";
 import HeadBar from "../../components/studio/HeadBar";
-import LeftSider from "../../components/studio/LeftSider";
 import Footer from "../../components/studio/Footer";
 
 const Widget = () => {
-	const intl = useIntl(); //i18n
-	const { studioname } = useParams(); //url 参数
 	return (
 		<div>
 			<HeadBar />
-			<LeftSider />
-			<h2>
-				{intl.formatMessage({ id: "columns.studio.title" })}/{studioname}/首页
-			</h2>
-			<div>
-				<Space>
-					<Link to=""> </Link>
-				</Space>
-			</div>
+			<Outlet />
 			<Footer />
 		</div>
 	);

+ 1 - 4
dashboard/src/pages/studio/palicanon/index.tsx

@@ -1,16 +1,14 @@
 import { useParams, Link } from "react-router-dom";
 import { useIntl } from "react-intl";
 import { Space } from "antd";
-import HeadBar from "../../../components/studio/HeadBar";
+
 import LeftSider from "../../../components/studio/LeftSider";
-import Footer from "../../../components/studio/Footer";
 
 const Widget = () => {
 	const intl = useIntl(); //i18n
 	const { studioname } = useParams(); //url 参数
 	return (
 		<div>
-			<HeadBar />
 			<LeftSider />
 			<h2>
 				studio/{studioname}/{intl.formatMessage({ id: "columns.studio.palicanon.title" })}
@@ -20,7 +18,6 @@ const Widget = () => {
 					<Link to=""> </Link>
 				</Space>
 			</div>
-			<Footer />
 		</div>
 	);
 };

+ 2 - 4
dashboard/src/pages/studio/recent/index.tsx

@@ -54,7 +54,6 @@ const Widget = () => {
 	const { studioname } = useParams();
 	return (
 		<Layout>
-			<HeadBar />
 			<Layout>
 				<LeftSider selectedKeys="recent" />
 				<Content>
@@ -116,8 +115,8 @@ const Widget = () => {
 								key: "option",
 								width: 120,
 								valueType: "option",
-								render: () => [
-									<Dropdown.Button type="link" overlay={menu}>
+								render: (text, row, index, action) => [
+									<Dropdown.Button type="link" key={index} overlay={menu}>
 										{intl.formatMessage({ id: "buttons.edit" })}
 									</Dropdown.Button>,
 								],
@@ -180,7 +179,6 @@ const Widget = () => {
 					/>
 				</Content>
 			</Layout>
-			<Footer />
 		</Layout>
 	);
 };

+ 3 - 167
dashboard/src/pages/studio/term/index.tsx

@@ -1,183 +1,19 @@
-import { useParams } from "react-router-dom";
-import { ProTable } from "@ant-design/pro-components";
-import { useIntl } from "react-intl";
-import { Link } from "react-router-dom";
-import { Button, Layout, Space, Table, Popover } from "antd";
-import { PlusOutlined } from "@ant-design/icons";
+import { Outlet } from "react-router-dom";
+import { Layout } from "antd";
 
-import HeadBar from "../../../components/studio/HeadBar";
 import LeftSider from "../../../components/studio/LeftSider";
-import Footer from "../../../components/studio/Footer";
-import TermCreate from "../../../components/studio/term/TermCreate";
 
 const { Content } = Layout;
 
-interface IItem {
-	id: number;
-	word: string;
-	tag: string;
-	channel: string;
-	meaning: string;
-	meaning2: string;
-	note: string;
-	createdAt: number;
-}
-
 const Widget = () => {
-	const intl = useIntl();
-	const { studioname } = useParams();
-
 	return (
 		<Layout>
-			<HeadBar />
 			<Layout>
 				<LeftSider selectedKeys="term" />
 				<Content>
-					<Layout>{studioname}</Layout>
-					<ProTable<IItem>
-						columns={[
-							{
-								title: intl.formatMessage({ id: "term.fields.sn.label" }),
-								dataIndex: "id",
-								key: "id",
-								width: 80,
-								search: false,
-							},
-							{
-								title: intl.formatMessage({ id: "term.fields.word.label" }),
-								dataIndex: "word",
-								key: "word",
-								render: (_) => <Link to="">{_}</Link>,
-								tip: "单词过长会自动收缩",
-								ellipsis: true,
-								formItemProps: {
-									rules: [
-										{
-											required: true,
-											message: "此项为必填项",
-										},
-									],
-								},
-							},
-							{
-								title: intl.formatMessage({ id: "term.fields.description.label" }),
-								dataIndex: "tag",
-								key: "description",
-								search: false,
-							},
-							{
-								title: intl.formatMessage({ id: "term.fields.channel.label" }),
-								dataIndex: "channel",
-								valueType: "select",
-								valueEnum: {
-									all: { text: "全部" },
-									1: { text: "中文" },
-									2: { text: "中文草稿" },
-									3: { text: "英文" },
-									4: { text: "英文草稿" },
-									5: { text: "Visuddhinanda" },
-								},
-							},
-							{
-								title: intl.formatMessage({ id: "term.fields.meaning.label" }),
-								dataIndex: "meaning",
-								key: "meaning",
-							},
-							{
-								title: intl.formatMessage({ id: "term.fields.meaning2.label" }),
-								dataIndex: "meaning2",
-								key: "meaning2",
-								tip: "意思过长会自动收缩",
-								ellipsis: true,
-							},
-							{
-								title: intl.formatMessage({ id: "term.fields.note.label" }),
-								dataIndex: "note",
-								key: "note",
-								search: false,
-								tip: "注释过长会自动收缩",
-								ellipsis: true,
-							},
-							{
-								title: intl.formatMessage({ id: "forms.fields.created-at.label" }),
-								key: "created-at",
-								width: 200,
-								search: false,
-								dataIndex: "createdAt",
-								valueType: "date",
-								sorter: (a, b) => a.createdAt - b.createdAt,
-							},
-						]}
-						rowSelection={{
-							// 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
-							// 注释该行则默认不显示下拉选项
-							selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
-						}}
-						tableAlertRender={({ selectedRowKeys, selectedRows, onCleanSelected }) => (
-							<Space size={24}>
-								<span>
-									已选 {selectedRowKeys.length} 项
-									<Button type="link" style={{ marginInlineStart: 8 }} onClick={onCleanSelected}>
-										取消选择
-									</Button>
-								</span>
-							</Space>
-						)}
-						tableAlertOptionRender={() => {
-							return (
-								<Space size={16}>
-									<Button type="link">批量删除</Button>
-									<Button type="link">导出数据</Button>
-								</Space>
-							);
-						}}
-						request={async (params = {}, sorter, filter) => {
-							// TODO
-							console.log(params, sorter, filter);
-
-							const size = params.pageSize || 20;
-							return {
-								total: 1 << 12,
-								success: true,
-								data: Array.from(Array(size).keys()).map((x) => {
-									const id = ((params.current || 1) - 1) * size + x + 1;
-
-									var it: IItem = {
-										id,
-										word: `word ${id}`,
-										tag: "",
-										channel: "2",
-										meaning: `parent ${id}`,
-										meaning2: `meaning ${id}`,
-										note: `note ${id}`,
-										createdAt: Date.now() - Math.floor(Math.random() * 200000),
-									};
-									return it;
-								}),
-							};
-						}}
-						rowKey="id"
-						//bordered
-						pagination={{
-							showQuickJumper: true,
-							showSizeChanger: true,
-						}}
-						headerTitle={intl.formatMessage({ id: "dict" })}
-						toolBarRender={() => [
-							<Popover content={<TermCreate studio={studioname} />} placement="bottomRight">
-								<Button key="button" icon={<PlusOutlined />} type="primary">
-									{intl.formatMessage({ id: "buttons.create" })}
-								</Button>
-							</Popover>,
-						]}
-						search={{
-							filterType: "light",
-						}}
-						dateFormatter="string"
-					/>
+					<Outlet />
 				</Content>
 			</Layout>
-			<Footer />
 		</Layout>
 	);
 };

+ 173 - 0
dashboard/src/pages/studio/term/list.tsx

@@ -0,0 +1,173 @@
+import { useParams } from "react-router-dom";
+import { ProTable } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { Link } from "react-router-dom";
+import { Button, Layout, Space, Table, Popover } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+
+import TermCreate from "../../../components/studio/term/TermCreate";
+
+interface IItem {
+	id: number;
+	word: string;
+	tag: string;
+	channel: string;
+	meaning: string;
+	meaning2: string;
+	note: string;
+	createdAt: number;
+}
+
+const Widget = () => {
+	const intl = useIntl();
+	const { studioname } = useParams();
+
+	return (
+		<>
+			<Layout>{studioname}</Layout>
+			<ProTable<IItem>
+				columns={[
+					{
+						title: intl.formatMessage({ id: "term.fields.sn.label" }),
+						dataIndex: "id",
+						key: "id",
+						width: 80,
+						search: false,
+					},
+					{
+						title: intl.formatMessage({ id: "term.fields.word.label" }),
+						dataIndex: "word",
+						key: "word",
+						render: (_) => <Link to="">{_}</Link>,
+						tip: "单词过长会自动收缩",
+						ellipsis: true,
+						formItemProps: {
+							rules: [
+								{
+									required: true,
+									message: "此项为必填项",
+								},
+							],
+						},
+					},
+					{
+						title: intl.formatMessage({ id: "term.fields.description.label" }),
+						dataIndex: "tag",
+						key: "description",
+						search: false,
+					},
+					{
+						title: intl.formatMessage({ id: "term.fields.channel.label" }),
+						dataIndex: "channel",
+						valueType: "select",
+						valueEnum: {
+							all: { text: "全部" },
+							1: { text: "中文" },
+							2: { text: "中文草稿" },
+							3: { text: "英文" },
+							4: { text: "英文草稿" },
+							5: { text: "Visuddhinanda" },
+						},
+					},
+					{
+						title: intl.formatMessage({ id: "term.fields.meaning.label" }),
+						dataIndex: "meaning",
+						key: "meaning",
+					},
+					{
+						title: intl.formatMessage({ id: "term.fields.meaning2.label" }),
+						dataIndex: "meaning2",
+						key: "meaning2",
+						tip: "意思过长会自动收缩",
+						ellipsis: true,
+					},
+					{
+						title: intl.formatMessage({ id: "term.fields.note.label" }),
+						dataIndex: "note",
+						key: "note",
+						search: false,
+						tip: "注释过长会自动收缩",
+						ellipsis: true,
+					},
+					{
+						title: intl.formatMessage({ id: "forms.fields.created-at.label" }),
+						key: "created-at",
+						width: 200,
+						search: false,
+						dataIndex: "createdAt",
+						valueType: "date",
+						sorter: (a, b) => a.createdAt - b.createdAt,
+					},
+				]}
+				rowSelection={{
+					// 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+					// 注释该行则默认不显示下拉选项
+					selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+				}}
+				tableAlertRender={({ selectedRowKeys, selectedRows, onCleanSelected }) => (
+					<Space size={24}>
+						<span>
+							已选 {selectedRowKeys.length} 项
+							<Button type="link" style={{ marginInlineStart: 8 }} onClick={onCleanSelected}>
+								取消选择
+							</Button>
+						</span>
+					</Space>
+				)}
+				tableAlertOptionRender={() => {
+					return (
+						<Space size={16}>
+							<Button type="link">批量删除</Button>
+							<Button type="link">导出数据</Button>
+						</Space>
+					);
+				}}
+				request={async (params = {}, sorter, filter) => {
+					// TODO
+					console.log(params, sorter, filter);
+
+					const size = params.pageSize || 20;
+					return {
+						total: 1 << 12,
+						success: true,
+						data: Array.from(Array(size).keys()).map((x) => {
+							const id = ((params.current || 1) - 1) * size + x + 1;
+
+							var it: IItem = {
+								id,
+								word: `word ${id}`,
+								tag: "",
+								channel: "2",
+								meaning: `parent ${id}`,
+								meaning2: `meaning ${id}`,
+								note: `note ${id}`,
+								createdAt: Date.now() - Math.floor(Math.random() * 200000),
+							};
+							return it;
+						}),
+					};
+				}}
+				rowKey="id"
+				//bordered
+				pagination={{
+					showQuickJumper: true,
+					showSizeChanger: true,
+				}}
+				headerTitle={intl.formatMessage({ id: "dict" })}
+				toolBarRender={() => [
+					<Popover content={<TermCreate studio={studioname} />} placement="bottomRight">
+						<Button key="button" icon={<PlusOutlined />} type="primary">
+							{intl.formatMessage({ id: "buttons.create" })}
+						</Button>
+					</Popover>,
+				]}
+				search={{
+					filterType: "light",
+				}}
+				dateFormatter="string"
+			/>
+		</>
+	);
+};
+
+export default Widget;