visuddhinanda 1 month ago
parent
commit
0994fdab7d

+ 76 - 0
dashboard-v6/src/components/auth/Account.tsx

@@ -0,0 +1,76 @@
+import {
+  ProForm,
+  ProFormSelect,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+
+import { get, put } from "../../request";
+import type { IUserApiData, IUserRequest, IUserResponse } from "../../api/Auth";
+
+import { useIntl } from "react-intl";
+
+interface IWidget {
+  userId?: string;
+  onLoad?: (data: IUserApiData) => void;
+}
+
+const AccountWidget = ({ userId, onLoad }: IWidget) => {
+  const intl = useIntl();
+
+  return (
+    <ProForm<IUserApiData>
+      onFinish={async (values: IUserApiData) => {
+        console.log(values);
+
+        const url = `/v2/user/${userId}`;
+        const postData = {
+          roles: values.role,
+        };
+        console.log("account put ", url, postData);
+        const res = await put<IUserRequest, IUserResponse>(url, postData);
+
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+        } else {
+          message.error(res.message);
+        }
+      }}
+      params={{}}
+      request={async () => {
+        const url = `/v2/user/${userId}`;
+        console.log("url", url);
+        const res = await get<IUserResponse>(url);
+        if (res.ok) {
+          if (typeof onLoad !== "undefined") {
+            onLoad(res.data);
+          }
+        }
+        return res.data;
+      }}
+    >
+      <ProFormText width="md" readonly name="userName" label="User Name" />
+      <ProFormText width="md" readonly name="nickName" label="Nick Name" />
+      <ProFormText width="md" readonly name="email" label="Email" />
+      <ProFormSelect
+        options={["administrator", "member", "uploader", "basic"].map(
+          (item) => {
+            return {
+              value: item,
+              label: item,
+            };
+          }
+        )}
+        fieldProps={{
+          mode: "tags",
+        }}
+        width="md"
+        name="role"
+        allowClear={true}
+        label={intl.formatMessage({ id: "forms.fields.role.label" })}
+      />
+    </ProForm>
+  );
+};
+
+export default AccountWidget;

+ 107 - 0
dashboard-v6/src/components/auth/Avatar.tsx

@@ -0,0 +1,107 @@
+import { useIntl } from "react-intl";
+import { Link, useNavigate } from "react-router";
+import { Tooltip, Typography } from "antd";
+import { Avatar } from "antd";
+import { Popover } from "antd";
+import { ProCard } from "@ant-design/pro-components";
+import {
+  UserOutlined,
+  HomeOutlined,
+  LogoutOutlined,
+  SettingOutlined,
+} from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import {
+  currentUser as _currentUser,
+  type IUser,
+} from "../../reducers/current-user";
+import type { TooltipPlacement } from "antd/lib/tooltip";
+import SettingModal from "../setting/SettingModal";
+import LoginButton from "./LoginButton";
+
+const { Title } = Typography;
+
+interface IUserCard {
+  user?: IUser;
+}
+const UserCard = ({ user }: IUserCard) => {
+  const intl = useIntl();
+  const navigate = useNavigate();
+  return (
+    <ProCard
+      style={{ maxWidth: 500, minWidth: 300 }}
+      actions={[
+        <Tooltip
+          title={intl.formatMessage({
+            id: "buttons.setting",
+          })}
+        >
+          <SettingModal trigger={<SettingOutlined key="setting" />} />
+        </Tooltip>,
+        <Tooltip
+          title={intl.formatMessage({
+            id: "columns.library.blog.label",
+          })}
+        >
+          <Link to={`/blog/${user?.realName}/overview`}>
+            <HomeOutlined key="home" />
+          </Link>
+        </Tooltip>,
+        <Tooltip
+          title={intl.formatMessage({
+            id: "buttons.sign-out",
+          })}
+        >
+          <LogoutOutlined
+            key="logout"
+            onClick={() => {
+              sessionStorage.removeItem("token");
+              localStorage.removeItem("token");
+              navigate("/anonymous/users/sign-in");
+            }}
+          />
+        </Tooltip>,
+      ]}
+    >
+      <div>
+        <Title level={4}>{user?.nickName}</Title>
+        <div style={{ textAlign: "right" }}>
+          {intl.formatMessage({
+            id: "buttons.welcome",
+          })}
+        </div>
+      </div>
+    </ProCard>
+  );
+};
+
+interface IWidget {
+  placement?: TooltipPlacement;
+  style?: React.CSSProperties;
+}
+const AvatarWidget = ({ style, placement = "bottomRight" }: IWidget) => {
+  const user = useAppSelector(_currentUser);
+
+  return (
+    <>
+      <Popover
+        content={user ? <UserCard user={user} /> : <LoginButton />}
+        placement={placement}
+      >
+        <span style={style}>
+          <Avatar
+            style={{ backgroundColor: user ? "#87d068" : "gray" }}
+            icon={user?.avatar ? undefined : <UserOutlined />}
+            src={user?.avatar}
+            size="small"
+          >
+            {user ? user?.nickName?.slice(0, 1) : undefined}
+          </Avatar>
+        </span>
+      </Popover>
+    </>
+  );
+};
+
+export default AvatarWidget;

+ 26 - 0
dashboard-v6/src/components/auth/LoginAlert.tsx

@@ -0,0 +1,26 @@
+import { useIntl } from "react-intl";
+import { Alert } from "antd";
+
+import { useAppSelector } from "../../hooks";
+import { isGuest } from "../../reducers/current-user";
+import LoginButton from "./LoginButton";
+
+const LoginAlertWidget = () => {
+  const intl = useIntl();
+  const guest = useAppSelector(isGuest);
+
+  return guest === true ? (
+    <Alert
+      title={intl.formatMessage({
+        id: "message.auth.guest.alert",
+      })}
+      type="warning"
+      closable
+      action={<LoginButton />}
+    />
+  ) : (
+    <></>
+  );
+};
+
+export default LoginAlertWidget;

+ 48 - 0
dashboard-v6/src/components/auth/LoginAlertModal.tsx

@@ -0,0 +1,48 @@
+import { useIntl } from "react-intl";
+import { Modal } from "antd";
+import { ExclamationCircleOutlined } from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { isGuest } from "../../reducers/current-user";
+import { useEffect } from "react";
+import { useNavigate } from "react-router";
+
+interface IWidget {
+  enable?: boolean;
+  mode?: string;
+}
+const LoginAlertModalWidget = ({ enable = false, mode = "read" }: IWidget) => {
+  const intl = useIntl();
+  const navigate = useNavigate();
+
+  const guest = useAppSelector(isGuest);
+  console.log("mode", mode);
+  useEffect(() => {
+    const guestMode = localStorage.getItem("guest_mode");
+    if (guestMode === "true") {
+      return;
+    }
+    if (guest && (mode !== "read" || enable === true)) {
+      Modal.confirm({
+        title: intl.formatMessage({
+          id: "labels.no.login",
+        }),
+        icon: <ExclamationCircleOutlined />,
+        content: intl.formatMessage({
+          id: "message.auth.guest.alert",
+        }),
+        okText: intl.formatMessage({
+          id: "buttons.sign-in",
+        }),
+        cancelText: intl.formatMessage({
+          id: "buttons.use.as.guest",
+        }),
+        onOk: () => navigate("/anonymous/users/sign-in"),
+        onCancel: () => localStorage.setItem("guest_mode", "true"),
+      });
+    }
+  }, [guest, mode, enable]);
+  return <></>;
+};
+
+export default LoginAlertModalWidget;

+ 20 - 0
dashboard-v6/src/components/auth/LoginButton.tsx

@@ -0,0 +1,20 @@
+import { useIntl } from "react-intl";
+import { Link } from "react-router";
+
+interface IWidget {
+  target?: React.HTMLAttributeAnchorTarget;
+}
+const LoginButton = ({ target }: IWidget) => {
+  const intl = useIntl();
+  const url = btoa(window.location.href);
+
+  return (
+    <Link to={`/anonymous/sign-in?url=${url}`} target={target}>
+      {intl.formatMessage({
+        id: "nut.users.sign-in-up.title",
+      })}
+    </Link>
+  );
+};
+
+export default LoginButton;

+ 174 - 0
dashboard-v6/src/components/auth/SignInAvatar.tsx

@@ -0,0 +1,174 @@
+import { useIntl } from "react-intl";
+import { useState } from "react";
+import { useNavigate } from "react-router";
+import { Divider, Menu, Typography } from "antd";
+import { Avatar } from "antd";
+import { Popover } from "antd";
+
+import {
+  UserOutlined,
+  HomeOutlined,
+  LogoutOutlined,
+  SettingOutlined,
+} from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import {
+  currentUser as _currentUser,
+  studioList,
+} from "../../reducers/current-user";
+import type { TooltipPlacement } from "antd/lib/tooltip";
+import SettingModal from "../setting/SettingModal";
+import { AdminIcon } from "../../assets/icon";
+import User from "./User";
+import { fullUrl } from "../../utils";
+import Studio from "./Studio";
+import LoginButton from "./LoginButton";
+
+const { Title, Paragraph, Text } = Typography;
+
+interface IWidget {
+  placement?: TooltipPlacement;
+  style?: React.CSSProperties;
+}
+
+const SignInAvatar = ({ style, placement = "bottomRight" }: IWidget) => {
+  const intl = useIntl();
+  const navigate = useNavigate();
+  const [settingOpen, setSettingOpen] = useState(false);
+
+  const user = useAppSelector(_currentUser);
+  const studios = useAppSelector(studioList);
+
+  console.debug("user", user);
+
+  const canManage =
+    user?.roles?.includes("root") || user?.roles?.includes("administrator");
+
+  if (typeof user === "undefined") {
+    return <LoginButton />;
+  } else {
+    const welcome = (
+      <Paragraph>
+        <Title level={3} style={{ fontSize: 22 }}>
+          {user.nickName}
+        </Title>
+        <Text type="secondary">账户名 {user.realName}</Text>
+        <Paragraph style={{ textAlign: "right", paddingTop: 30 }}>
+          {intl.formatMessage({
+            id: "buttons.welcome",
+          })}
+        </Paragraph>
+      </Paragraph>
+    );
+
+    let userList = [
+      {
+        key: user.realName,
+        label: <User {...user} />,
+      },
+    ];
+    const studioList = studios?.map((item) => {
+      return {
+        key: item.realName ?? "",
+        label: <Studio data={item} />,
+      };
+    });
+    if (studioList) {
+      userList = [...userList, ...studioList];
+    }
+    return (
+      <>
+        <Popover
+          content={
+            <div style={{ width: 350 }}>
+              <>{welcome}</>
+              <Divider></Divider>
+              <div style={{ maxHeight: 500, overflowY: "auto" }}>
+                <Menu
+                  style={{ width: "100%" }}
+                  mode={"inline"}
+                  selectable={false}
+                  items={[
+                    {
+                      key: "account",
+                      label: "选择账户",
+                      icon: <UserOutlined />,
+                      children: userList,
+                    },
+                    {
+                      key: "setting",
+                      label: "设置",
+                      icon: <SettingOutlined />,
+                    },
+                    {
+                      key: "admin",
+                      label: intl.formatMessage({
+                        id: "buttons.admin",
+                      }),
+                      icon: <AdminIcon />,
+                      disabled: !canManage,
+                    },
+                    {
+                      key: "blog",
+                      label: intl.formatMessage({
+                        id: "columns.library.blog.label",
+                      }),
+                      icon: <HomeOutlined key="home" />,
+                    },
+                    {
+                      key: "logout",
+                      label: intl.formatMessage({
+                        id: "buttons.sign-out",
+                      }),
+                      icon: <LogoutOutlined />,
+                    },
+                  ].filter((value) => !value.disabled)}
+                  onClick={(info) => {
+                    switch (info.key) {
+                      case "setting":
+                        setSettingOpen(true);
+                        break;
+                      case "admin":
+                        window.open(fullUrl(`/admin`), "_blank");
+                        break;
+                      case "blog":
+                        window.open(
+                          fullUrl(`/blog/${user.realName}/overview`),
+                          "_blank"
+                        );
+                        break;
+                      case "logout":
+                        sessionStorage.removeItem("token");
+                        localStorage.removeItem("token");
+                        navigate("/anonymous/users/sign-in");
+                        break;
+                    }
+                  }}
+                />
+              </div>
+            </div>
+          }
+          placement={placement}
+        >
+          <span style={style}>
+            <Avatar
+              style={{ backgroundColor: "#87d068" }}
+              icon={<UserOutlined />}
+              src={user?.avatar}
+              size="small"
+            >
+              {user.nickName?.slice(0, 2)}
+            </Avatar>
+          </span>
+        </Popover>
+        <SettingModal
+          open={settingOpen}
+          onClose={() => setSettingOpen(false)}
+        />
+      </>
+    );
+  }
+};
+
+export default SignInAvatar;

+ 26 - 0
dashboard-v6/src/components/auth/SoftwareEdition.tsx

@@ -0,0 +1,26 @@
+import { useIntl } from "react-intl";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import type { TSoftwareEdition } from "../../api/Auth";
+
+interface IWidget {
+  style?: React.CSSProperties;
+}
+const SoftwareEdition = ({ style }: IWidget) => {
+  const intl = useIntl();
+  const user = useAppSelector(currentUser);
+  let edition: TSoftwareEdition = "pro";
+  if (user?.roles?.includes("basic")) {
+    edition = "basic";
+  }
+  console.info("edition", edition);
+  return (
+    <span style={style}>
+      {intl.formatMessage({
+        id: `labels.software.edition.${edition}`,
+      })}
+    </span>
+  );
+};
+
+export default SoftwareEdition;

+ 47 - 0
dashboard-v6/src/components/auth/Studio.tsx

@@ -0,0 +1,47 @@
+import { Avatar, Space } from "antd";
+
+import StudioCard from "./StudioCard";
+import type { IStudio } from "../../api/Auth";
+import { getAvatarColor } from "./utils";
+
+interface IWidget {
+  data?: IStudio;
+  hideAvatar?: boolean;
+  hideName?: boolean;
+  popOver?: React.ReactNode;
+  onClick?: (studioName?: string) => void;
+}
+const StudioWidget = ({
+  data,
+  hideAvatar = false,
+  hideName = false,
+  popOver,
+  onClick,
+}: IWidget) => {
+  return (
+    <StudioCard popOver={popOver} studio={data}>
+      <Space
+        onClick={() => {
+          if (typeof onClick !== "undefined") {
+            onClick(data?.studioName);
+          }
+        }}
+      >
+        {hideAvatar ? (
+          <></>
+        ) : (
+          <Avatar
+            size="small"
+            src={data?.avatar}
+            style={{ backgroundColor: getAvatarColor(data?.nickName) }}
+          >
+            {data?.nickName?.slice(0, 2)}
+          </Avatar>
+        )}
+        {hideName ? "" : data?.nickName}
+      </Space>
+    </StudioCard>
+  );
+};
+
+export default StudioWidget;

+ 58 - 0
dashboard-v6/src/components/auth/StudioCard.tsx

@@ -0,0 +1,58 @@
+import { useIntl } from "react-intl";
+import { Popover, Avatar } from "antd";
+import { Link } from "react-router";
+import React, { type JSX } from "react";
+
+import type { IStudio } from "../../api/Auth";
+
+interface IWidget {
+  studio?: IStudio;
+  children?: JSX.Element;
+  popOver?: React.ReactNode;
+}
+const StudioCardWidget = ({ studio, children, popOver }: IWidget) => {
+  const intl = useIntl();
+
+  return (
+    <Popover
+      content={
+        popOver ? (
+          popOver
+        ) : (
+          <>
+            <div style={{ display: "flex" }}>
+              <div style={{ paddingRight: 8 }}>
+                <Avatar
+                  style={{ backgroundColor: "#87d068" }}
+                  size="large"
+                  src={studio?.avatar}
+                >
+                  {studio?.nickName?.slice(0, 2)}
+                </Avatar>
+              </div>
+              <div>
+                <div>{studio?.nickName}</div>
+                <div>
+                  <Link
+                    to={`/blog/${studio?.studioName}/overview`}
+                    target="_blank"
+                  >
+                    {intl.formatMessage({
+                      id: "columns.library.blog.label",
+                    })}
+                  </Link>
+                </div>
+              </div>
+            </div>
+          </>
+        )
+      }
+      placement="bottomRight"
+      arrow={{ pointAtCenter: true }}
+    >
+      {children}
+    </Popover>
+  );
+};
+
+export default StudioCardWidget;

+ 28 - 0
dashboard-v6/src/components/auth/ToLibrary.tsx

@@ -0,0 +1,28 @@
+import { useIntl } from "react-intl";
+import { Button } from "antd";
+import { Link } from "react-router";
+
+const ToLibraryWidget = () => {
+  const intl = useIntl();
+
+  return (
+    <>
+      <Link to="/palicanon/list">
+        <Button
+          type="primary"
+          style={{
+            paddingLeft: 18,
+            paddingRight: 18,
+            backgroundColor: "#52974e",
+          }}
+        >
+          {intl.formatMessage({
+            id: "columns.library.title",
+          })}
+        </Button>
+      </Link>
+    </>
+  );
+};
+
+export default ToLibraryWidget;

+ 40 - 0
dashboard-v6/src/components/auth/ToStudio.tsx

@@ -0,0 +1,40 @@
+import { useIntl } from "react-intl";
+import { Button } from "antd";
+import { Link } from "react-router";
+
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+
+interface IWidget {
+  style?: React.CSSProperties;
+}
+const ToStudioWidget = ({ style }: IWidget) => {
+  const intl = useIntl();
+
+  const user = useAppSelector(_currentUser);
+
+  if (typeof user !== "undefined") {
+    return (
+      <span style={style}>
+        <Button
+          type="primary"
+          style={{
+            paddingLeft: 18,
+            paddingRight: 18,
+            backgroundColor: "#52974e",
+          }}
+        >
+          <Link to={`/studio/${user.realName}/home`} target="_blank">
+            {intl.formatMessage({
+              id: "columns.studio.title",
+            })}
+          </Link>
+        </Button>
+      </span>
+    );
+  } else {
+    return <></>;
+  }
+};
+
+export default ToStudioWidget;

+ 71 - 0
dashboard-v6/src/components/auth/User.tsx

@@ -0,0 +1,71 @@
+import { Avatar, Popover, Space, Typography } from "antd";
+import { getAvatarColor } from "./utils";
+
+const { Text } = Typography;
+
+interface IWidget {
+  id?: string;
+  nickName?: string;
+  userName?: string;
+  avatar?: string;
+  showAvatar?: boolean;
+  showName?: boolean;
+  showUserName?: boolean;
+  hidePopover?: boolean;
+}
+const UserWidget = ({
+  nickName,
+  userName,
+  avatar,
+  showAvatar = true,
+  showName = true,
+  showUserName = false,
+  hidePopover = false,
+}: IWidget) => {
+  const inner = (
+    <Space>
+      {showAvatar ? (
+        <Avatar
+          size={"small"}
+          src={avatar}
+          style={
+            avatar ? undefined : { backgroundColor: getAvatarColor(nickName) }
+          }
+        >
+          {nickName?.slice(0, 2)}
+        </Avatar>
+      ) : undefined}
+      {showName ? <Text>{nickName}</Text> : undefined}
+      {showName && showUserName ? <Text>@</Text> : undefined}
+      {showUserName ? <Text>{userName}</Text> : undefined}
+    </Space>
+  );
+  return hidePopover ? (
+    inner
+  ) : (
+    <Popover
+      content={
+        <div>
+          <div>
+            <Avatar
+              size="large"
+              src={avatar}
+              style={
+                avatar
+                  ? undefined
+                  : { backgroundColor: getAvatarColor(nickName) }
+              }
+            >
+              {nickName?.slice(0, 2)}
+            </Avatar>
+          </div>
+          <Text>{`${nickName}@${userName}`}</Text>
+        </div>
+      }
+    >
+      {inner}
+    </Popover>
+  );
+};
+
+export default UserWidget;

+ 12 - 0
dashboard-v6/src/components/auth/utils.ts

@@ -0,0 +1,12 @@
+export const getAvatarColor = (name?: string) => {
+  const avatarColor = ["indianred", "blueviolet", "#87d068", "#108ee9"];
+  if (!name) {
+    return undefined;
+  }
+  let char = 0;
+  if (name.length > 1) {
+    char = name.length - 1;
+  }
+  const colorIndex = name.charCodeAt(char) % avatarColor.length;
+  return avatarColor[colorIndex];
+};

+ 105 - 0
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -0,0 +1,105 @@
+import { Menu, type MenuProps } from "antd";
+import {
+  SearchOutlined,
+  HomeOutlined,
+  RobotOutlined,
+  BookOutlined,
+} from "@ant-design/icons";
+
+import { useLocation, useNavigate } from "react-router";
+
+interface Props {
+  onSearch?: () => void;
+}
+const Widget = ({ onSearch }: Props) => {
+  const location = useLocation();
+  const navigate = useNavigate();
+
+  const items: MenuProps["items"] = [
+    {
+      key: "search",
+      icon: <SearchOutlined />,
+      label: "搜索",
+    },
+    {
+      key: "/workspace/home",
+      icon: <HomeOutlined />,
+      label: "主页",
+    },
+    {
+      key: "/workspace/ai",
+      icon: <RobotOutlined />,
+      label: "AI",
+    },
+    {
+      key: "/workspace/tipitaka",
+      icon: <BookOutlined />,
+      label: "巴利三藏",
+    },
+    {
+      key: "divider",
+      type: "divider",
+    },
+    {
+      key: "/workspace/recent",
+      icon: <BookOutlined />,
+      label: "最近打开",
+      children: [],
+    },
+    {
+      key: "/workspace/anthology",
+      icon: <BookOutlined />,
+      label: "文集",
+    },
+    {
+      key: "/workspace/channel",
+      icon: <BookOutlined />,
+      label: "频道",
+    },
+    {
+      key: "/workspace/task",
+      icon: <BookOutlined />,
+      label: "task",
+      children: [
+        {
+          key: "/workspace/task/hell",
+          icon: <BookOutlined />,
+          label: "task hell",
+        },
+      ],
+    },
+  ];
+
+  /** 当前高亮规则 */
+  const selectedKey: string =
+    location.pathname === "/"
+      ? "/"
+      : (items?.find(
+          (i) =>
+            i &&
+            "key" in i &&
+            typeof i.key === "string" &&
+            location.pathname.startsWith(i.key)
+        )?.key as string) || "";
+
+  /** 点击菜单 */
+  const handleClick = ({ key }: { key: string }) => {
+    if (key === "search") {
+      onSearch?.();
+      return;
+    }
+    navigate(key);
+  };
+
+  return (
+    <Menu
+      mode="inline"
+      selectedKeys={[selectedKey]}
+      items={items}
+      onClick={handleClick}
+      style={{ borderRight: 0 }}
+    />
+  );
+};
+
+export default Widget;

+ 131 - 0
dashboard-v6/src/components/setting/SettingAccount.tsx

@@ -0,0 +1,131 @@
+import {
+  ProForm,
+  ProFormText,
+  ProFormUploadButton,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import { useIntl } from "react-intl";
+
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import { delete_, get, put } from "../../request";
+import type { IUserRequest, IUserResponse } from "../../api/Auth";
+import type { UploadFile } from "antd/es/upload/interface";
+import { get as getToken } from "../../reducers/current-user";
+import type { IAttachmentResponse } from "../../api/Attachments";
+import type { IDeleteResponse } from "../../api/Group";
+
+interface IAccount {
+  id: string;
+  realName: string;
+  nickName: string;
+  email: string;
+  avatar?: UploadFile<IAttachmentResponse>[];
+}
+
+const SettingAccountWidget = () => {
+  const user = useAppSelector(currentUser);
+  const intl = useIntl();
+
+  return (
+    <ProForm<IAccount>
+      onFinish={async (values: IAccount) => {
+        console.log(values);
+        let _avatar: string = "";
+
+        if (
+          typeof values.avatar === "undefined" ||
+          values.avatar.length === 0
+        ) {
+          _avatar = "";
+        } else if (typeof values.avatar[0].response === "undefined") {
+          _avatar = values.avatar[0].uid;
+        } else {
+          console.debug("upload ", values.avatar[0].response);
+          _avatar = values.avatar[0].response.data.name;
+        }
+        const url = `/v2/user/${user?.id}`;
+        const postData = {
+          nickName: values.nickName,
+          avatar: _avatar,
+          email: values.email,
+        };
+        console.log("account put ", url, postData);
+        const res = await put<IUserRequest, IUserResponse>(url, postData);
+
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+        } else {
+          message.error(res.message);
+        }
+      }}
+      params={{}}
+      request={async () => {
+        const url = `/v2/user/${user?.id}`;
+        console.log("url", url);
+        const res = await get<IUserResponse>(url);
+        if (!res.ok) {
+          console.error("error get user info");
+        }
+        return {
+          id: res.data.id,
+          realName: res.data.userName,
+          nickName: res.data.nickName,
+          email: res.data.email,
+          avatar:
+            res.data.avatar && res.data.avatarName
+              ? [
+                  {
+                    uid: res.data.avatarName,
+                    name: "avatar",
+                    thumbUrl: res.data.avatar,
+                  },
+                ]
+              : [],
+        };
+      }}
+    >
+      <ProFormUploadButton
+        name="avatar"
+        label="头像"
+        max={1}
+        fieldProps={{
+          name: "file",
+          listType: "picture-card",
+          className: "avatar-uploader",
+          headers: {
+            Authorization: `Bearer ${getToken()}`,
+          },
+          onRemove: (file: UploadFile<unknown>): boolean => {
+            console.log("remove", file);
+            const url = `/v2/attachment/1?name=${file.uid}`;
+            console.info("avatar delete url", url);
+            delete_<IDeleteResponse>(url)
+              .then((json) => {
+                if (json.ok) {
+                  message.success("删除成功");
+                } else {
+                  message.error(json.message);
+                }
+              })
+              .catch((e) => console.log("Oops errors!", e));
+            return true;
+          },
+        }}
+        action={`${import.meta.env.BASE_URL}/api/v2/attachment?type=avatar`}
+        extra="必须为正方形。最大512*512"
+      />
+      <ProFormText
+        width="md"
+        readonly
+        name="realName"
+        label="realName"
+        tooltip="最长为 24 位"
+      />
+      <ProFormText width="md" name="nickName" label="nickName" />
+      <ProFormText readonly name="email" width="md" label="email" />
+    </ProForm>
+  );
+};
+
+export default SettingAccountWidget;

+ 61 - 0
dashboard-v6/src/components/setting/SettingArticle.tsx

@@ -0,0 +1,61 @@
+import { Divider } from "antd";
+import { useAppSelector } from "../../../hooks";
+import { settingInfo } from "../../../reducers/setting";
+
+import { SettingFind } from "./default";
+import SettingItem from "./SettingItem";
+import { useIntl } from "react-intl";
+
+const SettingArticleWidget = () => {
+  const settings = useAppSelector(settingInfo);
+  const intl = useIntl();
+  return (
+    <div>
+      <Divider>
+        {intl.formatMessage({
+          id: `buttons.read`,
+        })}
+      </Divider>
+      <SettingItem data={SettingFind("setting.display.original", settings)} />
+      <SettingItem data={SettingFind("setting.layout.direction", settings)} />
+      <SettingItem data={SettingFind("setting.layout.commentary", settings)} />
+      <SettingItem data={SettingFind("setting.layout.root.fixed", settings)} />
+      <SettingItem data={SettingFind("setting.layout.paragraph", settings)} />
+      <SettingItem
+        data={SettingFind("setting.pali.script.primary", settings)}
+      />
+      <SettingItem
+        data={SettingFind("setting.pali.script.secondary", settings)}
+      />
+      <SettingItem data={SettingFind("setting.term.first.show", settings)} />
+      <Divider>
+        {intl.formatMessage({
+          id: `buttons.translate`,
+        })}
+      </Divider>
+
+      <Divider>
+        {intl.formatMessage({
+          id: `buttons.wbw`,
+        })}
+      </Divider>
+      <SettingItem data={SettingFind("setting.wbw.order", settings)} />
+      <Divider>Nissaya</Divider>
+      <SettingItem
+        data={SettingFind("setting.nissaya.layout.read", settings)}
+      />
+      <SettingItem
+        data={SettingFind("setting.nissaya.layout.edit", settings)}
+      />
+
+      <Divider>
+        {intl.formatMessage({
+          id: `columns.library.dict.title`,
+        })}
+      </Divider>
+      <SettingItem data={SettingFind("setting.dict.lang", settings)} />
+    </div>
+  );
+};
+
+export default SettingArticleWidget;

+ 179 - 0
dashboard-v6/src/components/setting/SettingItem.tsx

@@ -0,0 +1,179 @@
+import { useIntl } from "react-intl";
+import { useMemo, type JSX } from "react";
+import {
+  type RadioChangeEvent,
+  Switch,
+  Typography,
+  Radio,
+  Select,
+  Transfer,
+} from "antd";
+import type { TransferKey } from "antd/lib/transfer/interface";
+
+import {
+  onChange as onSettingChanged,
+  settingInfo,
+  type ISettingItem,
+} from "../../reducers/setting";
+import { useAppSelector } from "../../hooks";
+import store from "../../store";
+import type { ISetting } from "./default";
+
+const { Text } = Typography;
+
+interface IWidgetSettingItem {
+  data?: ISetting;
+  autoSave?: boolean;
+  bordered?: boolean;
+  onChange?: (key: string, newTargetKeys: string[] | boolean | string) => void;
+}
+
+const SettingItemWidget = ({
+  data,
+  bordered = true,
+  onChange,
+  autoSave = true,
+}: IWidgetSettingItem) => {
+  const intl = useIntl();
+  const settings: ISettingItem[] | undefined = useAppSelector(settingInfo);
+
+  // 用 useMemo 替代 useEffect + useState,派生当前值
+  const value = useMemo(() => {
+    const currSetting = settings?.find((element) => element.key === data?.key);
+    if (typeof currSetting !== "undefined") {
+      return currSetting.value;
+    }
+    return data?.defaultValue;
+  }, [data?.key, data?.defaultValue, settings]);
+
+  const targetKeys = useMemo(() => {
+    if (Array.isArray(value)) {
+      return value as string[];
+    }
+    return [];
+  }, [value]);
+
+  let content: JSX.Element = <></>;
+
+  if (typeof data === "undefined") {
+    return content;
+  }
+
+  const description: string | undefined = data.description
+    ? intl.formatMessage({ id: data.description })
+    : undefined;
+
+  switch (typeof data.defaultValue) {
+    case "number":
+      break;
+
+    case "object":
+      if (data.widget === "transfer" && typeof data.options !== "undefined") {
+        content = (
+          <Transfer
+            dataSource={data.options.map((item) => ({
+              key: item.value,
+              title: intl.formatMessage({ id: item.label }),
+            }))}
+            titles={["备选", intl.formatMessage({ id: "labels.selected" })]}
+            targetKeys={targetKeys}
+            onChange={(newTargetKeys: TransferKey[]) => {
+              const keys = newTargetKeys.map(String);
+              store.dispatch(onSettingChanged({ key: data.key, value: keys }));
+              onChange?.(data.key, keys);
+            }}
+            render={(item) => item.title ?? ""}
+            oneWay
+          />
+        );
+      }
+      break;
+
+    case "string":
+      if (
+        data.widget === "radio-button" &&
+        typeof data.options !== "undefined"
+      ) {
+        content = (
+          <Radio.Group
+            value={value}
+            buttonStyle="solid"
+            onChange={(e: RadioChangeEvent) => {
+              if (autoSave) {
+                store.dispatch(
+                  onSettingChanged({ key: data.key, value: e.target.value })
+                );
+              }
+              onChange?.(data.key, e.target.value);
+            }}
+          >
+            {data.options.map((item, id) => (
+              <Radio.Button key={id} value={item.value}>
+                {intl.formatMessage({ id: item.label })}
+              </Radio.Button>
+            ))}
+          </Radio.Group>
+        );
+      } else if (typeof data.options !== "undefined") {
+        content = (
+          <Select
+            value={value as string}
+            style={{ width: 120 }}
+            variant={bordered ? "outlined" : "borderless"}
+            onChange={(val: string) => {
+              if (autoSave) {
+                store.dispatch(onSettingChanged({ key: data.key, value: val }));
+              }
+              onChange?.(data.key, val);
+            }}
+            options={data.options.map((item) => ({
+              value: item.value,
+              label: intl.formatMessage({ id: item.label }),
+            }))}
+          />
+        );
+      }
+      break;
+
+    case "boolean":
+      content = (
+        <Switch
+          checked={value as boolean}
+          onChange={(checked) => {
+            if (autoSave) {
+              store.dispatch(
+                onSettingChanged({ key: data.key, value: checked })
+              );
+            }
+            onChange?.(data.key, checked);
+          }}
+        />
+      );
+      break;
+
+    default:
+      break;
+  }
+
+  return (
+    <div style={{ marginBottom: 10 }}>
+      <div
+        style={{
+          display: "flex",
+          justifyContent: "space-between",
+          flexWrap: "wrap",
+        }}
+      >
+        <div>
+          <div>
+            <Text>{intl.formatMessage({ id: data.label })}</Text>
+          </div>
+          <Text type="secondary">{description}</Text>
+        </div>
+        <div style={{ marginLeft: "auto" }}>{content}</div>
+      </div>
+    </div>
+  );
+};
+
+export default SettingItemWidget;

+ 54 - 0
dashboard-v6/src/components/setting/SettingModal.tsx

@@ -0,0 +1,54 @@
+import { Modal, Tabs } from "antd";
+import { useState } from "react";
+import SettingArticle from "./SettingArticle";
+import SettingAccount from "./SettingAccount";
+interface IWidget {
+  trigger?: React.ReactNode;
+  open?: boolean;
+  onClose?: (isOpen: boolean) => void;
+}
+const SettingModalWidget = ({ trigger, open, onClose }: IWidget) => {
+  const [isInnerOpen, setIsInnerOpen] = useState(false);
+  const isModalOpen = open ?? isInnerOpen;
+
+  const showModal = () => {
+    setIsInnerOpen(true);
+  };
+
+  const handleOk = () => {
+    if (typeof onClose !== "undefined") {
+      onClose(false);
+    }
+    setIsInnerOpen(false);
+  };
+
+  const handleCancel = () => {
+    if (typeof onClose !== "undefined") {
+      onClose(false);
+    }
+    setIsInnerOpen(false);
+  };
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"80%"}
+        title="Setting"
+        footer={false}
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <Tabs
+          tabPlacement="start"
+          items={[
+            { label: "账户", key: "account", children: <SettingAccount /> }, // 务必填写 key
+            { label: "编辑器", key: "editor", children: <SettingArticle /> },
+          ]}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default SettingModalWidget;

+ 289 - 0
dashboard-v6/src/components/setting/default.ts

@@ -0,0 +1,289 @@
+import type { ISettingItem } from "../../../reducers/setting"
+
+export interface ISettingItemOption {
+  label: string;
+  value: string;
+}
+export interface ISetting {
+  key: string;
+  label: string;
+  description?: string;
+  defaultValue: string | number | boolean | string[];
+  value?: string | number | boolean;
+  widget?: "input" | "select" | "radio" | "radio-button" | "transfer";
+  options?: ISettingItemOption[];
+  max?: number;
+  min?: number;
+}
+
+export const GetUserSetting = (
+  key: string,
+  curr?: ISettingItem[]
+): string | number | boolean | string[] | undefined => {
+  const currSetting = curr?.find((element) => element.key === key);
+  if (typeof currSetting !== "undefined") {
+    return currSetting.value;
+  } else {
+    const _default = defaultSetting.find((element) => element.key === key);
+    if (typeof _default !== "undefined") {
+      return _default.defaultValue;
+    } else {
+      return undefined;
+    }
+  }
+};
+
+export const SettingFind = (
+  key: string,
+  settings?: ISettingItem[]
+): ISetting | undefined => {
+  const userSetting = GetUserSetting(key, settings);
+  const result = defaultSetting.find((element) => element.key === key);
+  if (userSetting && result) {
+    result.defaultValue = userSetting;
+  }
+  return result;
+};
+
+export const defaultSetting: ISetting[] = [
+  {
+    /**
+     * 是否显示巴利原文
+     */
+    key: "setting.display.original",
+    label: "setting.display.original.label",
+    description: "setting.display.original.description",
+    defaultValue: true,
+  },
+  {
+    /**
+     * 排版方向
+     */
+    key: "setting.layout.direction",
+    label: "setting.layout.direction.label",
+    description: "setting.layout.direction.description",
+    defaultValue: "column",
+    options: [
+      {
+        value: "column",
+        label: "setting.layout.direction.col.label",
+      },
+      {
+        value: "row",
+        label: "setting.layout.direction.row.label",
+      },
+    ],
+    widget: "radio-button",
+  },
+  {
+    /**
+     * commentary排版方向
+     */
+    key: "setting.layout.commentary",
+    label: "setting.layout.commentary.label",
+    description: "setting.layout.direction.description",
+    defaultValue: "column",
+    options: [
+      {
+        value: "column",
+        label: "setting.layout.direction.col.label",
+      },
+      {
+        value: "row",
+        label: "setting.layout.direction.row.label",
+      },
+    ],
+    widget: "radio-button",
+  },
+  {
+    /**
+     * 段落或者逐句对读
+     */
+    key: "setting.layout.paragraph",
+    label: "setting.layout.paragraph.label",
+    description: "setting.layout.paragraph.description",
+    defaultValue: "sentence",
+    options: [
+      {
+        value: "sentence",
+        label: "setting.layout.paragraph.sentence.label",
+      },
+      {
+        value: "paragraph",
+        label: "setting.layout.paragraph.paragraph.label",
+      },
+    ],
+    widget: "radio-button",
+  },
+  {
+    /**
+     * 第一巴利脚本
+     */
+    key: "setting.pali.script.primary",
+    label: "setting.pali.script.primary.label",
+    description: "setting.pali.script.primary.description",
+    defaultValue: "roman",
+    options: [
+      {
+        value: "roman",
+        label: "setting.pali.script.rome.label",
+      },
+      {
+        value: "roman_to_my",
+        label: "setting.pali.script.my.label",
+      },
+      {
+        value: "roman_to_si",
+        label: "setting.pali.script.si.label",
+      },
+      {
+        value: "roman_to_thai",
+        label: "setting.pali.script.thai.label",
+      },
+      {
+        value: "roman_to_taitham",
+        label: "setting.pali.script.tai.label",
+      },
+    ],
+  },
+  {
+    /**
+     * 第二巴利脚本
+     */
+    key: "setting.pali.script.secondary",
+    label: "setting.pali.script.secondary.label",
+    description: "setting.pali.script.secondary.description",
+    defaultValue: "none",
+    options: [
+      {
+        value: "none",
+        label: "setting.pali.script.none.label",
+      },
+      {
+        value: "roman",
+        label: "setting.pali.script.rome.label",
+      },
+      {
+        value: "roman_to_my",
+        label: "setting.pali.script.my.label",
+      },
+      {
+        value: "roman_to_si",
+        label: "setting.pali.script.si.label",
+      },
+    ],
+  },
+  {
+    /**
+     * 字典语言
+     */
+    key: "setting.dict.lang",
+    label: "setting.dict.lang.label",
+    description: "setting.dict.lang.description",
+    defaultValue: ["zh-Hans"],
+    widget: "transfer",
+    options: [
+      {
+        value: "en",
+        label: "languages.en-US",
+      },
+      {
+        value: "zh-Hans",
+        label: "languages.zh-Hans",
+      },
+      {
+        value: "zh-Hant",
+        label: "languages.zh-Hant",
+      },
+      {
+        value: "my",
+        label: "languages.my",
+      },
+      {
+        value: "vi",
+        label: "languages.vi",
+      },
+    ],
+  },
+  {
+    /**
+     * 术语首次显示
+     */
+    key: "setting.term.first.show",
+    label: "setting.term.first.show.label",
+    description: "setting.term.first.show.description",
+    defaultValue: "meaning_pali_others",
+    options: [
+      {
+        value: "meaning_pali_others",
+        label: "term.first.show.meaning_pali_others",
+      },
+      {
+        value: "meaning_pali",
+        label: "term.first.show.meaning_pali",
+      },
+      {
+        value: "meaning_others",
+        label: "term.first.show.meaning_others",
+      },
+      {
+        value: "meaning",
+        label: "term.first.show.meaning",
+      },
+    ],
+  },
+  {
+    /**
+     * nissaya 显示模式切换
+     */
+    key: "setting.nissaya.layout.read",
+    label: "setting.nissaya.layout.read.label",
+    defaultValue: "inline",
+    options: [
+      {
+        value: "inline",
+        label: "setting.nissaya.layout.inline.label",
+      },
+      {
+        value: "list",
+        label: "setting.nissaya.layout.list.label",
+      },
+    ],
+    widget: "radio-button",
+  },
+  {
+    /**
+     * nissaya 显示模式切换
+     */
+    key: "setting.nissaya.layout.edit",
+    label: "setting.nissaya.layout.edit.label",
+    defaultValue: "list",
+    options: [
+      {
+        value: "inline",
+        label: "setting.nissaya.layout.inline.label",
+      },
+      {
+        value: "list",
+        label: "setting.nissaya.layout.list.label",
+      },
+    ],
+    widget: "radio-button",
+  },
+  {
+    /**
+     * 是否显示逐词解析输入顺序提示
+     */
+    key: "setting.wbw.order",
+    label: "setting.wbw.order.label",
+    defaultValue: false,
+  },
+  {
+    /**
+     * 是否显示逐词解析输入顺序提示
+     */
+    key: "setting.layout.root.fixed",
+    label: "setting.layout.root.fixed.label",
+    defaultValue: false,
+  },
+];

+ 6 - 0
dashboard-v6/src/components/setting/index.tsx

@@ -0,0 +1,6 @@
+const Widget = () => {
+	return <div>change password</div>;
+  };
+  
+  export default Widget;
+  

+ 36 - 0
dashboard-v6/src/layouts/workspace/index.tsx

@@ -0,0 +1,36 @@
+import { Button, Layout } from "antd";
+import { Outlet } from "react-router";
+import { useState } from "react";
+import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
+import MainMenu from "../../components/navigation/MainMenu";
+import SignInAvatar from "../../components/auth/SignInAvatar";
+
+const { Sider, Content } = Layout;
+const Widget = () => {
+  const [collapsed, setCollapsed] = useState(false);
+  return (
+    <Layout style={{ minHeight: "100vh" }}>
+      <Sider
+        style={{ backgroundColor: "unset" }}
+        trigger={null}
+        collapsible
+        collapsed={collapsed}
+      >
+        <div style={{ display: "flex", justifyContent: "space-between" }}>
+          <SignInAvatar />
+          <Button
+            type="text"
+            icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
+            onClick={() => setCollapsed(!collapsed)}
+          />
+        </div>
+        <MainMenu />
+      </Sider>
+      <Content>
+        <Outlet />
+      </Content>
+    </Layout>
+  );
+};
+
+export default Widget;