├── .eslintignore ├── .prettierignore ├── .prettierrc ├── public ├── robots.txt ├── static │ ├── favicon.png │ ├── logo192.png │ ├── logo512.png │ └── manifest.json ├── moeflow-runtime-config.sample.json └── index.html ├── .env.sample ├── src ├── components │ ├── HotKey │ │ ├── README.md │ │ ├── index.ts │ │ ├── interfaces.ts │ │ ├── constants.ts │ │ ├── utils.ts │ │ ├── components │ │ │ └── HotKeyRecorder.tsx │ │ └── hooks │ │ │ └── useHotKey.ts │ ├── icon │ │ └── index.ts │ ├── project-file │ │ ├── index.ts │ │ ├── MovableAreaColorBackground.tsx │ │ ├── MovableItemBars.tsx │ │ ├── MovableAreaImageBackground.tsx │ │ ├── markers │ │ │ ├── overview │ │ │ │ └── index.tsx │ │ │ └── TranslationUser.tsx │ │ ├── ImageViewerSettingPanel.tsx │ │ └── ImageTranslatorSettingMouse.tsx │ ├── shared-form │ │ ├── FormItem.tsx │ │ ├── Form.tsx │ │ ├── EmailInput.tsx │ │ ├── VCodeInput.tsx │ │ ├── GroupJoinForm.tsx │ │ ├── RoleSelect.tsx │ │ ├── AuthLoginedTip.tsx │ │ └── UserPasswordEditForm.tsx │ ├── __Template__.tsx │ ├── project │ │ ├── ProjectFinishedTip.tsx │ │ ├── LanguageSelect.tsx │ │ └── ProjectImportFromLabelplusStatus.tsx │ ├── shared │ │ ├── LoadingIcon.tsx │ │ ├── Content.tsx │ │ ├── ContentItem.tsx │ │ ├── Dropdown.tsx │ │ ├── ContentTitle.tsx │ │ ├── Spin.tsx │ │ ├── EmptyTip.tsx │ │ ├── ListSkeletonItem.tsx │ │ ├── DashboardBox.tsx │ │ ├── Tooltip.tsx │ │ ├── Avatar.tsx │ │ ├── NavTab.tsx │ │ ├── NavTabs.tsx │ │ └── DebounceStatus.tsx │ ├── setting │ │ ├── LocalePicker.tsx │ │ ├── UserBasicSettings.tsx │ │ ├── UserSecuritySettings.tsx │ │ └── UserEditForm.tsx │ ├── project-set │ │ ├── ProjectSetSettingBase.tsx │ │ ├── ProjectSetCreateForm.tsx │ │ └── ProjectSetEditForm.tsx │ ├── unused │ │ ├── FileCover.tsx │ │ └── ImageOCRProgress.tsx │ └── admin │ │ └── AdminVCodeList.tsx ├── constants │ ├── source.ts │ ├── index.ts │ ├── invitation.ts │ ├── application.ts │ ├── output.ts │ ├── group.ts │ ├── team.ts │ ├── project.ts │ └── file.ts ├── interfaces │ ├── user.ts │ ├── project.ts │ ├── translation.ts │ ├── language.ts │ ├── projectSet.ts │ ├── index.ts │ ├── target.ts │ ├── role.ts │ ├── common.ts │ ├── team.ts │ ├── source.ts │ └── file.ts ├── fonts │ └── clipped │ │ ├── Label-Number.ttf │ │ └── README.txt ├── images │ ├── brand │ │ ├── mascot-jump1.png │ │ └── mascot-jump2.png │ └── common │ │ ├── default-team-avatar.jpg │ │ └── default-user-avatar.jpg ├── hooks │ ├── index.ts │ ├── useStateRef.ts │ ├── useTitle.ts │ └── usePagination.ts ├── utils │ ├── debug-logger.ts │ ├── api.ts │ ├── user.ts │ ├── regex.ts │ ├── i18n.ts │ ├── cookie.ts │ ├── index.test.ts │ ├── storage.ts │ └── source.ts ├── test │ ├── setup.ts │ └── utils.ts ├── apis │ ├── _request.ts │ ├── group.ts │ ├── language.ts │ ├── type.ts │ ├── siteSetting.ts │ ├── me.ts │ ├── tip.ts │ ├── target.ts │ ├── member.ts │ ├── output.ts │ ├── projectSet.ts │ ├── file.ts │ ├── translation.ts │ ├── team.ts │ ├── source.ts │ ├── insight.ts │ └── application.ts ├── pages │ ├── __Template__.tsx │ ├── 404.tsx │ ├── routes.ts │ ├── NewProject.tsx │ ├── NewTeam.tsx │ ├── Project.tsx │ ├── ProjectSetSetting.tsx │ ├── UserSetting.tsx │ └── Index.tsx ├── store │ ├── imageTranslator │ │ └── slice.ts │ ├── translation │ │ └── slice.ts │ ├── file │ │ └── slice.ts │ ├── team │ │ ├── sagas.ts │ │ └── slice.ts │ ├── helpers.ts │ ├── site │ │ └── slice.ts │ ├── project │ │ └── sagas.ts │ ├── user │ │ ├── slice.ts │ │ └── sagas.ts │ ├── projectSet │ │ ├── sagas.ts │ │ └── slice.ts │ ├── index.ts │ └── hotKey │ │ └── slice.ts ├── index.css ├── configs.tsx ├── fontAwesome.ts └── index.tsx ├── DEV.md ├── Dockerfile ├── tailwind.config.mjs ├── typings └── assets │ └── index.d.ts ├── Makefile ├── .dockerignore ├── .eslintrc.react.js ├── .gitignore ├── .github └── workflows │ ├── check-pr.yml │ └── deploy-image.yml ├── font-clipper.js ├── tsconfig.json ├── .eslintrc.js ├── index.html ├── LICENSE.md ├── scripts └── generate-locale-json.ts ├── .eslintrc.base.js └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/locales/*.json 2 | src/locales/*.yaml 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Build time constants. Passed to app by vite. 2 | 3 | REACT_APP_BASE_URL=/api/ 4 | -------------------------------------------------------------------------------- /src/components/HotKey/README.md: -------------------------------------------------------------------------------- 1 | ## TODO 2 | 3 | HotkeyListener Component 4 | useHotKeyFoucs Hook 5 | etc. 6 | -------------------------------------------------------------------------------- /src/constants/source.ts: -------------------------------------------------------------------------------- 1 | // 标记位置类型 2 | export const SOURCE_POSITION_TYPE = { 3 | IN: 1, 4 | OUT: 2, 5 | }; 6 | -------------------------------------------------------------------------------- /DEV.md: -------------------------------------------------------------------------------- 1 | # DEV 2 | 3 | ## Icon 4 | 5 | - New icons need to be registered at [fontAwesome.ts](src/fontAwesome.ts) first. -------------------------------------------------------------------------------- /public/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeflow-com/moeflow-frontend/HEAD/public/static/favicon.png -------------------------------------------------------------------------------- /public/static/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeflow-com/moeflow-frontend/HEAD/public/static/logo192.png -------------------------------------------------------------------------------- /public/static/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeflow-com/moeflow-frontend/HEAD/public/static/logo512.png -------------------------------------------------------------------------------- /src/components/icon/index.ts: -------------------------------------------------------------------------------- 1 | /** */ 2 | export { FontAwesomeIcon as Icon } from '@fortawesome/react-fontawesome'; 3 | -------------------------------------------------------------------------------- /src/interfaces/user.ts: -------------------------------------------------------------------------------- 1 | import { APIUser } from '../apis/user'; 2 | 3 | export interface User extends APIUser {} 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.26 2 | # NOTE assuming `npm build` is executed, like in CI workflow. 3 | COPY ./build /build 4 | -------------------------------------------------------------------------------- /src/fonts/clipped/Label-Number.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeflow-com/moeflow-frontend/HEAD/src/fonts/clipped/Label-Number.ttf -------------------------------------------------------------------------------- /src/images/brand/mascot-jump1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeflow-com/moeflow-frontend/HEAD/src/images/brand/mascot-jump1.png -------------------------------------------------------------------------------- /src/images/brand/mascot-jump2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeflow-com/moeflow-frontend/HEAD/src/images/brand/mascot-jump2.png -------------------------------------------------------------------------------- /src/fonts/clipped/README.txt: -------------------------------------------------------------------------------- 1 | Label-Number.ttf 字体切割自 ABeeZee-Regular.ttf 用于显示标记上的数字 2 | see: https://fonts.google.com/specimen/ABeeZee 3 | -------------------------------------------------------------------------------- /src/interfaces/project.ts: -------------------------------------------------------------------------------- 1 | import { APIProject } from '../apis/project'; 2 | 3 | // 项目 4 | export interface Project extends APIProject {} 5 | -------------------------------------------------------------------------------- /src/images/common/default-team-avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeflow-com/moeflow-frontend/HEAD/src/images/common/default-team-avatar.jpg -------------------------------------------------------------------------------- /src/images/common/default-user-avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeflow-com/moeflow-frontend/HEAD/src/images/common/default-user-avatar.jpg -------------------------------------------------------------------------------- /src/interfaces/translation.ts: -------------------------------------------------------------------------------- 1 | import { APITranslation } from '../apis/translation'; 2 | 3 | export interface Translation extends APITranslation {} 4 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { usePagination } from './usePagination'; 2 | export { useStateRef } from './useStateRef'; 3 | export { useTitle } from './useTitle'; 4 | -------------------------------------------------------------------------------- /src/utils/debug-logger.ts: -------------------------------------------------------------------------------- 1 | import debugModule from 'debug'; 2 | 3 | export function createDebugLogger(namespace: string) { 4 | return debugModule(`moeflow:${namespace}`); 5 | } 6 | -------------------------------------------------------------------------------- /tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | content: ['./public/index.html', './src/**/*.{js,ts,jsx,tsx}'], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /src/test/setup.ts: -------------------------------------------------------------------------------- 1 | // 引入按需引用的 Font Awesome,使用未添加的图标会报错 2 | import '../fontAwesome'; 3 | // 引入 jest-dom expect 扩展,以使用 toHaveClass 之类判断函数 4 | import '@testing-library/jest-dom/extend-expect'; 5 | -------------------------------------------------------------------------------- /typings/assets/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.jpg' { 2 | const path: string; 3 | export default path; 4 | } 5 | declare module '*.png' { 6 | const path: string; 7 | export default path; 8 | } 9 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './file'; 2 | export * from './group'; 3 | export * from './team'; 4 | export * from './project'; 5 | export * from './application'; 6 | export * from './invitation'; 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | locale-json: 2 | node_modules/.bin/tsx scripts/generate-locale-json.ts 3 | 4 | locale-json-watch: 5 | watch make locale-json 6 | 7 | format: 8 | npm run format:fix 9 | 10 | build: .PHONY 11 | npm run build 12 | 13 | .PHONY: 14 | -------------------------------------------------------------------------------- /public/moeflow-runtime-config.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": "Runtime configuration overrides. See src/configs.tsx", 3 | "moeflowCompanion": { 4 | "gradioUrl": "http://localhost:7860", 5 | "defaultMultimodalModel": "gemini-2.5-flash" 6 | } 7 | } -------------------------------------------------------------------------------- /src/constants/invitation.ts: -------------------------------------------------------------------------------- 1 | // 邀请加入状态 2 | export const INVITATION_STATUS = { 3 | PENDING: 1 as 1, 4 | ALLOW: 2 as 2, 5 | DENY: 3 as 3, 6 | }; 7 | export type InvitationStatuses = 8 | (typeof INVITATION_STATUS)[keyof typeof INVITATION_STATUS]; 9 | -------------------------------------------------------------------------------- /src/constants/application.ts: -------------------------------------------------------------------------------- 1 | // 申请加入状态 2 | export const APPLICATION_STATUS = { 3 | PENDING: 1 as 1, 4 | ALLOW: 2 as 2, 5 | DENY: 3 as 3, 6 | }; 7 | export type ApplicationStatuses = 8 | (typeof APPLICATION_STATUS)[keyof typeof APPLICATION_STATUS]; 9 | -------------------------------------------------------------------------------- /src/interfaces/language.ts: -------------------------------------------------------------------------------- 1 | // 语言 2 | export interface Language { 3 | code: string; 4 | enName: string; 5 | gOcrCode: string; 6 | gTraCode: string; 7 | i18nName: string; 8 | id: string; 9 | loName: string; 10 | noSpace: boolean; 11 | sort: number; 12 | } 13 | -------------------------------------------------------------------------------- /src/interfaces/projectSet.ts: -------------------------------------------------------------------------------- 1 | // 项目集 2 | export interface ProjectSet { 3 | id: string; 4 | name: string; 5 | intro: string; 6 | default: boolean; 7 | createTime: string; 8 | editTime: string; 9 | } 10 | // 用户的项目集 11 | export interface UserProjectSet extends ProjectSet {} 12 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | export * from './file'; 3 | export * from './language'; 4 | export * from './project'; 5 | export * from './projectSet'; 6 | export * from './role'; 7 | export * from './target'; 8 | export * from './team'; 9 | export * from './source'; 10 | -------------------------------------------------------------------------------- /src/components/project-file/index.ts: -------------------------------------------------------------------------------- 1 | export { ImageViewer } from './ImageViewer'; 2 | export { ImageSourceViewer } from './markers/ImageSourceViewer'; 3 | export { ImageTranslatorSettingMouse } from './ImageTranslatorSettingMouse'; 4 | export { ImageTranslatorSettingHotKey } from './ImageTranslatorSettingHotKey'; 5 | -------------------------------------------------------------------------------- /src/interfaces/target.ts: -------------------------------------------------------------------------------- 1 | import { Language } from './language'; 2 | 3 | // 项目目标 4 | export interface Target { 5 | id: string; 6 | language: Language; 7 | translatedSourceCount: number; 8 | checkedSourceCount: number; 9 | createTime: string; 10 | editTime: string; 11 | intro: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/interfaces/role.ts: -------------------------------------------------------------------------------- 1 | // 角色/权限 2 | export interface Permission { 3 | id: number; 4 | name: string; 5 | intro: string; 6 | } 7 | export interface Role { 8 | create_time: string; 9 | id: string; 10 | level: number; 11 | name: string; 12 | permissions: Permission[]; 13 | systemCode?: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/HotKey/index.ts: -------------------------------------------------------------------------------- 1 | import Bowser from 'bowser'; 2 | import { OSName } from '../../interfaces'; 3 | 4 | const browser = Bowser.getParser(window.navigator.userAgent); 5 | export const osName = browser.getOSName(true) as OSName; 6 | 7 | export { HotKeyRecorder } from './components/HotKeyRecorder'; 8 | export { useHotKey } from './hooks/useHotKey'; 9 | -------------------------------------------------------------------------------- /src/constants/output.ts: -------------------------------------------------------------------------------- 1 | // 导出进度 2 | export enum OUTPUT_STATUS { 3 | QUEUING = 0, // 排队中 4 | DOWNLOADING = 1, // 源文件整理中 5 | TRANSLATION_OUTPUTING = 2, // 翻译整理中 6 | ZIPING = 3, // 压缩中 7 | SUCCEEDED = 4, // 完成 8 | ERROR = 5, // 导出错误,请重试 9 | } 10 | // 导出类型 11 | export enum OUTPUT_TYPE { 12 | ALL = 0, // 所有内容 13 | ONLY_TEXT = 1, // 仅文本 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/api.ts: -------------------------------------------------------------------------------- 1 | import axios, { CancelToken, Canceler } from 'axios'; 2 | 3 | /** 获取用于取消 Axios 请求的 Token 和 Canceler */ 4 | const getCancelToken = (): [CancelToken, Canceler] => { 5 | const source = axios.CancelToken.source(); 6 | const cancel = () => { 7 | source.cancel('Canceled'); 8 | }; 9 | return [source.token, cancel]; 10 | }; 11 | export { getCancelToken }; 12 | -------------------------------------------------------------------------------- /src/utils/user.ts: -------------------------------------------------------------------------------- 1 | import { Project, Team } from '@/interfaces'; 2 | 3 | /** 4 | * 测试用户是否有某些权限 5 | * @param group 6 | * @param permission 7 | */ 8 | export const can = ( 9 | group: Team | Project | undefined, 10 | permission: number, 11 | ): boolean => { 12 | if (group && group.role) { 13 | return group.role.permissions.findIndex((p) => p.id === permission) > -1; 14 | } 15 | return false; 16 | }; 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # style.ts 编译后的文件,用于 config-overrides.js 中覆盖 LESS 样式 2 | /style.js 3 | /.env 4 | 5 | # report 6 | /report.*.json 7 | 8 | # dependencies 9 | /node_modules 10 | /.pnp 11 | **/.pnp.js 12 | 13 | # testing 14 | /coverage 15 | 16 | # misc 17 | **/.DS_Store 18 | **/.env.local 19 | **/.env.development.local 20 | **/.env.test.local 21 | **/.env.production.local 22 | 23 | **/npm-debug.log* 24 | **/yarn-debug.log* 25 | **/yarn-error.log* 26 | -------------------------------------------------------------------------------- /src/utils/regex.ts: -------------------------------------------------------------------------------- 1 | export const USER_NAME_REGEX = 2 | /^[\u2E80-\u2FDF\u3040-\u318F\u31A0-\u31BF\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FFF\uA960-\uA97F\uAC00-\uD7FFa-zA-Z0-9_]+$/; 3 | export const TEAM_NAME_REGEX = 4 | /^[\u2E80-\u2FDF\u3040-\u318F\u31A0-\u31BF\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FFF\uA960-\uA97F\uAC00-\uD7FFa-zA-Z0-9_]+$/; 5 | export const ID_REGEX = /^[a-f\d]{24}$/i; 6 | export const EMAIL_REGEX = /^[^@ ]+@[^.@ ]+(\.[^.@ ]+)*(\.[^.@ ]{2,})$/; 7 | -------------------------------------------------------------------------------- /src/apis/_request.ts: -------------------------------------------------------------------------------- 1 | import { BasicSuccessResult, request } from '.'; 2 | import { AxiosRequestConfig } from 'axios'; 3 | 4 | export async function uploadRequest( 5 | data: FormData, 6 | configs: AxiosRequestConfig, 7 | ): Promise> { 8 | return request({ 9 | data, 10 | ...configs, 11 | headers: { 12 | ...configs.headers, 13 | 'Content-Type': 'multipart/form-data', 14 | }, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import { GroupTypes } from '../apis/type'; 2 | import { getIntl } from '../locales'; 3 | 4 | export function formatGroupType(groupType: GroupTypes) { 5 | const intl = getIntl(); 6 | 7 | if (groupType === 'project') { 8 | return intl.formatMessage({ id: 'site.project' }); 9 | } else if (groupType === 'team') { 10 | return intl.formatMessage({ id: 'site.team' }); 11 | } else { 12 | return intl.formatMessage({ id: 'site.group' }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/components/shared-form/FormItem.tsx: -------------------------------------------------------------------------------- 1 | import { Form as AntdForm } from 'antd'; 2 | import { FormItemProps as AntdFormItemProps } from 'antd/lib/form'; 3 | import { FC } from '@/interfaces'; 4 | 5 | /** 表单的属性接口 */ 6 | interface FormItemProps {} 7 | /** 8 | * 表单 9 | * 自定义的行为: 10 | * - 当值变动时消除错误 11 | */ 12 | export const FormItem: FC = ({ 13 | ...props 14 | }) => { 15 | return ; 16 | }; 17 | -------------------------------------------------------------------------------- /.eslintrc.react.js: -------------------------------------------------------------------------------- 1 | const base = require('./.eslintrc.base'); 2 | const reactRules = { 3 | 'react-hooks/exhaustive-deps': 'error', 4 | 'react-hooks/rules-of-hooks': 'error', 5 | 'react/button-has-type': 'error', 6 | 'react/display-name': 0, 7 | 'react/prop-types': 0, 8 | 'react/react-in-jsx-scope': 0, 9 | }; 10 | 11 | module.exports = { 12 | ...base, 13 | plugins: [...base.plugins, 'react', 'react-hooks'], 14 | rules: { 15 | ...base.rules, 16 | ...reactRules, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/interfaces/common.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // 增加了 whyDidYouRender 属性的 React.FC 4 | export type FC

> = React.FC

& { 5 | whyDidYouRender?: any; 6 | }; 7 | 8 | // 文本方向定义 9 | export type Direction = 'ltr' | 'rtl'; 10 | export type WritingMode = 'horizontal-tb' | 'vertical-rl' | 'vertical-lr'; 11 | 12 | // 系统名称 13 | export type OSName = 'macos' | 'windows' | 'linux' | 'ios' | undefined; 14 | export type Platform = 'desktop' | 'tablet' | 'mobile' | undefined; 15 | -------------------------------------------------------------------------------- /src/components/__Template__.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React from 'react'; 3 | import { useIntl } from 'react-intl'; 4 | import { FC } from '@/interfaces'; 5 | import classNames from 'classnames'; 6 | 7 | /** 模板的属性接口 */ 8 | interface TmpProps { 9 | className?: string; 10 | } 11 | /** 12 | * 模板 13 | */ 14 | export const Tmp: FC = ({ className }) => { 15 | const { formatMessage } = useIntl(); 16 | 17 | return

; 18 | }; 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # style.ts 编译后的文件,用于 config-overrides.js 中覆盖 LESS 样式 4 | /style.js 5 | 6 | # report 7 | /report.*.json 8 | 9 | # dependencies 10 | /node_modules 11 | /.pnp 12 | .pnp.js 13 | 14 | # testing 15 | /coverage 16 | 17 | # production 18 | /build 19 | 20 | # misc 21 | .DS_Store 22 | .env 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | .vscode/* 29 | .idea 30 | stats.html 31 | 32 | /public/moeflow-runtime-config.json 33 | -------------------------------------------------------------------------------- /src/pages/__Template__.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React from 'react'; 3 | import { useIntl } from 'react-intl'; 4 | import { useHistory } from 'react-router-dom'; 5 | import { useTitle } from '@/hooks'; 6 | import { FC } from '@/interfaces'; 7 | 8 | /** 模板的属性接口 */ 9 | interface TmpProps {} 10 | /** 11 | * 模板 12 | */ 13 | const Tmp: FC = () => { 14 | const history = useHistory(); 15 | const { formatMessage } = useIntl(); 16 | useTitle(); 17 | 18 | return
模板
; 19 | }; 20 | export default Tmp; 21 | -------------------------------------------------------------------------------- /src/components/project/ProjectFinishedTip.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useIntl } from 'react-intl'; 3 | import { FC } from '../../interfaces'; 4 | import { EmptyTip } from '..'; 5 | 6 | /** 项目完结提示的属性接口 */ 7 | interface ProjectFinishedTipProps { 8 | className?: string; 9 | } 10 | /** 11 | * 项目完结提示 12 | */ 13 | export const ProjectFinishedTip: FC = ({ 14 | className, 15 | }) => { 16 | const { formatMessage } = useIntl(); // i18n 17 | 18 | return ; 19 | }; 20 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { useTitle } from '@/hooks'; 2 | import { useHistory } from 'react-router-dom'; 3 | import { FC, useEffect } from 'react'; 4 | 5 | export const NotFoundPage: FC = (props) => { 6 | const history = useHistory(); 7 | useTitle({ 8 | prefix: 'Page not found', 9 | }); 10 | useEffect(() => { 11 | const timer = setTimeout(() => { 12 | history.push('/'); 13 | }, 3000); 14 | 15 | return () => { 16 | clearTimeout(timer); 17 | }; 18 | }, []); 19 | return
Page not found. You will be redirected shortly
; 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/shared/LoadingIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Loading3QuartersOutlined } from '@ant-design/icons'; 2 | import React from 'react'; 3 | import { FC } from '@/interfaces'; 4 | 5 | /** 加载中 Icon 的属性接口 */ 6 | interface LoadingIconProps { 7 | color?: string; 8 | size?: number; 9 | className?: string; 10 | } 11 | /** 12 | * 加载中 Icon 13 | */ 14 | export const LoadingIcon: FC = ({ 15 | size = 18, 16 | color = '#ccc', 17 | className, 18 | } = {}) => { 19 | return ( 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/constants/group.ts: -------------------------------------------------------------------------------- 1 | // 团队允许加入的类型 2 | export const GROUP_ALLOW_APPLY_TYPE = { 3 | NONE: 1, 4 | ALL: 2, 5 | }; 6 | // 所有团体类型公用的权限 7 | export const GROUP_PERMISSION = { 8 | // 基础权限,为0 - 99 9 | ACCESS: 1, 10 | DELETE: 5, 11 | CHANGE: 10, 12 | CREATE_ROLE: 15, 13 | DELETE_ROLE: 20, 14 | // 加入流程权限,为 100 - 199 15 | CHECK_USER: 101, 16 | INVITE_USER: 105, 17 | DELETE_USER: 110, 18 | CHANGE_USER_ROLE: 115, 19 | CHANGE_USER_REMARK: 120, 20 | // 自定义权限为 1000 以上 21 | }; 22 | // 申请加入如何检查 23 | export const APPLICATION_CHECK_TYPE = { 24 | NO_NEED_CHECK: 1, 25 | ADMIN_CHECK: 2, 26 | }; 27 | -------------------------------------------------------------------------------- /src/interfaces/team.ts: -------------------------------------------------------------------------------- 1 | import { Role } from './role'; 2 | 3 | // 团队 4 | export interface Team { 5 | groupType: 'team'; 6 | id: string; 7 | name: string; 8 | intro: string; 9 | hasAvatar: boolean; 10 | avatar: string | null; 11 | allowApplyType: number; 12 | isNeedCheckApplication: boolean; 13 | maxUser: number; 14 | userCount: number; 15 | createTime: string; 16 | editTime: string; 17 | joined?: boolean; 18 | role?: Role; 19 | ocrQuotaMonth: number; 20 | ocrQuotaUsed: number; 21 | } 22 | // 用户的团队(包含角色) 23 | export interface UserTeam extends Team { 24 | role: Role; 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/check-pr.yml: -------------------------------------------------------------------------------- 1 | name: Check PR 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | check-pr: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup node and deps 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: "20" 19 | cache: npm 20 | cache-dependency-path: package-lock.json 21 | 22 | - run: npm i 23 | 24 | - run: npm run lint 25 | 26 | - run: npm run format 27 | 28 | - run: npm run build 29 | -------------------------------------------------------------------------------- /public/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "MoeFlow", 3 | "name": "MoeFlow - Translate Together", 4 | "icons": [ 5 | { 6 | "src": "favicon.png", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/components/shared/Content.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import classNames from 'classnames'; 3 | import { FC } from '@/interfaces'; 4 | import style from '@/style'; 5 | 6 | /** 一般内容 Body 的属性接口 */ 7 | interface ContentProps { 8 | className?: string; 9 | } 10 | /** 11 | * 一般内容 Body 12 | */ 13 | export const Content: FC = ({ children, className }) => { 14 | return ( 15 |
21 | {children} 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /font-clipper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 用于从字体文件中切割需要的部分 3 | */ 4 | const Fontmin = require('fontmin'); 5 | function clip({ text, fontSrc }) { 6 | const fontmin = new Fontmin() 7 | .src(fontSrc) 8 | .use( 9 | Fontmin.glyph({ 10 | text 11 | }) 12 | ) 13 | .dest(fontDest); 14 | fontmin.run(function(err, files, stream) { 15 | if (err) { 16 | console.error(err); 17 | } 18 | console.log('成功'); 19 | }); 20 | } 21 | 22 | const fontDest = 'src/fonts/clipped'; // 输出路径 23 | const fontSrc = 'src/fonts/ABeeZee-Regular.ttf'; // 源文件 24 | const text = '1234567890'; // 需要切出的文本 25 | clip({ text, fontSrc, fontDest }); 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | "paths": { 18 | "@/*": ["./src/*"] 19 | }, 20 | "typeRoots": ["node_modules/@types", "./typings"] 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /src/components/project-file/MovableAreaColorBackground.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React from 'react'; 3 | import { FC } from '@/interfaces'; 4 | 5 | /** 6 | * 可移动区域纯色背景属性接口 7 | */ 8 | interface MovableAreaColorBackgroundProps { 9 | color: string; 10 | className?: string; 11 | } 12 | /** 13 | * 可移动区域纯色背景 14 | */ 15 | export const MovableAreaColorBackground: FC< 16 | MovableAreaColorBackgroundProps 17 | > = ({ color, className }) => { 18 | return ( 19 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/store/imageTranslator/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | export type TranslatorMode = 'source' | 'translator' | 'proofreader' | 'god'; 4 | export interface ImageTranslator { 5 | readonly mode: TranslatorMode; 6 | } 7 | 8 | const initialState: ImageTranslator = { 9 | mode: 'translator', 10 | }; 11 | const slice = createSlice({ 12 | name: 'site', 13 | initialState, 14 | reducers: { 15 | setImageTranslatorMode(state, action: PayloadAction) { 16 | state.mode = action.payload; 17 | }, 18 | }, 19 | }); 20 | 21 | export const { setImageTranslatorMode } = slice.actions; 22 | export default slice.reducer; 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // TODO: 完善 eslint 的使用,在 git 提交时检查 2 | module.exports = { 3 | env: { 4 | browser: true, 5 | es6: true 6 | }, 7 | extends: './.eslintrc.react', 8 | globals: { 9 | Atomics: 'readonly', 10 | SharedArrayBuffer: 'readonly' 11 | }, 12 | parserOptions: { 13 | ecmaFeatures: { 14 | jsx: true 15 | }, 16 | ecmaVersion: 2018, 17 | sourceType: 'module' 18 | }, 19 | plugins: ['react', 'react-hooks'], 20 | rules: { 21 | 'react-hooks/rules-of-hooks': 'error', // 检查 Hook 的规则 22 | 'react-hooks/exhaustive-deps': 'warn', // 检查 effect 的依赖 23 | 'prefer-const': 'warn', 24 | '@typescript-eslint/prefer-as-const': 0, 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/shared/ContentItem.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import classNames from 'classnames'; 3 | import { FC } from '@/interfaces'; 4 | import style from '@/style'; 5 | 6 | /** 一般内容体中一行 的属性接口 */ 7 | interface ContentItemProps { 8 | className?: string; 9 | } 10 | /** 11 | * 一般内容体中一行 12 | */ 13 | export const ContentItem: FC = ({ children, className }) => { 14 | return ( 15 |
23 | {children} 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/constants/team.ts: -------------------------------------------------------------------------------- 1 | import { GROUP_ALLOW_APPLY_TYPE, GROUP_PERMISSION } from './group'; 2 | 3 | // 团队允许加入的类型 4 | export const TEAM_ALLOW_APPLY_TYPE = { 5 | ...GROUP_ALLOW_APPLY_TYPE, 6 | }; 7 | // 团队权限 8 | export const TEAM_PERMISSION = { 9 | ...GROUP_PERMISSION, 10 | AUTO_BECOME_PROJECT_ADMIN: 1010, 11 | CREATE_TERM_BANK: 1020, 12 | ACCESS_TERM_BANK: 1030, 13 | CHANGE_TERM_BANK: 1040, 14 | DELETE_TERM_BANK: 1050, 15 | CREATE_TERM: 1060, 16 | CHANGE_TERM: 1070, 17 | DELETE_TERM: 1080, 18 | CREATE_PROJECT: 1090, 19 | CREATE_PROJECT_SET: 1100, 20 | CHANGE_PROJECT_SET: 1110, 21 | DELETE_PROJECT_SET: 1120, 22 | USE_OCR_QUOTA: 1130, 23 | USE_MT_QUOTA: 1140, 24 | INSIGHT: 1150, 25 | }; 26 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | body { 4 | margin: 0; 5 | font-family: 6 | -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 7 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | } 11 | 12 | code { 13 | font-family: 14 | source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 15 | } 16 | 17 | @font-face { 18 | /* 字体切割自 ABeeZee-Regular.ttf,see: https://fonts.google.com/specimen/ABeeZee */ 19 | font-family: 'Label Number'; 20 | src: url('./fonts/clipped/Label-Number.ttf') format('truetype'); 21 | font-style: normal; 22 | font-weight: 600; 23 | } 24 | -------------------------------------------------------------------------------- /src/store/translation/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | export interface TranslationState { 4 | readonly focusedTranslation: { 5 | id: string; 6 | }; 7 | } 8 | 9 | export const initialState: TranslationState = { 10 | focusedTranslation: { 11 | id: '', 12 | }, 13 | }; 14 | 15 | const slice = createSlice({ 16 | name: 'translation', 17 | initialState, 18 | reducers: { 19 | focusTranslation( 20 | state, 21 | action: PayloadAction<{ 22 | id: string; 23 | }>, 24 | ) { 25 | state.focusedTranslation.id = action.payload.id; 26 | }, 27 | }, 28 | }); 29 | 30 | export const { focusTranslation } = slice.actions; 31 | export default slice.reducer; 32 | -------------------------------------------------------------------------------- /src/components/shared/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Dropdown as AntdDropdown } from 'antd'; 3 | import { DropDownProps as AntdDropdownProps } from 'antd/lib/dropdown'; 4 | import React from 'react'; 5 | import { FC } from '@/interfaces'; 6 | 7 | /** 下拉菜单的属性接口 */ 8 | interface DropdownProps { 9 | className?: string; 10 | } 11 | /** 12 | * 下拉菜单 13 | */ 14 | export const Dropdown: FC = ({ 15 | className, 16 | children, 17 | ...dropdownProps 18 | }) => { 19 | return ( 20 | 26 | {children} 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/test/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 获取 transform 中的 scale 值 3 | * @param transform transform 的值 4 | */ 5 | export function parseTransform(transform: string, name: string): number | null { 6 | const re = new RegExp(name + `\\((.+)\\)`); 7 | const result = transform.match(re); 8 | let value = null; 9 | if (result && result.length > 1) { 10 | value = parseFloat(result[1]); 11 | } 12 | return value; 13 | } 14 | 15 | /** 16 | * 比较元素的 transform: scale(x),x 是否和给予的 scale 值一致 17 | * @param element Dom 元素 18 | * @param scale scale 值 19 | */ 20 | export function expectScale(element: HTMLElement, scale: number): void { 21 | const domScale = parseTransform(element.style.transform, 'scale') as number; 22 | expect(domScale).toBeCloseTo(scale); 23 | } 24 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | MoeFlow 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/shared/ContentTitle.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import classNames from 'classnames'; 3 | import { FC } from '@/interfaces'; 4 | import style from '@/style'; 5 | 6 | /** 一般内容标题的属性接口 */ 7 | interface ContentTitleProps { 8 | className?: string; 9 | } 10 | /** 11 | * 一般内容标题 12 | */ 13 | export const ContentTitle: FC = ({ 14 | children, 15 | className, 16 | }) => { 17 | return ( 18 |
28 | {children} 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/HotKey/interfaces.ts: -------------------------------------------------------------------------------- 1 | export type OSName = 'macos' | 'windows' | 'linux' | undefined; 2 | export type ModifierKey = 'ctrl' | 'alt' | 'shift' | 'meta'; 3 | export type HotKeyEvent = { 4 | key: string; 5 | ctrl: boolean; 6 | alt: boolean; 7 | shift: boolean; 8 | meta: boolean; 9 | displayName: string; 10 | nativeEvent: KeyboardEvent; 11 | }; 12 | export type HotKeyOption = { 13 | id?: string; 14 | key?: string; 15 | ctrl?: boolean; 16 | alt?: boolean; 17 | shift?: boolean; 18 | meta?: boolean; 19 | keyDown?: boolean; 20 | keyUp?: boolean; 21 | disabled?: boolean; 22 | /** 23 | * @deprecated use `disabled` 24 | */ 25 | disibled?: boolean; 26 | preventDefault?: boolean; 27 | stopPropagation?: boolean; 28 | ignoreKeyboardElement?: boolean; 29 | }; 30 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | MoeFlow 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/apis/group.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import { request } from '.'; 3 | import { toPlural } from '../utils'; 4 | import { GroupTypes } from './type'; 5 | 6 | export interface APIGroupPublicInfo { 7 | id: string; 8 | name: string; 9 | joined: boolean; 10 | userCount: number; 11 | applicationCheckType: number; 12 | } 13 | 14 | /** 获取团体申请列表 */ 15 | const getGroupPublicInfo = ({ 16 | groupType, 17 | groupID, 18 | configs, 19 | }: { 20 | groupType: GroupTypes; 21 | groupID: string; 22 | configs?: AxiosRequestConfig; 23 | }) => { 24 | return request({ 25 | method: 'GET', 26 | url: `/v1/${toPlural(groupType)}/${groupID}/public-info`, 27 | ...configs, 28 | }); 29 | }; 30 | 31 | export default { 32 | getGroupPublicInfo, 33 | }; 34 | -------------------------------------------------------------------------------- /src/pages/routes.ts: -------------------------------------------------------------------------------- 1 | export const routes = { 2 | index: '/', 3 | login: '/login', 4 | signUp: '/register', 5 | resetPassword: '/reset-password', 6 | dashboard: { 7 | $: '/dashboard', 8 | user: { 9 | setting: '/dashboard/user/setting', 10 | invitations: `/dashboard/user/invitations`, 11 | relatedApplications: '/dashboard/user/related-applications', 12 | }, 13 | project: { 14 | new: `/dashboard/new-project`, 15 | show: `/dashboard/projects/:projectId`, 16 | asRouter: `/dashboard/projects/:projectId`, 17 | }, 18 | }, 19 | imageTranslator: { 20 | asRouter: `/image-translator/:fileID-:targetID`, 21 | build: (fileId: string, targetId: string) => 22 | `/image-translator/${fileId}-${targetId}`, 23 | }, 24 | admin: '/admin', 25 | } as const; 26 | -------------------------------------------------------------------------------- /src/components/project-file/MovableItemBars.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Icon } from '@/components'; 3 | import React from 'react'; 4 | import { FC } from '@/interfaces'; 5 | 6 | /** 用于拖动元素的三条线样的把手的属性接口 */ 7 | interface MovableItemBarsProps { 8 | className?: string; 9 | } 10 | /** 11 | * 用于拖动元素的三条线样的把手 12 | */ 13 | export const MovableItemBars: FC = ({ className }) => { 14 | return ( 15 |
28 | 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/hooks/useStateRef.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Dispatch, 3 | MutableRefObject, 4 | SetStateAction, 5 | useEffect, 6 | useRef, 7 | useState, 8 | } from 'react'; 9 | 10 | /** 11 | * 实现同时使用State和Ref的自定义Hook 12 | * @typeparam S 初始值类型 13 | * @param initialValue 初始值 14 | */ 15 | export function useStateRef( 16 | initialState: S | (() => S), 17 | ): [S, Dispatch>, MutableRefObject]; 18 | export function useStateRef(): [ 19 | S | undefined, 20 | Dispatch>, 21 | MutableRefObject, 22 | ]; 23 | export function useStateRef(initialValue?: S) { 24 | const [state, setState] = useState(initialValue); 25 | const ref = useRef(initialValue); 26 | useEffect(() => { 27 | ref.current = state; 28 | }, [state]); 29 | return [state, setState, ref]; 30 | } 31 | -------------------------------------------------------------------------------- /src/components/shared-form/Form.tsx: -------------------------------------------------------------------------------- 1 | import { Form as AntdForm } from 'antd'; 2 | import { FormProps as AntdFormProps } from 'antd/lib/form'; 3 | import { Store } from 'rc-field-form/es/interface'; 4 | import { FC } from '@/interfaces'; 5 | 6 | /** 表单的属性接口 */ 7 | interface FormProps {} 8 | /** 9 | * 表单 10 | * 自定义的行为: 11 | * - 当值变动时消除错误 12 | */ 13 | export const Form: FC = ({ ...props }) => { 14 | /** 处理 Form 值变动 */ 15 | const handleValuesChange = (changedValues: Store, values: Store) => { 16 | // 当值变动时消除错误 17 | for (const key in changedValues) { 18 | props.form?.setFields([{ name: key, errors: [] }]); 19 | } 20 | if (props.onValuesChange) { 21 | props.onValuesChange(changedValues, values); 22 | } 23 | }; 24 | 25 | return ; 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/setting/LocalePicker.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from '@/interfaces'; 2 | import { MenuProps } from 'antd'; 3 | import { availableLocales, setLocale } from '@/locales'; 4 | import { Dropdown, Icon } from '@/components'; 5 | import { css } from '@emotion/core'; 6 | 7 | const dropDownMenuItemStyle = css` 8 | width: 150px; 9 | height: 30px; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | `; 14 | 15 | export const LocalePicker: FC = () => { 16 | const menuProps: MenuProps = { 17 | items: Object.entries(availableLocales).map(([locale, label]) => ({ 18 | label:
{label}
, 19 | key: `locale-${locale}`, 20 | onClick: () => setLocale(locale), 21 | })), 22 | }; 23 | return ( 24 | 25 |
26 | 27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/shared/Spin.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Spin as AntdSpin } from 'antd'; 3 | import { SpinProps as AntdSpinProps } from 'antd/lib/spin'; 4 | import { LoadingIcon } from './LoadingIcon'; 5 | import { FC } from '@/interfaces'; 6 | 7 | /** Spin 的属性接口 */ 8 | interface SpinProps extends AntdSpinProps { 9 | className?: string; 10 | } 11 | /** 12 | * Spin 13 | */ 14 | export const Spin: FC = ({ className, children, ...spinProps }) => { 15 | return ( 16 | } 28 | {...spinProps} 29 | > 30 | {children} 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/apis/language.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 各种类型选项(系统角色/加入审核类型等) API 3 | */ 4 | import { request } from '.'; 5 | import { AxiosRequestConfig } from 'axios'; 6 | import { toLowerCamelCase } from '@/utils'; 7 | 8 | export interface APILanguage { 9 | id: string; 10 | enName: string; 11 | loName: string; 12 | i18nName: string; 13 | noSpace: boolean; 14 | code: string; 15 | gTraCode: string; 16 | gOcrCode: string; 17 | sort: number; 18 | } 19 | 20 | /** 获取系统角色的请求数据 */ 21 | interface GetLanguagesData { 22 | configs?: AxiosRequestConfig; 23 | } 24 | 25 | /** Get global lang list */ 26 | async function getLanguages({ configs = {} } = {} as GetLanguagesData) { 27 | const res = await request({ 28 | method: 'GET', 29 | url: `/v1/languages`, 30 | ...configs, 31 | }); 32 | res.data = res.data.map((item) => toLowerCamelCase(item)); 33 | return res; 34 | } 35 | 36 | export default { 37 | getLanguages, 38 | }; 39 | -------------------------------------------------------------------------------- /src/store/file/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | export interface FilesState { 3 | page: number; 4 | word: string; 5 | scrollTop: number; 6 | selectedFileIds: string[]; 7 | } 8 | export interface FileState { 9 | readonly filesState: FilesState; 10 | } 11 | 12 | export const initialState: FileState = { 13 | filesState: { 14 | page: 1, 15 | word: '', 16 | scrollTop: 0, 17 | selectedFileIds: [], 18 | }, 19 | }; 20 | 21 | const slice = createSlice({ 22 | name: 'file', 23 | initialState, 24 | reducers: { 25 | setFilesState(state, action: PayloadAction>) { 26 | state.filesState = { ...state.filesState, ...action.payload }; 27 | }, 28 | resetFilesState(state) { 29 | state.filesState = initialState.filesState; 30 | }, 31 | }, 32 | }); 33 | 34 | export const { setFilesState, resetFilesState } = slice.actions; 35 | export default slice.reducer; 36 | -------------------------------------------------------------------------------- /src/apis/type.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 各种类型选项(系统角色/加入审核类型等) API 3 | */ 4 | import { request } from '.'; 5 | import { toUnderScoreCase, toHyphenCase } from '@/utils'; 6 | import { AxiosRequestConfig } from 'axios'; 7 | 8 | export type TypeNames = 9 | | 'allowApplyType' 10 | | 'applicationCheckType' 11 | | 'systemRole'; 12 | export type GroupTypes = 'team' | 'project'; 13 | 14 | /** 获取系统角色的请求数据 */ 15 | interface GetSystemRolesData { 16 | typeName: TypeNames; 17 | groupType: GroupTypes; 18 | params?: { with_creator?: boolean }; 19 | configs?: AxiosRequestConfig; 20 | } 21 | /** 获取系统角色 */ 22 | const getTypes = ( 23 | { typeName, groupType, params, configs } = {} as GetSystemRolesData, 24 | ) => { 25 | return request({ 26 | method: 'GET', 27 | url: `/v1/types/${toHyphenCase(typeName)}`, 28 | params: { 29 | group_type: groupType, 30 | ...toUnderScoreCase(params), 31 | }, 32 | ...configs, 33 | }); 34 | }; 35 | 36 | export default { 37 | getTypes, 38 | }; 39 | -------------------------------------------------------------------------------- /src/interfaces/source.ts: -------------------------------------------------------------------------------- 1 | import { APISource } from '@/apis/source'; 2 | 3 | export const labelSavingStatuses = ['creating', 'saving', 'deleting']; 4 | 5 | export type LabelStatus = 6 | | 'creating' // 创建中 7 | | 'saving' // 保存修改中 8 | | 'deleting' // 删除中 9 | | 'pending'; // 等待操作 10 | 11 | export type InputDebounceStatus = 12 | | 'debouncing' // 保存防抖中 13 | | 'saving' // 保存中 14 | | 'saveFailed' // 保存失败 15 | | 'saveSuccessful'; // 保存成功 16 | 17 | export interface ProofreadContentStatuses { 18 | [translationID: string]: InputDebounceStatus | undefined; 19 | } 20 | 21 | export interface Source extends APISource { 22 | isTemp: boolean; 23 | focus: boolean; 24 | selecting: boolean; // 选择翻译中 25 | labelStatus: LabelStatus; 26 | myTranslationContentStatus?: InputDebounceStatus; 27 | proodreadContentStatuses: ProofreadContentStatuses; 28 | } 29 | 30 | export type SourceTranslationState = 31 | | 'needTranslation' 32 | | 'needCheckTranslation' 33 | | 'needSelectAndCheckTranslation' 34 | | 'translationOk'; 35 | -------------------------------------------------------------------------------- /src/hooks/useTitle.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, useEffect } from 'react'; 2 | import { useIntl } from 'react-intl'; 3 | 4 | interface UseTitleParams { 5 | prefix?: string; 6 | suffix?: string; 7 | hyphen?: string; 8 | } 9 | interface UseTitle { 10 | (params?: UseTitleParams, deps?: DependencyList): void; 11 | } 12 | /** 13 | * 设置页面标题 14 | * @param prefix 前缀 15 | * @param suffix 后缀 16 | * @param hyphen 站点名和前缀/后缀之间的连字符 17 | * @param deps 18 | */ 19 | export const useTitle: UseTitle = ( 20 | { prefix = '', suffix = '', hyphen = ' · ' }: UseTitleParams = {}, 21 | deps = [], 22 | ): void => { 23 | const { formatMessage } = useIntl(); 24 | if (prefix !== '') prefix = prefix + hyphen; 25 | if (suffix !== '') suffix = hyphen + suffix; 26 | useEffect(() => { 27 | document.title = prefix + formatMessage({ id: 'site.name' }) + suffix; 28 | return () => { 29 | document.title = formatMessage({ id: 'site.name' }); 30 | }; 31 | // eslint-disable-next-line react-hooks/exhaustive-deps 32 | }, deps); 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/project-set/ProjectSetSettingBase.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { useIntl } from 'react-intl'; 3 | import { 4 | Content, 5 | ContentItem, 6 | ContentTitle, 7 | ProjectSetEditForm, 8 | } from '@/components'; 9 | import { FC } from '@/interfaces'; 10 | import style from '@/style'; 11 | 12 | /** 项目集基础设置的属性接口 */ 13 | interface ProjectSetSettingBaseProps { 14 | className?: string; 15 | } 16 | /** 17 | * 项目集基础设置 18 | */ 19 | export const ProjectSetSettingBase: FC = ({ 20 | className, 21 | }) => { 22 | const { formatMessage } = useIntl(); // i18n 23 | 24 | return ( 25 |
33 | 34 | {formatMessage({ id: 'projectSet.info' })} 35 | 36 | 37 | 38 | 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/constants/project.ts: -------------------------------------------------------------------------------- 1 | import { GROUP_PERMISSION, GROUP_ALLOW_APPLY_TYPE } from './group'; 2 | 3 | // 项目允许加入的类型 4 | export const PROJECT_ALLOW_APPLY_TYPE = { 5 | ...GROUP_ALLOW_APPLY_TYPE, 6 | TEAM_USER: 3, 7 | }; 8 | 9 | // 项目权限 10 | export const PROJECT_PERMISSION = { 11 | ...GROUP_PERMISSION, 12 | FINISH: 1010, 13 | ADD_FILE: 1020, 14 | MOVE_FILE: 1030, 15 | RENAME_FILE: 1040, 16 | DELETE_FILE: 1050, 17 | OUTPUT_TRA: 1060, 18 | ADD_LABEL: 1080, 19 | MOVE_LABEL: 1090, 20 | DELETE_LABEL: 1100, 21 | ADD_TRA: 1110, 22 | DELETE_TRA: 1120, 23 | PROOFREAD_TRA: 1130, 24 | CHECK_TRA: 1140, 25 | }; 26 | 27 | // 项目状态 28 | export enum PROJECT_STATUS { 29 | WORKING = 0, 30 | FINISHED = 1, 31 | } 32 | 33 | // 从 LP 导入状态 34 | export enum IMPORT_FROM_LABELPLUS_STATUS { 35 | PENDING = 0, // 排队中 36 | RUNNING = 1, // 进行中 37 | SUCCEEDED = 2, // 成功 38 | ERROR = 3, // 错误 39 | } 40 | 41 | // 从 LP 导入错误 42 | export enum IMPORT_FROM_LABELPLUS_ERROR_TYPE { 43 | UNKNOWN = 0, // 未知 44 | NO_TARGET = 1, // 运行时,没有的翻译目标 45 | NO_CREATOR = 2, // 项目没有创建人 46 | PARSE_FAILED = 3, // 解析失败 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 kozzzx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/shared-form/EmailInput.tsx: -------------------------------------------------------------------------------- 1 | import { Input, InputRef } from 'antd'; 2 | import { InputProps } from 'antd/lib/input'; 3 | import React from 'react'; 4 | 5 | interface EmailInputProps { 6 | value?: string | number | string[]; 7 | onChange?: (value: string) => void; 8 | className?: string; 9 | } 10 | /** 11 | * 邮箱输入框(不允许空格) 12 | */ 13 | const EmailInputWithoutRef: React.ForwardRefRenderFunction< 14 | InputRef, 15 | EmailInputProps & Omit 16 | > = ({ value = '', onChange, className, ...inputProps }, ref) => { 17 | /** 处理值变化 */ 18 | const handleChange = (e: React.ChangeEvent) => { 19 | // 只允许输入数字 20 | e.target.value = e.target.value.replace(/(\s*)/g, ''); 21 | // 如果过滤后和之前值相同,则跳过 22 | if (e.target.value === value) return; 23 | // 调用父级的 onChange 24 | if (onChange) onChange(e.target.value); 25 | }; 26 | 27 | return ( 28 | 35 | ); 36 | }; 37 | export const EmailInput = React.forwardRef(EmailInputWithoutRef); 38 | -------------------------------------------------------------------------------- /src/components/shared-form/VCodeInput.tsx: -------------------------------------------------------------------------------- 1 | import { Input, type InputRef } from 'antd'; 2 | import { InputProps } from 'antd/lib/input'; 3 | import React from 'react'; 4 | 5 | interface VCodeInputProps { 6 | value?: string | number | string[]; 7 | onChange?: (value: string) => void; 8 | className?: string; 9 | } 10 | /** 11 | * 验证码输入框(只允许数字) 12 | */ 13 | const VCodeInputWithoutRef: React.ForwardRefRenderFunction< 14 | InputRef, 15 | VCodeInputProps & Omit 16 | > = ({ value = '', onChange, className, ...inputProps }, ref) => { 17 | /** 处理值变化 */ 18 | const handleChange = (e: React.ChangeEvent) => { 19 | // 只允许输入数字 20 | e.target.value = e.target.value.replace(/[^\d]/g, ''); 21 | // 如果过滤后和之前值相同,则跳过 22 | if (e.target.value === value) return; 23 | // 调用父级的 onChange 24 | if (onChange) onChange(e.target.value); 25 | }; 26 | 27 | return ( 28 | 35 | ); 36 | }; 37 | export const VCodeInput = React.forwardRef(VCodeInputWithoutRef); 38 | -------------------------------------------------------------------------------- /src/utils/cookie.ts: -------------------------------------------------------------------------------- 1 | import { Cookies } from 'react-cookie'; 2 | import { jwtDecode } from 'jwt-decode'; 3 | 4 | const cookies = new Cookies(); 5 | 6 | /** 7 | * 将 Cookie 中取出的字符串转为布尔值 8 | */ 9 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 10 | const toBoolean = (value: string | undefined) => { 11 | return value?.toLowerCase() === 'true'; 12 | }; 13 | 14 | /** 15 | * 从 Cookie 中获取 token 16 | */ 17 | const getToken = (): string | undefined => { 18 | return cookies.get('token'); 19 | }; 20 | 21 | /** 22 | * 向 Cookie 设置 token 23 | * @param token 用户令牌 24 | */ 25 | const setToken = (token: string, rememberMe?: boolean) => { 26 | const { exp } = jwtDecode(token, { header: true }) as { exp: number }; // token 过期时间(时间戳) 27 | const maxAge = exp - Math.floor(new Date().getTime() / 1000); // Cookie 过期时间(x秒后) 28 | if (rememberMe) { 29 | cookies.set('token', token, { path: '/', maxAge }); 30 | } else { 31 | cookies.set('token', token, { path: '/' }); 32 | } 33 | }; 34 | 35 | /** 36 | * 从 Cookie 中删除 token 37 | */ 38 | const removeToken = () => { 39 | cookies.remove('token', { path: '/' }); 40 | }; 41 | 42 | export { getToken, setToken, removeToken }; 43 | -------------------------------------------------------------------------------- /src/utils/index.test.ts: -------------------------------------------------------------------------------- 1 | import { toLowerCamelCase, toUnderScoreCase } from './index'; 2 | 3 | const underScores = [ 4 | 1, 5 | 'str', 6 | 1.1, 7 | true, 8 | false, 9 | undefined, 10 | { aa_aa: 1, bb_bb: 'str', cc_cc: { cc_cc: 1 }, dd_dd: [{ dd_dd: 1 }, 1] }, 11 | [1, 'str', { cc_cc: 1 }, [{ dd_dd: 1 }, 1]], 12 | ]; 13 | 14 | const lowerCamels = [ 15 | 1, 16 | 'str', 17 | 1.1, 18 | true, 19 | false, 20 | undefined, 21 | { aaAa: 1, bbBb: 'str', ccCc: { ccCc: 1 }, ddDd: [{ ddDd: 1 }, 1] }, 22 | [1, 'str', { ccCc: 1 }, [{ ddDd: 1 }, 1]], 23 | ]; 24 | 25 | describe('toLowerCamelCase', () => { 26 | it('conversion', () => { 27 | expect(toLowerCamelCase(underScores)).toEqual(lowerCamels); 28 | for (let i = 0; i < underScores.length; i++) { 29 | expect(toLowerCamelCase(underScores[i])).toEqual(lowerCamels[i]); 30 | } 31 | }); 32 | }); 33 | 34 | describe('toUnderScoreCase', () => { 35 | it('conversion', () => { 36 | expect(toUnderScoreCase(lowerCamels)).toEqual(underScores); 37 | for (let i = 0; i < lowerCamels.length; i++) { 38 | expect(toUnderScoreCase(lowerCamels[i])).toEqual(underScores[i]); 39 | } 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/shared/EmptyTip.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import classNames from 'classnames'; 3 | import React from 'react'; 4 | import { FC } from '@/interfaces'; 5 | import style from '@/style'; 6 | 7 | /** 空提示的属性接口 */ 8 | interface EmptyTipProps { 9 | text: string | React.ReactNode | React.ReactNode[]; 10 | buttons?: React.ReactNode | React.ReactNode[]; 11 | className?: string; 12 | } 13 | /** 14 | * 空提示 15 | */ 16 | export const EmptyTip: FC = ({ text, buttons, className }) => { 17 | return ( 18 |
35 |
{text}
36 |
{buttons}
37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/pages/NewProject.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React from 'react'; 3 | import { useIntl } from 'react-intl'; 4 | import { 5 | Content, 6 | ContentItem, 7 | ContentTitle, 8 | DashboardBox, 9 | GroupJoinForm, 10 | } from '../components'; 11 | import { FC } from '../interfaces'; 12 | import style from '../style'; 13 | import { useTitle } from '../hooks'; 14 | 15 | /** 加入项目的属性接口 */ 16 | interface NewProjectProps {} 17 | /** 18 | * 加入项目 19 | */ 20 | const NewProject: FC = () => { 21 | const { formatMessage } = useIntl(); // i18n 22 | useTitle(); // 设置标题 23 | 24 | return ( 25 | 34 | 35 | {formatMessage({ id: 'site.joinProject' })} 36 | 37 | 38 | 39 | 40 | 41 | } 42 | /> 43 | ); 44 | }; 45 | export default NewProject; 46 | -------------------------------------------------------------------------------- /src/apis/siteSetting.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import { request } from '.'; 3 | import { toUnderScoreCase } from '@/utils'; 4 | 5 | export interface APISiteSetting { 6 | enableWhitelist: boolean; 7 | whitelistEmails: string[]; 8 | onlyAllowAdminCreateTeam: boolean; 9 | autoJoinTeamIDs: string[]; 10 | } 11 | 12 | const getSiteSetting = ({ configs }: { configs?: AxiosRequestConfig }) => { 13 | return request({ 14 | method: 'GET', 15 | url: `/v1/admin/site-setting`, 16 | ...configs, 17 | }); 18 | }; 19 | 20 | const editSiteSetting = ({ 21 | data, 22 | configs, 23 | }: { 24 | data: APISiteSetting; 25 | configs?: AxiosRequestConfig; 26 | }) => { 27 | return request({ 28 | method: 'PUT', 29 | url: `/v1/admin/site-setting`, 30 | data: toUnderScoreCase(data), 31 | ...configs, 32 | }); 33 | }; 34 | 35 | export interface APIHomepage { 36 | html: string; 37 | css: string; 38 | } 39 | const getHomepage = ({ configs }: { configs?: AxiosRequestConfig }) => { 40 | return request({ 41 | method: 'GET', 42 | url: `/v1/site/homepage`, 43 | ...configs, 44 | }); 45 | }; 46 | 47 | export default { 48 | getSiteSetting, 49 | editSiteSetting, 50 | getHomepage, 51 | }; 52 | -------------------------------------------------------------------------------- /src/interfaces/file.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FileNotExistReasons, 3 | FileSafeStatuses, 4 | FileTypes, 5 | ParseStatuses, 6 | } from '../constants'; 7 | 8 | // 文件目标缓存 9 | export interface FileTargetCache { 10 | translatedSourceCount: number; 11 | checkedSourceCount: number; 12 | } 13 | 14 | // 文件 15 | export interface File { 16 | id: string; 17 | name: string; 18 | saveName: string; 19 | type: FileTypes; 20 | sourceCount: number; 21 | translatedSourceCount: number; 22 | checkedSourceCount: number; 23 | safeStatus: FileSafeStatuses; 24 | fileNotExistReason: FileNotExistReasons; 25 | parseStatus: ParseStatuses; 26 | parseStatusDetailName: string; 27 | parseErrorTypeDetailName: string; 28 | parentID: string | null; 29 | fileTargetCache?: FileTargetCache; 30 | // 图片文件专用 31 | url?: string; 32 | coverUrl?: string; 33 | safeCheckUrl?: string; 34 | nextImage?: File; 35 | prevImage?: File; 36 | imageOcrPercent?: number; 37 | imageOcrPercentDetailName?: string; 38 | 39 | /** 40 | * NOTE fields below are browser only 41 | */ 42 | // 上传中的文件 43 | uploading?: boolean; 44 | uploadOverwrite?: boolean; 45 | /** undefined when fetched from server */ 46 | uploadState?: 'uploading' | 'success' | 'failure'; 47 | uploadPercent?: number; // 1-100 48 | } 49 | -------------------------------------------------------------------------------- /src/components/HotKey/constants.ts: -------------------------------------------------------------------------------- 1 | // KeyboardEvent.key 2 | // 'AltGraph' will let e.altKey and e.ctrlKey become true (Not tested) 3 | export const MODIFIER_KEY_EVENT_KEYS = [ 4 | 'Control', 5 | 'Alt', 6 | 'Shift', 7 | 'Meta', 8 | 'AltGraph', 9 | ]; 10 | 11 | // KeyboardEvent.code 12 | // Digits, Letters, Symbols 13 | export const MAIN_KEY_EVENT_CODES = [ 14 | 'Backquote', 15 | 'Digit1', 16 | 'Digit2', 17 | 'Digit3', 18 | 'Digit4', 19 | 'Digit5', 20 | 'Digit6', 21 | 'Digit7', 22 | 'Digit8', 23 | 'Digit9', 24 | 'Digit0', 25 | 'Minus', 26 | 'Equal', 27 | 'KeyQ', 28 | 'KeyW', 29 | 'KeyE', 30 | 'KeyR', 31 | 'KeyT', 32 | 'KeyY', 33 | 'KeyU', 34 | 'KeyI', 35 | 'KeyO', 36 | 'KeyP', 37 | 'BracketLeft', 38 | 'BracketRight', 39 | 'Backslash', 40 | 'KeyA', 41 | 'KeyS', 42 | 'KeyD', 43 | 'KeyF', 44 | 'KeyG', 45 | 'KeyH', 46 | 'KeyJ', 47 | 'KeyK', 48 | 'KeyL', 49 | 'Semicolon', 50 | 'Quote', 51 | 'KeyZ', 52 | 'KeyX', 53 | 'KeyC', 54 | 'KeyV', 55 | 'KeyB', 56 | 'KeyN', 57 | 'KeyM', 58 | 'Comma', 59 | 'Period', 60 | 'Slash', 61 | ]; 62 | export const ARROW_KEY_EVENT_CODES = [ 63 | 'ArrowUp', 64 | 'ArrowDown', 65 | 'ArrowLeft', 66 | 'ArrowRight', 67 | ]; 68 | export const SPACE_KEY_EVENT_CODES = ['Backspace', 'Space', 'Enter']; 69 | -------------------------------------------------------------------------------- /src/components/shared/ListSkeletonItem.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Skeleton } from 'antd'; 3 | import { FC } from '@/interfaces'; 4 | import style from '@/style'; 5 | import { listItemStyle } from '@/utils/style'; 6 | 7 | /** 列表元素骨架的属性接口 */ 8 | interface ListSkeletonItemProps { 9 | className?: string; 10 | } 11 | /** 12 | * 列表元素骨架 13 | */ 14 | export const ListSkeletonItem: FC = ({ className }) => { 15 | return ( 16 |
39 | 44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/components/setting/UserBasicSettings.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React from 'react'; 3 | import { useIntl } from 'react-intl'; 4 | import { Content, ContentItem, ContentTitle } from '@/components'; 5 | import { FC } from '@/interfaces'; 6 | import style from '../../style'; 7 | import { AvatarUpload } from '../shared/AvatarUpload'; 8 | import { UserEditForm } from './UserEditForm'; 9 | import { LocalePicker } from './LocalePicker'; 10 | 11 | /** 用户基础设置的属性接口 */ 12 | interface UserBasicSettingsProps { 13 | className?: string; 14 | } 15 | /** 16 | * 用户基础设置 17 | */ 18 | export const UserBasicSettings: FC = ({ 19 | className, 20 | }) => { 21 | const { formatMessage } = useIntl(); // i18n 22 | 23 | return ( 24 |
32 | 33 | 34 | {formatMessage({ id: 'user.info' })} 35 |
36 | 37 |
38 | 39 | 40 | 41 |
42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/setting/UserSecuritySettings.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React from 'react'; 3 | import { useIntl } from 'react-intl'; 4 | import { 5 | Content, 6 | ContentItem, 7 | ContentTitle, 8 | UserEmailEditForm, 9 | UserPasswordEditForm, 10 | } from '..'; 11 | import style from '@/style'; 12 | import { FC } from '@/interfaces'; 13 | 14 | /** 用户安全设置的属性接口 */ 15 | interface UserSecuritySettingsProps { 16 | className?: string; 17 | } 18 | /** 19 | * 用户安全设置 20 | */ 21 | export const UserSecuritySettings: FC = ({ 22 | className, 23 | }) => { 24 | const { formatMessage } = useIntl(); // i18n 25 | 26 | return ( 27 |
35 | 36 | 37 | {formatMessage({ id: 'user.passwordSetting' })} 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | {formatMessage({ id: 'user.emailSetting' })} 46 | 47 | 48 | 49 | 50 | 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/constants/file.ts: -------------------------------------------------------------------------------- 1 | // 文件类型 2 | export const FILE_TYPE = { 3 | UNKNOWN: 0 as 0, // 未知 4 | FOLDER: 1 as 1, // 文件夹 5 | IMAGE: 2 as 2, // 图片 6 | TEXT: 3 as 3, // 纯文本 7 | }; 8 | export type FileTypes = (typeof PARSE_STATUS)[keyof typeof PARSE_STATUS]; 9 | 10 | // 文件安全检测状态 11 | export const FILE_SAFE_STATUS = { 12 | // 第一步 13 | NEED_MACHINE_CHECK: 0 as 0, // 需要机器检测 14 | QUEUING: 1 as 1, // 机器检测排队中 15 | WAIT_RESULT: 2 as 2, // 机器检测等待结果 16 | // 第二步(根据机器检测结果) 17 | NEED_HUMAN_CHECK: 3 as 3, // 需要人工检查 18 | // 第三步 19 | SAFE: 4 as 4, // 已检测安全 20 | BLOCK: 5 as 5, // 文件被删除屏蔽,需要重新上传 21 | }; 22 | export type FileSafeStatuses = 23 | (typeof FILE_SAFE_STATUS)[keyof typeof FILE_SAFE_STATUS]; 24 | 25 | // 文件处理状态 26 | export const PARSE_STATUS = { 27 | NOT_START: 0 as 0, // 未开始 28 | QUEUING: 1 as 1, // 排队中 29 | PARSING: 2 as 2, // 解析中 30 | PARSE_FAILED: 3 as 3, // 解析失败 31 | PARSE_SUCCEEDED: 4 as 4, // 解析成功 32 | }; 33 | export type ParseStatuses = (typeof PARSE_STATUS)[keyof typeof PARSE_STATUS]; 34 | 35 | // 文件不存在的原因 36 | export const FILE_NOT_EXIST_REASON = { 37 | UNKNOWN: 0 as 0, // 未知 38 | NOT_UPLOAD: 1 as 1, // 还没有上传 39 | FINISH: 2 as 2, // 因为完结被删除 40 | BLOCK: 4 as 4, // 因为屏蔽被删除 41 | }; 42 | export type FileNotExistReasons = 43 | (typeof FILE_NOT_EXIST_REASON)[keyof typeof FILE_NOT_EXIST_REASON]; 44 | 45 | // 图片封面大小 46 | export const IMAGE_COVER = { 47 | WIDTH: 180 as 180, 48 | HEIGHT: 140 as 140, 49 | }; 50 | -------------------------------------------------------------------------------- /src/store/team/sagas.ts: -------------------------------------------------------------------------------- 1 | import { cancelled, put, select, takeLatest } from 'redux-saga/effects'; 2 | import api from '../../apis'; 3 | import { toLowerCamelCase } from '../../utils'; 4 | import { getCancelToken } from '../../utils/api'; 5 | import { clearCurrentTeam, setCurrentTeam, setCurrentTeamSaga } from './slice'; 6 | import { AppState } from '..'; 7 | import { UserTeam } from '../../interfaces'; 8 | 9 | // worker Sage 10 | function* setCurrentTeamWorker(action: ReturnType) { 11 | // 清空当前 team 12 | yield put(clearCurrentTeam()); 13 | const teams = yield select((state: AppState) => state.team.teams); 14 | const team = teams.find((team: UserTeam) => team.id === action.payload.id); 15 | if (team) { 16 | // 已存在与 teams 中,则直接获取 17 | yield put(setCurrentTeam(team)); 18 | } else { 19 | // 从 API 获取当前 team 20 | const [cancelToken, cancel] = getCancelToken(); 21 | try { 22 | const result = yield api.getTeam({ 23 | id: action.payload.id, 24 | configs: { cancelToken }, 25 | }); 26 | yield put(setCurrentTeam(toLowerCamelCase(result.data))); 27 | } catch (error) { 28 | error.default(); 29 | } finally { 30 | if (yield cancelled()) { 31 | cancel(); 32 | } 33 | } 34 | } 35 | } 36 | 37 | // watcher Saga 38 | function* watcher() { 39 | yield takeLatest(setCurrentTeamSaga.type, setCurrentTeamWorker); 40 | } 41 | 42 | // root Saga 43 | export default watcher; 44 | -------------------------------------------------------------------------------- /src/store/helpers.ts: -------------------------------------------------------------------------------- 1 | import { call, cancel, fork, join, takeEvery } from 'redux-saga/effects'; 2 | 3 | interface Tasks { 4 | [propName: string]: any; 5 | } 6 | export function takeLeadingPerKey( 7 | patternOrChannel: any, 8 | worker: any, 9 | keySelector: any, 10 | ...args: any[] 11 | ) { 12 | return fork(function* () { 13 | const tasks: Tasks = {}; 14 | 15 | yield takeEvery(patternOrChannel, function* (action) { 16 | const key = yield call(keySelector, action); 17 | 18 | if (!(tasks[key] && tasks[key].isRunning())) { 19 | tasks[key] = yield fork(worker, ...args, action); 20 | 21 | yield join(tasks[key]); 22 | 23 | if (tasks[key] && !tasks[key].isRunning()) { 24 | delete tasks[key]; 25 | } 26 | } 27 | }); 28 | }); 29 | } 30 | 31 | export function takeLatestPerKey( 32 | patternOrChannel: any, 33 | worker: any, 34 | keySelector: any, 35 | ...args: any[] 36 | ) { 37 | return fork(function* () { 38 | const tasks: Tasks = {}; 39 | 40 | yield takeEvery(patternOrChannel, function* (action) { 41 | const key: string = yield call(keySelector, action); 42 | 43 | if (tasks[key]) { 44 | yield cancel(tasks[key]); 45 | } 46 | 47 | tasks[key] = yield fork(worker, ...args, action); 48 | 49 | yield join(tasks[key]); 50 | 51 | if (tasks[key] && !tasks[key].isRunning()) { 52 | delete tasks[key]; 53 | } 54 | }); 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /src/apis/me.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 角色相关 API 3 | */ 4 | import { AxiosRequestConfig } from 'axios'; 5 | import { PaginationParams, request } from '.'; 6 | import { ApplicationStatuses } from '@/constants'; 7 | import { InvitationStatuses } from '@/constants'; 8 | import { toUnderScoreCase } from '@/utils'; 9 | import { APIApplication } from './application'; 10 | import { APIInvitation } from './invitation'; 11 | 12 | /** 获取用户的邀请的请求数据 */ 13 | interface GetUserInvitationsParams { 14 | status?: InvitationStatuses[]; 15 | } 16 | /** 获取用户的邀请 */ 17 | const getUserInvitations = ({ 18 | params, 19 | configs, 20 | }: { 21 | params?: GetUserInvitationsParams & PaginationParams; 22 | configs?: AxiosRequestConfig; 23 | } = {}) => { 24 | return request({ 25 | method: 'GET', 26 | url: `/v1/user/invitations`, 27 | params: toUnderScoreCase(params), 28 | ...configs, 29 | }); 30 | }; 31 | 32 | /** 获取用户可以处理的加入申请的请求数据 */ 33 | interface GetRelatedApplicationsParams { 34 | status?: ApplicationStatuses[]; 35 | } 36 | /** 获取用户可以处理的加入申请 */ 37 | const getRelatedApplications = ({ 38 | params, 39 | configs, 40 | }: { 41 | params?: GetRelatedApplicationsParams & PaginationParams; 42 | configs?: AxiosRequestConfig; 43 | } = {}) => { 44 | return request({ 45 | method: 'GET', 46 | url: `/v1/user/related-applications`, 47 | params: toUnderScoreCase(params), 48 | ...configs, 49 | }); 50 | }; 51 | 52 | export default { 53 | getUserInvitations, 54 | getRelatedApplications, 55 | }; 56 | -------------------------------------------------------------------------------- /src/store/site/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { OSName, Platform } from '@/interfaces'; 3 | import { RuntimeConfig } from '@/configs'; 4 | 5 | export interface SiteState { 6 | osName: OSName; 7 | platform: Platform; 8 | newInvitationsCount: number; 9 | relatedApplicationsCount: number; 10 | runtimeConfig: RuntimeConfig; 11 | } 12 | 13 | const initialState: SiteState = { 14 | osName: null!, 15 | platform: null!, 16 | relatedApplicationsCount: 0, 17 | newInvitationsCount: 0, 18 | runtimeConfig: null!, 19 | }; 20 | const slice = createSlice({ 21 | name: 'site', 22 | initialState, 23 | reducers: { 24 | setPlatform(state, action: PayloadAction) { 25 | state.platform = action.payload; 26 | }, 27 | setOSName(state, action: PayloadAction) { 28 | state.osName = action.payload; 29 | }, 30 | setRelatedApplicationsCount(state, action: PayloadAction) { 31 | state.relatedApplicationsCount = action.payload; 32 | }, 33 | setNewInvitationsCount(state, action: PayloadAction) { 34 | state.newInvitationsCount = action.payload; 35 | }, 36 | setRuntimeConfig(state, action: PayloadAction) { 37 | state.runtimeConfig = action.payload; 38 | }, 39 | }, 40 | }); 41 | 42 | export const { 43 | setPlatform, 44 | setOSName, 45 | setRelatedApplicationsCount, 46 | setNewInvitationsCount, 47 | setRuntimeConfig, 48 | } = slice.actions; 49 | export default slice.reducer; 50 | -------------------------------------------------------------------------------- /src/apis/tip.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import { request } from '.'; 3 | import { APIUser } from './user'; 4 | 5 | export interface APITip { 6 | sourceID: string; 7 | id: string; 8 | content: string; 9 | user: APIUser | null; 10 | createTime: string; 11 | editTime: string; 12 | } 13 | 14 | /** 新增原文的请求数据 */ 15 | interface CreateTipData { 16 | content: string; 17 | } 18 | /** 新增原文 */ 19 | const createTip = ({ 20 | sourceID, 21 | data, 22 | configs, 23 | }: { 24 | sourceID: string; 25 | data: CreateTipData; 26 | configs?: AxiosRequestConfig; 27 | }) => { 28 | return request({ 29 | method: 'POST', 30 | url: `/v1/sources/${sourceID}/tips`, 31 | data: data, 32 | ...configs, 33 | }); 34 | }; 35 | 36 | /** 修改原文的请求数据 */ 37 | interface EditTipData { 38 | content: string; 39 | } 40 | /** 修改原文 */ 41 | const editTip = ({ 42 | tipID, 43 | data, 44 | configs, 45 | }: { 46 | tipID: string; 47 | data: EditTipData; 48 | configs?: AxiosRequestConfig; 49 | }) => { 50 | return request({ 51 | method: 'PUT', 52 | url: `/v1/tips/${tipID}`, 53 | data: data, 54 | ...configs, 55 | }); 56 | }; 57 | 58 | /** 删除原文 */ 59 | const deleteTip = ({ 60 | tipID, 61 | configs, 62 | }: { 63 | tipID: string; 64 | configs?: AxiosRequestConfig; 65 | }) => { 66 | return request({ 67 | method: 'DELETE', 68 | url: `/v1/tips/${tipID}`, 69 | ...configs, 70 | }); 71 | }; 72 | 73 | export default { 74 | createTip, 75 | deleteTip, 76 | editTip, 77 | }; 78 | -------------------------------------------------------------------------------- /src/pages/NewTeam.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React from 'react'; 3 | import { useIntl } from 'react-intl'; 4 | import { 5 | Content, 6 | ContentItem, 7 | ContentTitle, 8 | DashboardBox, 9 | GroupJoinForm, 10 | TeamCreateForm, 11 | } from '../components'; 12 | import { FC } from '../interfaces'; 13 | import style from '../style'; 14 | import { useTitle } from '../hooks'; 15 | 16 | /** 加入/创建团队的属性接口 */ 17 | interface NewTeamProps {} 18 | /** 19 | * 加入/创建团队 20 | */ 21 | const NewTeam: FC = () => { 22 | const { formatMessage } = useIntl(); // i18n 23 | useTitle(); // 设置标题 24 | 25 | return ( 26 | 35 | 36 | {formatMessage({ id: 'site.createTeam' })} 37 | 38 | 39 | 40 | 41 | 48 | {formatMessage({ id: 'site.joinTeam' })} 49 | 50 | 51 | 52 | 53 | 54 | } 55 | /> 56 | ); 57 | }; 58 | export default NewTeam; 59 | -------------------------------------------------------------------------------- /src/components/shared/DashboardBox.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React from 'react'; 3 | import { FC } from '@/interfaces'; 4 | import style from '../../style'; 5 | 6 | /** 带有导航栏的布局的属性接口 */ 7 | interface DashboardBoxProps { 8 | /* 导航,横着在顶部(一般用于 PC 版,手机版自行处理成单独的页面) */ 9 | nav?: React.ReactNode | React.ReactNode[]; 10 | content: React.ReactNode | React.ReactNode[]; 11 | className?: string; 12 | } 13 | /** 14 | * 带有导航栏的布局 15 | */ 16 | export const DashboardBox: FC = ({ 17 | nav, 18 | content, 19 | className, 20 | }) => { 21 | return ( 22 |
53 | {nav &&
{nav}
} 54 |
{content}
55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/store/project/sagas.ts: -------------------------------------------------------------------------------- 1 | import { cancelled, put, select, takeLatest } from 'redux-saga/effects'; 2 | import { api, BasicSuccessResult } from '@/apis'; 3 | import { toLowerCamelCase } from '@/utils'; 4 | import { getCancelToken } from '@/utils/api'; 5 | import { 6 | clearCurrentProject, 7 | setCurrentProject, 8 | setCurrentProjectSaga, 9 | } from './slice'; 10 | import { AppState } from '@/store'; 11 | import { Project } from '@/interfaces'; 12 | 13 | // worker Sage 14 | function* setCurrentProjectWorker( 15 | action: ReturnType, 16 | ) { 17 | // 清空当前 project 18 | yield put(clearCurrentProject()); 19 | const projects: Project[] = yield select( 20 | (state: AppState) => state.project.projects, 21 | ); 22 | const project = projects.find( 23 | (project: Project) => project.id === action.payload.id, 24 | ); 25 | if (project) { 26 | // 已存在与 projects 中,则直接获取 27 | yield put(setCurrentProject(project)); 28 | } else { 29 | // 从 API 获取当前 project 30 | const [cancelToken, cancel] = getCancelToken(); 31 | try { 32 | const result: BasicSuccessResult = yield api.project.getProject({ 33 | id: action.payload.id, 34 | configs: { cancelToken }, 35 | }); 36 | yield put(setCurrentProject(toLowerCamelCase(result.data))); 37 | } catch (error: any) { 38 | error.default(); 39 | } finally { 40 | if (yield cancelled()) { 41 | cancel(); 42 | } 43 | } 44 | } 45 | } 46 | 47 | // watcher Saga 48 | function* watcher() { 49 | yield takeLatest(setCurrentProjectSaga.type, setCurrentProjectWorker); 50 | } 51 | 52 | // root Saga 53 | export default watcher; 54 | -------------------------------------------------------------------------------- /src/store/user/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | export interface Locale { 3 | id: string; 4 | intro: string; 5 | name: string; 6 | } 7 | export interface UserState { 8 | /** id */ 9 | id: string; 10 | /** 邮箱 */ 11 | email: string; 12 | /** 用户名 */ 13 | name: string; 14 | /** 是否设置头像 */ 15 | hasAvatar: boolean; 16 | /** 头像地址 */ 17 | avatar: string; 18 | /** 签名 */ 19 | signature: string; 20 | /** 区域 */ 21 | locale: Locale; 22 | /** 用户 Token */ 23 | token: string; 24 | admin: boolean; 25 | } 26 | 27 | export type SetUserInfoAction = PayloadAction< 28 | Partial> 29 | >; 30 | export const initialState: UserState = { 31 | id: '', 32 | email: '', 33 | name: '', 34 | hasAvatar: false, 35 | avatar: '', 36 | signature: '', 37 | locale: { 38 | id: '', 39 | intro: '', 40 | name: '', 41 | }, 42 | token: '', 43 | admin: false, 44 | }; 45 | const slice = createSlice({ 46 | name: 'user', 47 | initialState, 48 | reducers: { 49 | setUserToken( 50 | state, 51 | action: PayloadAction< 52 | Pick & { 53 | rememberMe?: boolean; 54 | refresh?: boolean; 55 | } 56 | >, 57 | ) { 58 | state.token = action.payload.token; 59 | // 之后会触发 saga 获取用户详情 60 | }, 61 | setUserInfo(state, action: SetUserInfoAction) { 62 | let key: keyof typeof action.payload; 63 | for (key in action.payload) { 64 | state[key] = action.payload[key] as never; 65 | } 66 | }, 67 | }, 68 | }); 69 | 70 | export const { setUserToken, setUserInfo } = slice.actions; 71 | export default slice.reducer; 72 | -------------------------------------------------------------------------------- /src/components/unused/FileCover.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import classNames from 'classnames'; 3 | import { FC, File } from '../../interfaces'; 4 | import style from '../../style'; 5 | 6 | /** 文件封面的属性接口 */ 7 | interface FileCoverProps { 8 | file: File; 9 | onClick: () => void; 10 | coverWidth: number; 11 | coverHeight: number; 12 | className?: string; 13 | } 14 | /** 15 | * 文件封面 16 | */ 17 | export const FileCover: FC = ({ 18 | file, 19 | onClick, 20 | coverWidth, 21 | coverHeight, 22 | className, 23 | }) => { 24 | return ( 25 |
45 | e.preventDefault()} // 禁止 Firefox 拖拽图片(Firefox 仅 drageable={false} 无效) 49 | onContextMenu={(e) => e.preventDefault()} // 禁止鼠标右键菜单 和 Android 上 Chrome/Firefox,重按/长按图片弹出菜单 50 | src={file.coverUrl} 51 | onClick={onClick} 52 | alt={file.name} 53 | /> 54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/shared/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip as AntdTooltip } from 'antd'; 2 | import { 3 | TooltipPropsWithTitle as AntdTooltipPropsWithTitle, 4 | TooltipPropsWithOverlay as AntdTooltipPropsWithOverlay, 5 | } from 'antd/lib/tooltip'; 6 | import { useSelector } from 'react-redux'; 7 | import { AppState } from '@/store'; 8 | import { FC } from '@/interfaces'; 9 | import { css, Global } from '@emotion/core'; 10 | import style from '@/style'; 11 | 12 | /** 13 | * 手机版自动隐藏的 Tooltip 14 | */ 15 | interface TooltipPropsWithTitle extends AntdTooltipPropsWithTitle { 16 | disabled?: boolean; 17 | } 18 | interface TooltipPropsWithOverlay extends AntdTooltipPropsWithOverlay { 19 | disabled?: boolean; 20 | } 21 | export type TooltipProps = TooltipPropsWithTitle | TooltipPropsWithOverlay; 22 | export const Tooltip: FC = ( 23 | { disabled, children, ...args } = {} as TooltipProps, 24 | ) => { 25 | const platform = useSelector((state: AppState) => state.site.platform); 26 | const isMobile = platform === 'mobile'; 27 | 28 | if (isMobile || disabled) { 29 | return <>{children}; 30 | } else { 31 | return ( 32 | <> 33 | 47 | 48 | {children} 49 | 50 | 51 | ); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /src/store/projectSet/sagas.ts: -------------------------------------------------------------------------------- 1 | import { cancelled, put, select, takeLatest } from 'redux-saga/effects'; 2 | import { api, BasicSuccessResult } from '@/apis'; 3 | import { toLowerCamelCase } from '@/utils'; 4 | import { getCancelToken } from '@/utils/api'; 5 | import { 6 | clearCurrentProjectSet, 7 | setCurrentProjectSet, 8 | setCurrentProjectSetSaga, 9 | } from './slice'; 10 | import { AppState } from '..'; 11 | import { UserProjectSet } from '@/interfaces'; 12 | 13 | // worker Sage 14 | function* setCurrentProjectSetWorker( 15 | action: ReturnType, 16 | ) { 17 | // 清空当前 projectSet 18 | yield put(clearCurrentProjectSet()); 19 | const projectSets: UserProjectSet[] = yield select( 20 | (state: AppState) => state.projectSet.projectSets, 21 | ); 22 | const projectSet = projectSets.find( 23 | (projectSet: UserProjectSet) => projectSet.id === action.payload.id, 24 | ); 25 | if (projectSet) { 26 | // 已存在与 projectSets 中,则直接获取 27 | yield put(setCurrentProjectSet(projectSet)); 28 | } else { 29 | // 从 API 获取当前 projectSet 30 | const [cancelToken, cancel] = getCancelToken(); 31 | try { 32 | const result: BasicSuccessResult = 33 | yield api.projectSet.getProjectSet({ 34 | id: action.payload.id, 35 | configs: { cancelToken }, 36 | }); 37 | yield put(setCurrentProjectSet(toLowerCamelCase(result.data))); 38 | } catch (error: any) { 39 | error.default(); 40 | } finally { 41 | if (yield cancelled()) { 42 | cancel(); 43 | } 44 | } 45 | } 46 | } 47 | 48 | // watcher Saga 49 | function* watcher() { 50 | yield takeLatest(setCurrentProjectSetSaga.type, setCurrentProjectSetWorker); 51 | } 52 | 53 | // root Saga 54 | export default watcher; 55 | -------------------------------------------------------------------------------- /src/configs.tsx: -------------------------------------------------------------------------------- 1 | import { lazyThenable } from '@jokester/ts-commonutil/lib/concurrency/lazy-thenable'; 2 | 3 | export interface RuntimeConfig { 4 | // base URL for API requests 5 | baseURL: string; 6 | 7 | // TODO: more fields can be added here 8 | } 9 | 10 | /** 11 | * overridable runtime config 12 | * priority DESC: 13 | * 1. /moeflow-runtime-config.json 14 | * 2. value from vite config 15 | * 3. fallback 16 | */ 17 | export const runtimeConfig = lazyThenable(async () => { 18 | const overriden: RuntimeConfig = await fetch('/moeflow-runtime-config.json') 19 | .then((res) => res.json()) 20 | .catch(() => null); 21 | const merged: RuntimeConfig = { 22 | ...{ 23 | // defaults 24 | baseURL: process.env.REACT_APP_BASE_URL || '/api/', 25 | }, 26 | ...overriden, 27 | }; 28 | 29 | // console.debug('runtimeConfig', merged); 30 | return merged; 31 | }); 32 | 33 | /** consts */ 34 | export const configs = { 35 | default: { 36 | team: { 37 | systemRole: 'member', // 团队默认角色,后端部署后不会变动 38 | allowApplyType: 2, 39 | applicationCheckType: 2, 40 | }, 41 | project: { 42 | systemRole: 'supporter', // 项目默认角色,后端部署后不会变动 43 | allowApplyType: 3, 44 | applicationCheckType: 1, 45 | sourceLanugageCode: 'ja', // 默认源语言,后端部署后不会变动 46 | targetLanguageCodes: ['zh-TW'], // 默认目标语言,后端部署后不会变动 47 | }, 48 | }, 49 | } as const; 50 | 51 | if (process.env.NODE_ENV === 'production') { 52 | // 生产环境配置 53 | } else if (process.env.NODE_ENV === 'test') { 54 | // 测试环境配置 55 | } else if (process.env.NODE_ENV === 'development') { 56 | // dev 57 | console.debug({ 58 | configs, 59 | env: process.env.NODE_ENV, 60 | }); 61 | } else { 62 | throw new Error(`unexpected environment: ${process.env.NODE_ENV}`); 63 | } 64 | -------------------------------------------------------------------------------- /scripts/generate-locale-json.ts: -------------------------------------------------------------------------------- 1 | import fsp from 'node:fs/promises'; 2 | import path from 'path'; 3 | import yaml from 'js-yaml'; 4 | 5 | const assetDir = path.join(__dirname, '../src/locales'); 6 | const messageYaml = path.join(assetDir, 'messages.yaml'); 7 | 8 | /** 9 | * yield [path, message] pairs 10 | */ 11 | function* extractPathedMessages(obj: object, locale: string, pathPrefix: readonly string[] = []): Generator<[string, string]> { 12 | for (const [key, value] of Object.entries(obj)) { 13 | if (typeof value === 'object' && value) { 14 | yield* extractPathedMessages(value, locale, [...pathPrefix, key]); 15 | } else if (typeof value === 'string') { 16 | if (key === locale) yield [pathPrefix.join('.'), value]; 17 | } else { 18 | throw new Error(`unexpected value type at ${[...pathPrefix, key].join('.')}: ${typeof value}`); 19 | } 20 | } 21 | } 22 | 23 | const lang2Basename = Object.entries({ 24 | zhCn: 'zh-cn.json', 25 | en: 'en.json', 26 | }) 27 | 28 | setTimeout(async function main() { 29 | /** 30 | * key => locale => message 31 | * */ 32 | const messages = yaml.load( 33 | await fsp.readFile(messageYaml, { encoding: 'utf-8' }), 34 | ) as Record>; 35 | 36 | const path2count: Record = {}; 37 | for (const [locale, basename] of lang2Basename ) { 38 | /** 39 | * key => message 40 | */ 41 | const value: Record = {}; 42 | for(const [path, msg] of extractPathedMessages(messages, locale)) { 43 | path2count[path] = (path2count[path] ?? 0) + 1; 44 | value[path] = msg; 45 | } 46 | const dest = path.join(assetDir, basename); 47 | await fsp.writeFile(dest, JSON.stringify(value, null, 2)); 48 | console.info(`written to ${dest}`); 49 | } 50 | }); 51 | -------------------------------------------------------------------------------- /src/components/project-file/MovableAreaImageBackground.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React, { useRef } from 'react'; 3 | import { FC } from '@/interfaces'; 4 | import classNames from 'classnames'; 5 | 6 | /** 可移动区域图片背景的属性接口 */ 7 | interface MovableAreaImageBackgroundProps { 8 | src: string; 9 | onLoad?: (size: { width: number; height: number }) => any; 10 | className?: string; 11 | } 12 | /** 13 | * 可移动区域图片背景 14 | * @param onLoad 当图片onLoad时调用此函数,传递当时的size 15 | */ 16 | export const MovableAreaImageBackground: FC< 17 | MovableAreaImageBackgroundProps 18 | > = ({ src, onLoad, className: _className }) => { 19 | /** 20 | * required to override tailwindcss's @base 21 | * see https://tailwindcss.com/docs/preflight#images-are-block-level 22 | */ 23 | const className = classNames(_className, 'max-w-none'); 24 | const domRef = useRef(null); 25 | return ( 26 | e.preventDefault()} // 禁止 Firefox 拖拽图片(Firefox 仅 drageable={false} 无效) 39 | onContextMenu={(e) => e.preventDefault()} // 禁止鼠标右键菜单 和 Android 上 Chrome/Firefox,重按/长按图片弹出菜单 40 | ref={domRef} 41 | onLoad={(e) => { 42 | if (onLoad) { 43 | const size = { 44 | width: (domRef.current as HTMLImageElement).offsetWidth, 45 | height: (domRef.current as HTMLImageElement).offsetHeight, 46 | }; 47 | onLoad(size); 48 | } 49 | }} 50 | src={src} 51 | alt="" 52 | /> 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /.eslintrc.base.js: -------------------------------------------------------------------------------- 1 | const tsRules = { 2 | '@typescript-eslint/ban-ts-comment': 1, 3 | '@typescript-eslint/camelcase': 0, 4 | '@typescript-eslint/explicit-member-accessibility': 0, 5 | '@typescript-eslint/no-empty-function': 0, 6 | '@typescript-eslint/no-empty-interface': 0, 7 | '@typescript-eslint/no-explicit-any': 1, 8 | '@typescript-eslint/no-namespace': 0, 9 | '@typescript-eslint/no-non-null-assertion': 1, 10 | '@typescript-eslint/no-parameter-properties': 0, 11 | '@typescript-eslint/no-unused-vars': 1, 12 | "@typescript-eslint/ban-types": 1, 13 | '@typescript-eslint/no-var-requires': 1, 14 | }; 15 | 16 | const nodeRules = { 17 | 'node/no-extraneous-import': 0, 18 | 'node/no-extraneous-require': 0, 19 | 'node/no-unpublished-require': 0, 20 | }; 21 | 22 | const jsRules = { 23 | 'no-constant-condition': 'warn', 24 | 'no-control-regex': 'warn', 25 | 'no-empty': 0, 26 | 'no-inner-declarations': 'warn', 27 | 'no-irregular-whitespace': 'warn', 28 | 'no-unused-vars': 0, 29 | 'no-useless-escape': 'warn', 30 | }; 31 | 32 | const mergedRules = { 33 | ...jsRules, 34 | ...nodeRules, 35 | ...tsRules, 36 | }; 37 | module.exports = { 38 | parserOptions: { 39 | ecmaVersion: 'latest', 40 | }, 41 | 42 | extends: [], 43 | plugins: [], 44 | overrides: [ 45 | { 46 | files: ['**/*.spec.ts', '**/*.test.ts', '**/*.spec.tsx', '**/*.test.tsx'], 47 | env: { 48 | jest: true, 49 | }, 50 | }, 51 | { 52 | files: ['**/*.ts', '**/*.tsx'], 53 | extends: ['plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/recommended'], 54 | rules: mergedRules, 55 | }, 56 | ], 57 | rules: mergedRules, 58 | settings: { 59 | react: { 60 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use 61 | }, 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/apis/target.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 目标相关 API 3 | */ 4 | import { request } from '.'; 5 | import { AxiosRequestConfig } from 'axios'; 6 | import { toUnderScoreCase } from '@/utils'; 7 | import { PaginationParams } from '.'; 8 | import { APILanguage } from './language'; 9 | 10 | export interface APITarget { 11 | id: string; 12 | language: APILanguage; 13 | translatedSourceCount: number; 14 | checkedSourceCount: number; 15 | createTime: string; 16 | editTime: string; 17 | intro: string; 18 | } 19 | 20 | /** 获取项目的翻译目标列表的请求数据 */ 21 | interface GetProjectTargetsParams { 22 | word?: string; 23 | } 24 | /** 获取项目的翻译目标列表 */ 25 | const getProjectTargets = ({ 26 | projectID, 27 | params, 28 | configs, 29 | }: { 30 | projectID: string; 31 | params?: GetProjectTargetsParams & PaginationParams; 32 | configs?: AxiosRequestConfig; 33 | }) => { 34 | return request({ 35 | method: 'GET', 36 | url: `/v1/projects/${projectID}/targets`, 37 | params: { ...toUnderScoreCase(params) }, 38 | ...configs, 39 | }); 40 | }; 41 | 42 | /** 新建翻译目标的请求数据 */ 43 | interface CreateTargetData { 44 | language: string; 45 | } 46 | /** 新建翻译目标 */ 47 | const createTarget = ({ 48 | projectID, 49 | data, 50 | configs, 51 | }: { 52 | projectID: string; 53 | data: CreateTargetData; 54 | configs?: AxiosRequestConfig; 55 | }) => { 56 | return request({ 57 | method: 'POST', 58 | url: `/v1/projects/${projectID}/targets`, 59 | data: toUnderScoreCase(data), 60 | ...configs, 61 | }); 62 | }; 63 | 64 | /** 完结翻译目标 */ 65 | const deleteTarget = ({ 66 | id, 67 | configs, 68 | }: { 69 | id: string; 70 | configs?: AxiosRequestConfig; 71 | }) => { 72 | return request({ 73 | method: 'DELETE', 74 | url: `/v1/targets/${id}`, 75 | ...configs, 76 | }); 77 | }; 78 | 79 | export default { 80 | getProjectTargets, 81 | createTarget, 82 | deleteTarget, 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/shared/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Avatar as AntdAvatar, Badge } from 'antd'; 3 | import { AvatarProps as AntdAvatarProps } from 'antd/lib/avatar'; 4 | import React from 'react'; 5 | import { useIntl } from 'react-intl'; 6 | import defaultTeamAvatar from '@/images/common/default-team-avatar.jpg'; 7 | import defaultUserAvatar from '@/images/common/default-user-avatar.jpg'; 8 | 9 | /** 头像的属性接口 */ 10 | interface AvatarProps { 11 | shape?: 'circle' | 'square'; 12 | url?: string | null; 13 | type?: 'team' | 'user'; 14 | dot?: boolean; 15 | className?: string; 16 | } 17 | /** 18 | * 头像 19 | */ 20 | const AvatarWithoutRef: React.ForwardRefRenderFunction< 21 | any, 22 | AvatarProps & AntdAvatarProps 23 | > = ({ dot = false, shape, url, type, className, ...avatarProps }, ref) => { 24 | const { formatMessage } = useIntl(); // i18n 25 | 26 | // 默认头像 27 | let avatarUrl; 28 | switch (type) { 29 | case 'user': 30 | avatarUrl = defaultUserAvatar; 31 | break; 32 | case 'team': 33 | avatarUrl = defaultTeamAvatar; 34 | break; 35 | } 36 | if (url) { 37 | avatarUrl = url; 38 | } 39 | 40 | // 默认形状 41 | let avatarShape: 'circle' | 'square' | undefined; 42 | if (shape) { 43 | avatarShape = shape; 44 | } else { 45 | if (type === 'user') { 46 | avatarShape = 'circle'; 47 | } else { 48 | avatarShape = 'square'; 49 | } 50 | } 51 | 52 | return ( 53 | 54 | 65 | 66 | ); 67 | }; 68 | export const Avatar = React.forwardRef(AvatarWithoutRef); 69 | -------------------------------------------------------------------------------- /src/components/project-file/markers/overview/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import classNames from 'classnames'; 3 | import React from 'react'; 4 | import { FC, Source as ISource } from '@/interfaces'; 5 | import style from '@/style'; 6 | import { Source } from './Source'; 7 | 8 | /** 全能模式的属性接口 */ 9 | interface ImageSourceViewerGodProps { 10 | sources: ISource[]; 11 | targetID: string; 12 | className?: string; 13 | } 14 | /** 15 | * Overview aka 全能模式 16 | */ 17 | export const ImageSourceViewerGod: FC = ({ 18 | sources, 19 | targetID, 20 | className, 21 | }) => { 22 | return ( 23 |
48 | {sources.map((source, index) => { 49 | return ( 50 | 60 | ); 61 | })} 62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/shared-form/GroupJoinForm.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Button, Form as AntdForm, Input } from 'antd'; 3 | import classNames from 'classnames'; 4 | import { useIntl } from 'react-intl'; 5 | import { useHistory } from 'react-router-dom'; 6 | import { GroupTypes } from '@/apis/type'; 7 | import { FC } from '@/interfaces'; 8 | import { ID_REGEX } from '@/utils/regex'; 9 | import { Form } from './Form'; 10 | import { FormItem } from './FormItem'; 11 | 12 | /** 加入团体表单的属性接口 */ 13 | interface GroupJoinFormProps { 14 | groupType: GroupTypes; 15 | className?: string; 16 | } 17 | /** 18 | * 加入团体表单 19 | */ 20 | export const GroupJoinForm: FC = ({ 21 | groupType, 22 | className, 23 | }) => { 24 | const { formatMessage } = useIntl(); // i18n 25 | const [form] = AntdForm.useForm(); 26 | const history = useHistory(); 27 | 28 | const handleFinish = (values: any) => { 29 | history.push(`/dashboard/join/${groupType}/${values.groupID}`); 30 | }; 31 | 32 | return ( 33 |
34 |
40 | 51 | 52 | 53 | 54 | 57 | 58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/store/user/sagas.ts: -------------------------------------------------------------------------------- 1 | import { put, takeEvery } from 'redux-saga/effects'; 2 | import { initialState, setUserInfo, setUserToken } from './slice'; 3 | import { toLowerCamelCase } from '../../utils'; 4 | import { api, BasicSuccessResult } from '../../apis'; 5 | import { setToken, removeToken } from '../../utils/cookie'; 6 | import { GetUserInfoResponse } from '../../apis/auth'; 7 | import type { Axios } from 'axios'; 8 | 9 | // worker Sage 10 | function* getUserInfoAsync(action: ReturnType) { 11 | const token = action.payload.token; 12 | const instance: Axios = yield api.getAxiosInstance(); 13 | if (token === '') { 14 | if (import.meta.env.DEV) { 15 | // do nothing in dev: vite hot reloading may create APIClient multiple times, 16 | // causing 401 and an empty token being set 17 | console.debug('[user/sagas] Ignored empty token in DEV due to HMR'); 18 | return; 19 | } 20 | // 清除 Axios Authorization 头 21 | delete instance.defaults.headers.common['Authorization']; 22 | // 清除 Cookie token 23 | removeToken(); 24 | // 清除 Store 用户信息 25 | yield put(setUserInfo(initialState)); 26 | } else { 27 | // 设置 Axios Authorization 头 28 | instance.defaults.headers.common['Authorization'] = `Bearer ${token}`; 29 | // 设置 Cookie token 30 | if (!action.payload.refresh) { 31 | setToken(token, action.payload.rememberMe); 32 | } 33 | // 获取并记录用户信息到 Store 34 | try { 35 | const result: BasicSuccessResult = 36 | yield api.auth.getUserInfo({ 37 | data: { token }, 38 | }); 39 | yield put(setUserInfo(toLowerCamelCase(result.data))); 40 | } catch (error: any) { 41 | error.default(); 42 | } 43 | } 44 | } 45 | 46 | // watcher Saga 47 | function* watcher() { 48 | yield takeEvery(setUserToken.type, getUserInfoAsync); 49 | } 50 | 51 | // root Saga 52 | export default watcher; 53 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import { combineReducers } from 'redux'; 3 | import createSagaMiddleware from 'redux-saga'; 4 | import { all } from 'redux-saga/effects'; 5 | import siteReducer from './site/slice'; 6 | import teamReducer from './team/slice'; 7 | import teamSaga from './team/sagas'; 8 | import projectSetReducer from './projectSet/slice'; 9 | import projectSetSaga from './projectSet/sagas'; 10 | import projectReducer from './project/slice'; 11 | import fileReducer from './file/slice'; 12 | import projectSaga from './project/sagas'; 13 | import userReducer from './user/slice'; 14 | import userSaga from './user/sagas'; 15 | import sourceReducer from './source/slice'; 16 | import sourceSaga from './source/sagas'; 17 | import hotKeyReducer from './hotKey/slice'; 18 | import imageTranslatorReducer from './imageTranslator/slice'; 19 | import translationReducer from './translation/slice'; 20 | 21 | // 组合各个 Reducers 22 | const rootReducer = combineReducers({ 23 | site: siteReducer, 24 | user: userReducer, 25 | team: teamReducer, 26 | projectSet: projectSetReducer, 27 | project: projectReducer, 28 | file: fileReducer, 29 | source: sourceReducer, 30 | hotKey: hotKeyReducer, 31 | imageTranslator: imageTranslatorReducer, 32 | translation: translationReducer, 33 | }); 34 | 35 | function* rootSaga() { 36 | yield all([ 37 | userSaga(), 38 | teamSaga(), 39 | projectSetSaga(), 40 | projectSaga(), 41 | sourceSaga(), 42 | ]); 43 | } 44 | export type AppState = ReturnType; 45 | 46 | function createStore() { 47 | // 创建 Sage 中间件 48 | const sagaMiddleware = createSagaMiddleware(); 49 | // 创建 Store 50 | const store = configureStore({ 51 | reducer: rootReducer, 52 | middleware: [sagaMiddleware], 53 | }); 54 | // 执行 Sage 中间件 55 | sagaMiddleware.run(rootSaga); 56 | return store; 57 | } 58 | 59 | const store = createStore(); 60 | export default store; 61 | -------------------------------------------------------------------------------- /src/apis/member.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import { request, PaginationParams } from '.'; 3 | import { GroupTypes } from './type'; 4 | import { toPlural, toUnderScoreCase } from '../utils'; 5 | import { Role } from '../interfaces'; 6 | 7 | /** 获取团队用户列表的请求数据 */ 8 | interface GetMembersParams { 9 | word: string; 10 | } 11 | /** 获取团队用户列表 */ 12 | const getMembers = ({ 13 | groupType, 14 | groupID, 15 | params, 16 | configs, 17 | }: { 18 | groupType: GroupTypes; 19 | groupID: string; 20 | params: GetMembersParams & PaginationParams; 21 | configs?: AxiosRequestConfig; 22 | }) => { 23 | return request({ 24 | method: 'GET', 25 | url: `/v1/${toPlural(groupType)}/${groupID}/users`, 26 | params: toUnderScoreCase(params), 27 | ...configs, 28 | }); 29 | }; 30 | 31 | /** 删除团队成员 */ 32 | const deleteMember = ({ 33 | groupType, 34 | groupID, 35 | userID, 36 | configs, 37 | }: { 38 | groupType: GroupTypes; 39 | groupID: string; 40 | userID: string; 41 | configs?: AxiosRequestConfig; 42 | }) => { 43 | return request({ 44 | method: 'DELETE', 45 | url: `/v1/${toPlural(groupType)}/${groupID}/users/${userID}`, 46 | ...configs, 47 | }); 48 | }; 49 | 50 | /** 修改团队成员的请求数据 */ 51 | interface EditMemberData { 52 | roleID: string; 53 | } 54 | interface EditMemberReturn { 55 | message: string; 56 | role: Role; 57 | } 58 | /** 修改团队成员 */ 59 | const editMember = ({ 60 | groupType, 61 | groupID, 62 | userID, 63 | data, 64 | configs, 65 | }: { 66 | groupType: GroupTypes; 67 | groupID: string; 68 | userID: string; 69 | data: EditMemberData; 70 | configs?: AxiosRequestConfig; 71 | }) => { 72 | return request({ 73 | method: 'PUT', 74 | url: `/v1/${toPlural(groupType)}/${groupID}/users/${userID}`, 75 | data: { 76 | role: data.roleID, 77 | }, 78 | ...configs, 79 | }); 80 | }; 81 | 82 | export default { 83 | getMembers, 84 | deleteMember, 85 | editMember, 86 | }; 87 | -------------------------------------------------------------------------------- /src/components/project-file/markers/TranslationUser.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import classNames from 'classnames'; 3 | import React from 'react'; 4 | import { Avatar, Icon, Tooltip } from '@/components'; 5 | import { FC } from '@/interfaces'; 6 | import style from '@/style'; 7 | 8 | /** 翻译用户显示的属性接口 */ 9 | interface TranslationUserProps { 10 | iconType: 'translation' | 'proofread'; 11 | iconTooltip?: string; 12 | avatar?: string; 13 | name?: string; 14 | className?: string; 15 | } 16 | /** 17 | * 翻译用户显示 18 | */ 19 | export const TranslationUser: FC = ({ 20 | iconType, 21 | iconTooltip, 22 | avatar, 23 | name, 24 | className, 25 | }) => { 26 | const icon = iconType === 'translation' ? 'pencil-alt' : 'pen-nib'; 27 | 28 | return ( 29 |
53 | 54 | 55 | 56 | {avatar && ( 57 | 63 | )} 64 | {name &&
{name}
} 65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/components/shared-form/RoleSelect.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Select } from 'antd'; 3 | import { SelectValue } from 'antd/lib/select'; 4 | import classNames from 'classnames'; 5 | import React from 'react'; 6 | import { TEAM_PERMISSION } from '@/constants'; 7 | import { FC, Project, Role, UserTeam } from '@/interfaces'; 8 | import { User } from '@/interfaces/user'; 9 | import { can } from '@/utils/user'; 10 | 11 | const { Option } = Select; 12 | 13 | /** 角色切换器的属性接口 */ 14 | interface RoleSelectProps { 15 | roles?: Role[]; 16 | user: User & { role: Role }; 17 | group: UserTeam | Project; 18 | onChange?: (user: User, roleID: string) => void; 19 | className?: string; 20 | } 21 | /** 22 | * 角色切换器 23 | */ 24 | export const RoleSelect: FC = ({ 25 | roles, 26 | user, 27 | group, 28 | onChange, 29 | className, 30 | }) => { 31 | return ( 32 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /.github/workflows/deploy-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "v**" 8 | branches: 9 | - main 10 | 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | 15 | jobs: 16 | build-and-push-image: 17 | runs-on: ubuntu-latest 18 | 19 | permissions: 20 | contents: read 21 | packages: write 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v3 26 | 27 | - name: Setup node and deps 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: "20" 31 | cache: npm 32 | cache-dependency-path: package-lock.json 33 | 34 | - run: npm i 35 | 36 | - name: Build app 37 | run: npm run build 38 | 39 | - name: gzip static files # for nginx 40 | run: find ./build -name '*.js' -or -name '*.css' -or -name '*.html' -or -name '*.json' | xargs -n 1 -P 8 gzip -9 --keep && find ./build 41 | 42 | - name: Log in to the Container registry 43 | uses: docker/login-action@v2.0.0 44 | with: 45 | registry: ${{ env.REGISTRY }} 46 | username: ${{ github.actor }} 47 | password: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | - name: Extract metadata (tags, labels) for Docker 50 | id: meta 51 | uses: docker/metadata-action@v4.0.1 52 | with: 53 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 54 | tags: | # almost same as action default 55 | type=ref,event=branch 56 | type=ref,event=tag,pattern={{raw}} 57 | type=ref,event=pr 58 | 59 | - name: Setup docker buildx 60 | uses: docker/setup-buildx-action@v2 61 | 62 | - name: Build and push Docker image 63 | uses: docker/build-push-action@v4.2.1 64 | with: 65 | context: . 66 | push: true 67 | platforms: linux/arm64,linux/amd64 68 | tags: ${{ steps.meta.outputs.tags }} 69 | labels: ${{ steps.meta.outputs.labels }} 70 | -------------------------------------------------------------------------------- /src/pages/Project.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { Route, Switch, useParams, useRouteMatch } from 'react-router-dom'; 4 | import { useTitle } from '@/hooks'; 5 | import { FC } from '@/interfaces'; 6 | import { AppState } from '@/store'; 7 | import { 8 | clearCurrentProject, 9 | setCurrentProjectSaga, 10 | } from '@/store/project/slice'; 11 | import ProjectFiles from './ProjectFiles'; 12 | import ProjectPreview from './ProjectPreview'; 13 | import ProjectSetting from './ProjectSetting'; 14 | 15 | /** 项目路由的属性接口 */ 16 | interface ProjectProps {} 17 | /** 18 | * 项目路由 19 | */ 20 | const Project: FC = () => { 21 | useTitle(); // 设置标题 22 | const { projectID } = useParams<{ projectID: string }>(); 23 | const dispatch = useDispatch(); 24 | const { path } = useRouteMatch(); 25 | const currentTeam = useSelector((state: AppState) => state.team.currentTeam); 26 | const currentProjectSet = useSelector( 27 | (state: AppState) => state.projectSet.currentProjectSet, 28 | ); 29 | const currentProject = useSelector( 30 | (state: AppState) => state.project.currentProject, 31 | ); 32 | 33 | // 设置当前目标 project 34 | useEffect(() => { 35 | dispatch(setCurrentProjectSaga({ id: projectID })); 36 | return () => { 37 | dispatch(clearCurrentProject()); 38 | }; 39 | // eslint-disable-next-line react-hooks/exhaustive-deps 40 | }, [projectID]); 41 | 42 | return ( 43 | 44 | {/* 只有当从团队进入的时候才有未加入的项目,才会显示项目预览 */} 45 | 46 | {currentTeam && currentProjectSet && ( 47 | 52 | )} 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | export default Project; 64 | -------------------------------------------------------------------------------- /src/components/admin/AdminVCodeList.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Table, TablePaginationConfig } from 'antd'; 3 | import classNames from 'classnames'; 4 | import React, { useEffect, useState } from 'react'; 5 | import apis from '@/apis'; 6 | import { APIVCode } from '@/apis/user'; 7 | import { FC } from '@/interfaces'; 8 | import { toLowerCamelCase } from '@/utils'; 9 | import dayjs from 'dayjs'; 10 | 11 | /** 验证码列表的属性接口 */ 12 | interface AdminVCodeListProps { 13 | className?: string; 14 | } 15 | /** 16 | * 验证码列表 17 | */ 18 | export const AdminVCodeList: FC = ({ className }) => { 19 | const [data, setData] = useState([]); 20 | const [loading, setLoading] = useState(false); 21 | 22 | const fetchData = async () => { 23 | setLoading(true); 24 | try { 25 | const result = await apis.adminGetVCodeList(); 26 | const data = toLowerCamelCase(result.data); 27 | setData(data); 28 | } catch (error) { 29 | error.default(); 30 | } finally { 31 | setLoading(false); 32 | } 33 | }; 34 | 35 | useEffect(() => { 36 | fetchData(); 37 | }, []); 38 | 39 | const columns = [ 40 | { 41 | title: '类型介绍', 42 | dataIndex: 'intro', 43 | key: 'intro', 44 | }, 45 | { 46 | title: '验证码', 47 | dataIndex: 'content', 48 | key: 'content', 49 | }, 50 | { 51 | title: '验证码信息', 52 | dataIndex: 'info', 53 | key: 'info', 54 | }, 55 | { 56 | title: '过期时间', 57 | dataIndex: 'expires', 58 | key: 'expires', 59 | render: (_: any, record: APIVCode) => 60 | (dayjs.utc().isAfter(dayjs.utc(record.expires)) ? '[已过期] ' : '') + 61 | dayjs.utc(record.expires).local().format('lll'), 62 | }, 63 | { 64 | title: '生成时间', 65 | dataIndex: 'sendTime', 66 | key: 'sendTime', 67 | }, 68 | ]; 69 | 70 | return ( 71 |
72 | record.id} 75 | columns={columns} 76 | loading={loading} 77 | /> 78 | 79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /src/components/project/LanguageSelect.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Select } from 'antd'; 3 | import { SelectProps } from 'antd/lib/select'; 4 | import classNames from 'classnames'; 5 | import { useEffect, useState } from 'react'; 6 | import { api } from '@/apis'; 7 | import { FC } from '@/interfaces'; 8 | 9 | interface SelectOption { 10 | label: string; 11 | value: string; 12 | disabled: boolean; 13 | } 14 | /** 语言选择器的属性接口 */ 15 | interface LanguageSelectProps extends SelectProps { 16 | disabledLanguageIDs?: readonly string[]; 17 | className?: string; 18 | } 19 | /** 20 | * 语言选择器 21 | */ 22 | export const LanguageSelect: FC = ({ 23 | disabledLanguageIDs = [], 24 | className, 25 | ...props 26 | }) => { 27 | const [loading, setLoading] = useState(true); 28 | const [options, setOptions] = useState([]); 29 | 30 | useEffect(() => { 31 | setLoading(true); 32 | api.language.getLanguages().then((result) => { 33 | const options = result.data.map((item) => { 34 | const option: SelectOption = { 35 | label: item.i18nName, 36 | value: item.code, 37 | disabled: false, 38 | }; 39 | return option; 40 | }); 41 | setOptions(options); 42 | setLoading(false); 43 | }); 44 | // eslint-disable-next-line react-hooks/exhaustive-deps 45 | }, []); 46 | 47 | useEffect(() => { 48 | function filterDisabledLanguage(options: SelectOption[]) { 49 | return options.map((option) => ({ 50 | ...option, 51 | disabled: disabledLanguageIDs.includes(option.value), 52 | })); 53 | } 54 | setOptions((options) => filterDisabledLanguage(options)); 55 | // eslint-disable-next-line react-hooks/exhaustive-deps 56 | }, [options.length, disabledLanguageIDs.length, disabledLanguageIDs[0]]); 57 | 58 | return ( 59 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /src/components/HotKey/utils.ts: -------------------------------------------------------------------------------- 1 | import { osName } from '.'; 2 | import { HotKeyOption, HotKeyEvent, ModifierKey } from './interfaces'; 3 | 4 | export const isKeyboardElement = (element: HTMLElement): boolean => { 5 | if ( 6 | ['INPUT', 'TEXTAREA'].includes(element.tagName) && 7 | !(element as HTMLInputElement | HTMLTextAreaElement).readOnly 8 | ) { 9 | return true; 10 | } 11 | if (element.tagName === 'SELECT') { 12 | return true; 13 | } 14 | if (element.isContentEditable) { 15 | return true; 16 | } 17 | return false; 18 | }; 19 | 20 | export const getModifierKeyDisplayName = (modifierKey: ModifierKey): string => { 21 | let osKeyName: string = modifierKey; 22 | if (osName === 'macos') { 23 | osKeyName = { 24 | ctrl: 'Control', 25 | alt: 'Option', 26 | shift: 'Shift', 27 | meta: 'Command', 28 | }[modifierKey]; 29 | } else if (osName === 'windows') { 30 | osKeyName = { 31 | ctrl: 'Ctrl', 32 | alt: 'Alt', 33 | shift: 'Shift', 34 | meta: 'Win', 35 | }[modifierKey]; 36 | } else { 37 | osKeyName = { 38 | ctrl: 'Ctrl', 39 | alt: 'Alt', 40 | shift: 'Shift', 41 | meta: 'Meta', 42 | }[modifierKey]; 43 | } 44 | return osKeyName; 45 | }; 46 | 47 | export const getHotKeyDisplayName = ({ 48 | key = '', 49 | ctrl = false, 50 | alt = false, 51 | shift = false, 52 | meta = false, 53 | }: HotKeyOption): string => { 54 | let diaplayName = ''; 55 | diaplayName += ctrl ? getModifierKeyDisplayName('ctrl') + '+' : ''; 56 | diaplayName += alt ? getModifierKeyDisplayName('alt') + '+' : ''; 57 | diaplayName += shift ? getModifierKeyDisplayName('shift') + '+' : ''; 58 | diaplayName += meta ? getModifierKeyDisplayName('meta') + '+' : ''; 59 | diaplayName += key; 60 | return diaplayName; 61 | }; 62 | 63 | export const getHotKeyEvent = (nativeEvent: KeyboardEvent): HotKeyEvent => { 64 | const event: HotKeyEvent = { 65 | displayName: '', 66 | key: nativeEvent.code, 67 | ctrl: nativeEvent.ctrlKey, 68 | alt: nativeEvent.altKey, 69 | shift: nativeEvent.shiftKey, 70 | meta: nativeEvent.metaKey, 71 | nativeEvent: nativeEvent, 72 | }; 73 | event.displayName = getHotKeyDisplayName(event); 74 | return event; 75 | }; 76 | -------------------------------------------------------------------------------- /src/components/unused/ImageOCRProgress.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import classNames from 'classnames'; 3 | import React from 'react'; 4 | import { Icon, Tooltip } from '..'; 5 | import { ParseStatuses, PARSE_STATUS } from '../../constants'; 6 | import { FC } from '../../interfaces'; 7 | import style from '../../style'; 8 | 9 | const HEIGHT = 12; 10 | export const IMAGE_OCR_PROGRESS_HEIGHT = HEIGHT; 11 | 12 | /** 图片 OCR 进度的属性接口 */ 13 | interface ImageOCRProgressProps { 14 | parseStatus: ParseStatuses; 15 | parseStatusName?: string; 16 | parseErrorTypeDetailName?: string; 17 | percent?: number; 18 | percentName?: string; 19 | className?: string; 20 | } 21 | /** 22 | * 图片 OCR 进度 23 | */ 24 | export const ImageOCRProgress: FC = ({ 25 | parseStatus, 26 | parseStatusName = '', 27 | parseErrorTypeDetailName = '', 28 | percent = 0, 29 | percentName = '', 30 | className, 31 | }) => { 32 | return ( 33 |
59 | 63 |
64 | {parseStatus === PARSE_STATUS.PARSING ? percentName : parseStatusName} 65 |
66 | {parseStatus === PARSE_STATUS.PARSE_FAILED && ( 67 | 71 | 72 | 73 | )} 74 |
75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /src/components/HotKey/components/HotKeyRecorder.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Button, Input, InputRef } from 'antd'; 3 | import classNames from 'classnames'; 4 | import React, { FC, useRef, useState } from 'react'; 5 | import { useIntl } from 'react-intl'; 6 | import { MODIFIER_KEY_EVENT_KEYS } from '../constants'; 7 | import { HotKeyEvent, HotKeyOption } from '../interfaces'; 8 | import { getHotKeyDisplayName, getHotKeyEvent } from '../utils'; 9 | 10 | /** 热键设置器的属性接口 */ 11 | interface HotKeyRecorderProps { 12 | hotKey?: HotKeyOption; 13 | onHotKeyChange?: (hotKey?: HotKeyEvent) => void; 14 | className?: string; 15 | } 16 | /** 17 | * 热键设置器 18 | */ 19 | export const HotKeyRecorder: FC = ({ 20 | hotKey, 21 | onHotKeyChange, 22 | className, 23 | }) => { 24 | const { formatMessage } = useIntl(); // i18n 25 | 26 | const placeholder = hotKey 27 | ? getHotKeyDisplayName(hotKey) 28 | : formatMessage({ id: 'hotKeyRecorder.null' }); 29 | const [value, setValue] = useState(''); 30 | const domRef = useRef(null); 31 | 32 | return ( 33 |
45 | setValue(formatMessage({ id: 'hotKeyRecorder.tip' }))} 51 | onBlur={() => setValue('')} 52 | onKeyDown={(e) => { 53 | e.stopPropagation(); 54 | e.preventDefault(); 55 | // Ignore modifier keys 56 | if (MODIFIER_KEY_EVENT_KEYS.includes(e.nativeEvent.key)) return; 57 | const event = getHotKeyEvent(e.nativeEvent); 58 | onHotKeyChange?.(event); 59 | domRef.current?.blur(); 60 | }} 61 | readOnly 62 | /> 63 | {hotKey && ( 64 | 72 | )} 73 |
74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/pages/ProjectSetSetting.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React, { useEffect } from 'react'; 3 | import { useIntl } from 'react-intl'; 4 | import { useDispatch, useSelector } from 'react-redux'; 5 | import { 6 | Redirect, 7 | Route, 8 | Switch, 9 | useParams, 10 | useRouteMatch, 11 | } from 'react-router-dom'; 12 | import { 13 | DashboardBox, 14 | NavTab, 15 | NavTabs, 16 | ProjectSetSettingBase, 17 | } from '@/components'; 18 | import { Spin } from '@/components'; 19 | import { FC } from '@/interfaces'; 20 | import { AppState } from '@/store'; 21 | import { setCurrentProjectSetSaga } from '@/store/projectSet/slice'; 22 | import { useTitle } from '@/hooks'; 23 | 24 | /** 项目集设置页的属性接口 */ 25 | interface ProjectSetSettingProps {} 26 | /** 27 | * 项目集设置页 28 | */ 29 | const ProjectSetSetting: FC = () => { 30 | const { formatMessage } = useIntl(); // i18n 31 | useTitle(); // 设置标题 32 | const { projectSetID } = useParams() as { projectSetID: string }; 33 | const dispatch = useDispatch(); 34 | const { path, url } = useRouteMatch(); 35 | const platform = useSelector((state: AppState) => state.site.platform); 36 | const currentProjectSet = useSelector( 37 | (state: AppState) => state.projectSet.currentProjectSet, 38 | ); 39 | const isMobile = platform === 'mobile'; 40 | 41 | // 设置当前目标 projectSet 42 | useEffect(() => { 43 | dispatch(setCurrentProjectSetSaga({ id: projectSetID })); 44 | // eslint-disable-next-line react-hooks/exhaustive-deps 45 | }, [projectSetID]); 46 | 47 | const nav = currentProjectSet && ( 48 | 49 | 50 | {formatMessage({ id: 'projectSet.baseSetting' })} 51 | 52 | 53 | ); 54 | 55 | return currentProjectSet ? ( 56 | 61 | {/* 自动跳转到第一个导航 */} 62 | 63 | 64 | 65 | 66 | 67 | } 68 | /> 69 | ) : ( 70 | 79 | ); 80 | }; 81 | export default ProjectSetSetting; 82 | -------------------------------------------------------------------------------- /src/components/shared-form/AuthLoginedTip.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Button } from 'antd'; 3 | import { useIntl } from 'react-intl'; 4 | import { useDispatch, useSelector } from 'react-redux'; 5 | import { useHistory } from 'react-router'; 6 | import { Avatar } from '@/components'; 7 | import { AppState } from '@/store'; 8 | import { setUserToken } from '@/store/user/slice'; 9 | import style from '@/style'; 10 | import { FC } from '@/interfaces'; 11 | 12 | /** 已经登陆提示的属性接口 */ 13 | interface AuthLoginedTipProps { 14 | className?: string; 15 | } 16 | /** 17 | * 已经登陆提示 18 | */ 19 | export const AuthLoginedTip: FC = ({ className }) => { 20 | const { formatMessage } = useIntl(); // i18n 21 | const userName = useSelector((state: AppState) => state.user.name); 22 | const dispatch = useDispatch(); 23 | const history = useHistory(); 24 | const currentUser = useSelector((state: AppState) => state.user); 25 | 26 | /** 前往仪表盘 */ 27 | const goDashboard = () => { 28 | history.push('/dashboard/projects'); 29 | }; 30 | 31 | /** 登出 */ 32 | const logout = () => { 33 | dispatch(setUserToken({ token: '' })); 34 | }; 35 | 36 | return ( 37 |
59 | 65 |
66 | {formatMessage({ id: 'auth.loginedTip' }, { userName })} 67 |
68 | 77 | 80 |
81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /src/pages/UserSetting.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React from 'react'; 3 | import { useIntl } from 'react-intl'; 4 | import { useSelector } from 'react-redux'; 5 | import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; 6 | import { DashboardBox, NavTab, NavTabs, Spin } from '../components'; 7 | import { FC } from '../interfaces'; 8 | import { AppState } from '../store'; 9 | import { useTitle } from '../hooks'; 10 | import { UserBasicSettings } from '../components/setting/UserBasicSettings'; 11 | import { UserSecuritySettings } from '../components/setting/UserSecuritySettings'; 12 | 13 | /** 团队设置页的属性接口 */ 14 | interface UserSettingProps {} 15 | /** 16 | * 团队设置页 17 | */ 18 | const UserSetting: FC = () => { 19 | const { formatMessage } = useIntl(); // i18n 20 | useTitle(); // 设置标题 21 | const { path, url } = useRouteMatch(); 22 | const platform = useSelector((state: AppState) => state.site.platform); 23 | const isMobile = platform === 'mobile'; 24 | const user = useSelector((state: AppState) => state.user); 25 | 26 | const nav = ( 27 | 28 | 29 | {formatMessage({ id: 'site.baseSetting' })} 30 | 31 | 32 | {formatMessage({ id: 'site.safeSetting' })} 33 | 34 | 35 | ); 36 | 37 | return ( 38 | 44 | {isMobile ? ( 45 | // 手机版导航单独为一个页面 46 | 47 | {nav} 48 | 49 | ) : ( 50 | // PC 版自动跳转到第一个导航 51 | 52 | )} 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | ) : ( 61 | 70 | ) 71 | } 72 | /> 73 | ); 74 | }; 75 | export default UserSetting; 76 | -------------------------------------------------------------------------------- /src/components/HotKey/hooks/useHotKey.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, useEffect } from 'react'; 2 | import { MODIFIER_KEY_EVENT_KEYS } from '../constants'; 3 | import { HotKeyEvent, HotKeyOption } from '../interfaces'; 4 | import { getHotKeyEvent, isKeyboardElement } from '../utils'; 5 | 6 | export type UseHotKey = { 7 | ( 8 | option: HotKeyOption, 9 | callback: (event: HotKeyEvent, option: HotKeyOption) => any, 10 | deps?: DependencyList, 11 | ): void; 12 | }; 13 | export const useHotKey: UseHotKey = (option, callback, deps = []) => { 14 | const { 15 | key, 16 | ctrl = false, 17 | alt = false, 18 | shift = false, 19 | meta = false, 20 | keyDown = true, 21 | keyUp = false, 22 | preventDefault = true, 23 | stopPropagation = true, 24 | ignoreKeyboardElement = true, 25 | } = option; 26 | 27 | const disabled = option.disabled ?? option.disibled ?? false; 28 | 29 | useEffect(() => { 30 | if (disabled) return; 31 | const listener = (nativeEvent: KeyboardEvent) => { 32 | // Ignore modifier keys 33 | if (MODIFIER_KEY_EVENT_KEYS.includes(nativeEvent.key)) return; 34 | // Ignore disabled elements 35 | if ( 36 | ignoreKeyboardElement && 37 | isKeyboardElement(nativeEvent.target as HTMLElement) 38 | ) { 39 | return; 40 | } 41 | // If key set, ignore key not match 42 | const event = getHotKeyEvent(nativeEvent); 43 | if (key !== undefined) { 44 | if (event.key !== key) return; 45 | if (event.ctrl !== ctrl) return; 46 | if (event.alt !== alt) return; 47 | if (event.shift !== shift) return; 48 | if (event.meta !== meta) return; 49 | } 50 | if (preventDefault) nativeEvent.preventDefault(); 51 | if (stopPropagation) nativeEvent.stopPropagation(); 52 | callback(event, option); 53 | }; 54 | if (keyDown) window.addEventListener('keydown', listener); 55 | if (keyUp) window.addEventListener('keyup', listener); 56 | return () => { 57 | if (keyDown) window.removeEventListener('keydown', listener); 58 | if (keyUp) window.removeEventListener('keyup', listener); 59 | }; 60 | // eslint-disable-next-line react-hooks/exhaustive-deps 61 | }, [ 62 | key, 63 | ctrl, 64 | alt, 65 | shift, 66 | meta, 67 | keyDown, 68 | keyUp, 69 | disabled, 70 | preventDefault, 71 | stopPropagation, 72 | // eslint-disable-next-line react-hooks/exhaustive-deps 73 | ...deps, 74 | ]); 75 | }; 76 | -------------------------------------------------------------------------------- /src/apis/output.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import { request } from '.'; 3 | import { OUTPUT_STATUS, OUTPUT_TYPE } from '../constants/output'; 4 | import { toUnderScoreCase } from '../utils'; 5 | import { APIProject } from './project'; 6 | import { APITarget } from './target'; 7 | import { APIUser } from './user'; 8 | 9 | export interface APIOutput { 10 | id: string; 11 | project: APIProject; 12 | target: APITarget; 13 | user?: APIUser; 14 | type: OUTPUT_TYPE; 15 | status: OUTPUT_STATUS; 16 | fileIDsInclude: string[]; 17 | fileIDsExclude: string[]; 18 | statusDetails: { 19 | id: OUTPUT_STATUS; 20 | name: string; 21 | intro: string; 22 | }[]; 23 | link?: string; 24 | createTime: string; 25 | } 26 | 27 | /** 获取导出 */ 28 | const getOutputs = ({ 29 | projectID, 30 | targetID, 31 | configs, 32 | }: { 33 | projectID: string; 34 | targetID: string; 35 | configs?: AxiosRequestConfig; 36 | }) => { 37 | return request({ 38 | method: 'GET', 39 | url: `/v1/projects/${projectID}/targets/${targetID}/outputs`, 40 | ...configs, 41 | }); 42 | }; 43 | 44 | /** 新增导出 */ 45 | export interface CreateOutputData { 46 | type: OUTPUT_TYPE; 47 | fileIdsInclude?: string[]; 48 | fileIdsExclude?: string[]; 49 | } 50 | const createOutput = ({ 51 | projectID, 52 | targetID, 53 | data, 54 | configs, 55 | }: { 56 | projectID: string; 57 | targetID: string; 58 | data: CreateOutputData; 59 | configs?: AxiosRequestConfig; 60 | }) => { 61 | return request({ 62 | method: 'POST', 63 | url: `/v1/projects/${projectID}/targets/${targetID}/outputs`, 64 | data: toUnderScoreCase(data), 65 | ...configs, 66 | }); 67 | }; 68 | 69 | const createAllOutput = ({ 70 | projectID, 71 | configs, 72 | }: { 73 | projectID: string; 74 | configs?: AxiosRequestConfig; 75 | }) => { 76 | return request({ 77 | method: 'POST', 78 | url: `/v1/projects/${projectID}/outputs`, 79 | ...configs, 80 | }); 81 | }; 82 | 83 | const createTeamOutput = ({ 84 | teamID, 85 | configs, 86 | }: { 87 | teamID: string; 88 | configs?: AxiosRequestConfig; 89 | }) => { 90 | return request({ 91 | method: 'POST', 92 | url: `/v1/teams/${teamID}/outputs`, 93 | ...configs, 94 | }); 95 | }; 96 | 97 | export default { 98 | getOutputs, 99 | createOutput, 100 | createAllOutput, 101 | createTeamOutput, 102 | }; 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 萌翻[MoeFlow]前端项目 2 | [![GitHubStars](https://img.shields.io/github/stars/moeflow-com/moeflow-frontend)]() 3 | [![GitHubForks](https://img.shields.io/github/forks/moeflow-com/moeflow-frontend)]() 4 | [![Chinese README](https://img.shields.io/badge/README-中文-red)](README.md) 5 | [![English README](https://img.shields.io/badge/README-English-blue)](ENG_README.md) 6 | 7 | **由于部分API代码调整,请更新萌翻后端到对应 Version.1.0.1 后继续使用。** 8 | 9 | ## 部署方法 10 | 11 | 非开发者建议参考 [moeflow-deploy](https://github.com/moeflow-com/moeflow-deploy) ,用docker和docker-compose部署。 12 | 13 | ## 技术栈 14 | 15 | - Core 16 | - react 17 | - react-router // 路由 18 | - emotion // CSS in JS 19 | - react-intl // i18n 20 | - redux 21 | - react-redux 22 | - redux-saga // 副作用处理 23 | - immer.js // 不可变对象处理 24 | - UI 25 | - antd 26 | - antd-mobile 27 | - classnames 28 | - fontawesome 29 | - Other 30 | - pepjs // Pointer 事件垫片 31 | - bowser // 浏览器识别 32 | - why-did-you-render // 性能优化 33 | - lodash // 工具库 34 | - uuid 35 | - fontmin // 字体剪切 36 | 37 | ## 本地开发 38 | 39 | 1. 安装 Node.js 近期LTS版本,如v18 v20 40 | 2. `npm install` 安装依赖项 41 | 3. `npm start` 启动vite 开发服务器 42 | - 开发服务器自带API反向代理。默认将 `localhost:5173/api/*` 的请求转发到 `localhost:5000/*` (本地moeflow-backend开发版地址) 43 | - 上述配置可在 `vite.config.ts` 修改。比如不用本地的moeflow-backend,改用公网的服务器。 44 | 4. `npm build` 发布前端代码,**请注意** 此时使用的后端地址配置为 `.env` 中的配置。 45 | - 如果没有创建 `.env` 则为默认值 `/api`。 46 | 47 | 如果您要部署到 `Vercel` 之类的网站托管程序上,您可以直接将 `REACT_APP_BASE_URL` 相对应的后端接口地址配置到托管程序的环境变量中。 48 | 49 | ## 修改项目配置 50 | 51 | 如果您的译制组不是从 日语(ja) 翻译为 繁体中文(zh-TW) 您可以修改 `src/configs.tsx` 文件中的对应位置的配置(文件中有注释)。 52 | 以下是常见的几个语言代码: 53 | 54 | - `ja` 日语 55 | - `en` 英语 56 | - `ko` 朝鲜语(韩语) 57 | - `zh-CN` 简体中文 58 | - `zh-TW` 繁体中文 59 | 60 | ## 版本更新内容 61 | 62 | ### Version 1.0.0 63 | 64 | 萌翻前后端开源的首个版本 65 | 66 | ### Version 1.0.1 67 | 68 | 1. 处理一些数据处理和界面上的BUG 69 | 2. 调整需要初始化的默认配置内容,减少后只需要修改环境变量 `REACT_APP_BASE_URL` 指向您部署的后端地址。 70 | 3. 调整静态文件生成的目录结构,方便前后端联合部署。 71 | 4. 调整“创建团队”、“创建项目”页面中部分项目提交的内容。**(请配合最新版本的后端,避免出现数据格式问题!)** 72 | 5. 可配置网站标题等位置的内容,请从 `src/locales` 中查找对应词汇进行修改。 73 | 74 | ### Version 1.0.3 75 | 76 | (旧构架的最后稳定版本。如果新版本中遇到问题,建议回退至此版本尝试。) 77 | 78 | 1. 支持设置和显示首页 HTML/CSS 79 | 2. 同时构建linux-amd64和linux-aarch64镜像。此版本起可以部署到ARM机器。 80 | 81 | ### Version 1.1.0 82 | 83 | 1. 抛弃create-react-app和webpack,改用vite构建。 84 | 85 | ### Version 1.1.1 86 | 87 | - i18n: english locale 88 | - EXPERIMENTAL manga-image-translator based assisted translation 89 | - upgrade deps 90 | - minor fixes 91 | 92 | ### Version NEXT 93 | 94 | - [diff](https://github.com/moeflow-com/moeflow-frontend/compare/v1.1.1...main) 95 | -------------------------------------------------------------------------------- /src/utils/storage.ts: -------------------------------------------------------------------------------- 1 | import store from 'store'; 2 | import { HotKeyOption } from '../components/HotKey/interfaces'; 3 | import { HotKeyState } from '../store/hotKey/slice'; 4 | import { LLMConf } from '@/services/ai/llm_preprocess'; 5 | 6 | interface DefaultTarget { 7 | projectID: string; 8 | targetID: string; 9 | } 10 | 11 | export const saveDefaultTargetID = ({ 12 | projectID, 13 | targetID, 14 | }: DefaultTarget): void => { 15 | let defaultTargets: DefaultTarget[] = store.get('defaultTargets', []); 16 | defaultTargets = defaultTargets.filter( 17 | (item) => item.projectID !== projectID, 18 | ); 19 | defaultTargets.push({ projectID, targetID }); 20 | store.set('defaultTargets', defaultTargets.slice(-50)); 21 | }; 22 | 23 | export const loadDefaultTargetID = ({ 24 | projectID, 25 | }: { 26 | projectID: string; 27 | }): string | undefined => { 28 | const defaultTargets: DefaultTarget[] = store.get('defaultTargets', []); 29 | const defaultTarget = defaultTargets.find( 30 | (item) => item.projectID === projectID, 31 | ); 32 | return defaultTarget?.targetID; 33 | }; 34 | 35 | export const clearDefaultTargetID = ({ 36 | projectID, 37 | }: { 38 | projectID: string; 39 | }): void => { 40 | const defaultTargets: DefaultTarget[] = store.get('defaultTargets', []); 41 | store.set( 42 | 'defaultTargets', 43 | defaultTargets.filter((item) => item.projectID !== projectID), 44 | ); 45 | }; 46 | 47 | export const hotKeyStoragePrefix = 'hotKey-'; 48 | export type HotKeyStroage = HotKeyOption | null | 'disabled'; 49 | export const saveHotKey = ({ 50 | name, 51 | index, 52 | option, 53 | }: { 54 | name: keyof HotKeyState; 55 | index: number; 56 | option?: HotKeyOption; 57 | }): void => { 58 | const options: HotKeyStroage[] = store.get( 59 | `${hotKeyStoragePrefix}${name}`, 60 | [], 61 | ); 62 | options[index] = option ? option : 'disabled'; 63 | store.set(`${hotKeyStoragePrefix}${name}`, options); 64 | }; 65 | 66 | export const loadHotKey = ({ 67 | name, 68 | index, 69 | }: { 70 | name: keyof HotKeyState; 71 | index: number; 72 | }): HotKeyStroage => { 73 | const options: HotKeyStroage[] = store.get( 74 | `${hotKeyStoragePrefix}${name}`, 75 | [], 76 | ); 77 | return options[index] ? options[index] : null; 78 | }; 79 | 80 | export const llmConfStorage = { 81 | load(): LLMConf | null { 82 | return store.get('llmConf', null); 83 | }, 84 | save(conf: LLMConf | null) { 85 | if (conf) { 86 | store.set('llmConf', conf); 87 | } else { 88 | store.remove('llmConf'); 89 | } 90 | }, 91 | } as const; 92 | -------------------------------------------------------------------------------- /src/store/hotKey/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { HotKeyOption } from '../../components/HotKey/interfaces'; 3 | import { OSName } from '../../interfaces'; 4 | 5 | export const getDefaultHotKey = ({ 6 | name, 7 | index, 8 | osName, 9 | }: { 10 | name: keyof HotKeyState; 11 | index: number; 12 | osName: OSName; 13 | }): HotKeyOption | undefined => { 14 | const options: { macos: HotKeyState; windows: HotKeyState } = { 15 | macos: { 16 | focusNextSource: [ 17 | { key: 'Tab', ignoreKeyboardElement: false }, 18 | { key: 'Enter', meta: true, ignoreKeyboardElement: false }, 19 | ], 20 | focusPrevSource: [ 21 | { key: 'Tab', shift: true, ignoreKeyboardElement: false }, 22 | ], 23 | goNextPage: [ 24 | { key: 'ArrowRight', meta: true, ignoreKeyboardElement: false }, 25 | ], 26 | goPrevPage: [ 27 | { key: 'ArrowLeft', meta: true, ignoreKeyboardElement: false }, 28 | ], 29 | }, 30 | windows: { 31 | focusNextSource: [ 32 | { key: 'Tab', ignoreKeyboardElement: false }, 33 | { key: 'Enter', ctrl: true, ignoreKeyboardElement: false }, 34 | ], 35 | focusPrevSource: [ 36 | { key: 'Tab', shift: true, ignoreKeyboardElement: false }, 37 | ], 38 | goNextPage: [ 39 | { key: 'ArrowRight', ctrl: true, ignoreKeyboardElement: false }, 40 | ], 41 | goPrevPage: [ 42 | { key: 'ArrowLeft', ctrl: true, ignoreKeyboardElement: false }, 43 | ], 44 | }, 45 | }; 46 | if (osName === 'macos') { 47 | return options['macos'][name][index]; 48 | } else { 49 | return options['windows'][name][index]; 50 | } 51 | }; 52 | 53 | export interface HotKeyState { 54 | focusNextSource: (HotKeyOption | undefined)[]; 55 | focusPrevSource: (HotKeyOption | undefined)[]; 56 | goNextPage: (HotKeyOption | undefined)[]; 57 | goPrevPage: (HotKeyOption | undefined)[]; 58 | } 59 | 60 | export const hotKeyInitialState: HotKeyState = { 61 | focusNextSource: [], 62 | focusPrevSource: [], 63 | goNextPage: [], 64 | goPrevPage: [], 65 | }; 66 | 67 | const slice = createSlice({ 68 | name: 'hotKey', 69 | initialState: hotKeyInitialState, 70 | reducers: { 71 | setHotKey( 72 | state, 73 | action: PayloadAction<{ 74 | name: keyof HotKeyState; 75 | index: number; 76 | option?: HotKeyOption; 77 | }>, 78 | ) { 79 | const { name, index, option } = action.payload; 80 | state[name][index] = option; 81 | }, 82 | }, 83 | }); 84 | 85 | export const { setHotKey } = slice.actions; 86 | export default slice.reducer; 87 | -------------------------------------------------------------------------------- /src/utils/source.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { APISource } from '../apis/source'; 3 | import { APITranslation } from '../apis/translation'; 4 | import { SourceTranslationState } from '../interfaces'; 5 | import { Translation } from '../interfaces/translation'; 6 | 7 | export const isValidTranslation = ( 8 | translation: Translation | undefined, 9 | ): boolean => { 10 | if (!translation) { 11 | return false; 12 | } 13 | return Boolean( 14 | translation.content || translation.proofreadContent || translation.selected, 15 | ); 16 | }; 17 | 18 | export const filterValidTranslations = ( 19 | translations: (Translation | undefined)[], 20 | ): Translation[] => { 21 | return translations.filter(isValidTranslation) as Translation[]; 22 | }; 23 | 24 | export const getSortedTranslations = (source: APISource): APITranslation[] => { 25 | // 筛选有效翻译 26 | const translations = filterValidTranslations(source.translations); 27 | // 将自己的翻译加入 28 | if (isValidTranslation(source.myTranslation)) { 29 | translations.unshift(source.myTranslation as Translation); 30 | } 31 | // 将翻译按修改时间排序 32 | translations.sort((a, b) => { 33 | const isBefore = dayjs.utc(a.editTime).isBefore(dayjs.utc(b.editTime)); 34 | return isBefore ? 1 : -1; 35 | }); 36 | // 将已选中的翻译放在第一个 37 | const selectedTranslationIndex = translations.findIndex( 38 | (translation) => translation.selected, 39 | ); 40 | const hasSelectedTranslation = selectedTranslationIndex > -1; 41 | if (hasSelectedTranslation) { 42 | const translation = translations[selectedTranslationIndex]; 43 | translations.splice(selectedTranslationIndex, 1); 44 | translations.unshift(translation); 45 | } 46 | return translations; 47 | }; 48 | 49 | export const getBestTranslation = ( 50 | source: APISource, 51 | ): APITranslation | undefined => { 52 | return getSortedTranslations(source)[0]; 53 | }; 54 | 55 | export const checkTranslationState = ( 56 | source: APISource, 57 | ): SourceTranslationState => { 58 | const translations = [...source.translations]; 59 | if (source.myTranslation) { 60 | translations.unshift(source.myTranslation); 61 | } 62 | const validTranslations = filterValidTranslations(translations); 63 | let statusLine: SourceTranslationState; 64 | if (validTranslations.length > 0) { 65 | if (validTranslations.some((t) => t.selected)) { 66 | statusLine = 'translationOk'; 67 | } else { 68 | if (validTranslations.length === 1) { 69 | statusLine = 'needCheckTranslation'; 70 | } else { 71 | statusLine = 'needSelectAndCheckTranslation'; 72 | } 73 | } 74 | } else { 75 | statusLine = 'needTranslation'; 76 | } 77 | return statusLine; 78 | }; 79 | -------------------------------------------------------------------------------- /src/apis/projectSet.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 角色相关 API 3 | */ 4 | import { request } from '.'; 5 | import { AxiosRequestConfig } from 'axios'; 6 | import { toUnderScoreCase } from '../utils'; 7 | import { PaginationParams } from '.'; 8 | 9 | /** 获取团队的项目集列表的请求数据 */ 10 | interface GetTeamProjectSetsParams { 11 | word?: string; 12 | } 13 | /** 获取团队的项目集列表 */ 14 | const getTeamProjectSets = ({ 15 | teamID, 16 | params, 17 | configs, 18 | }: { 19 | teamID: string; 20 | params?: GetTeamProjectSetsParams & PaginationParams; 21 | configs?: AxiosRequestConfig; 22 | }) => { 23 | return request({ 24 | method: 'GET', 25 | url: `/v1/teams/${teamID}/project-sets`, 26 | params: toUnderScoreCase(params), 27 | ...configs, 28 | }); 29 | }; 30 | 31 | /** 获取项目集 */ 32 | const getProjectSet = ({ 33 | id, 34 | configs, 35 | }: { 36 | id: string; 37 | configs?: AxiosRequestConfig; 38 | }) => { 39 | return request({ 40 | method: 'GET', 41 | url: `/v1/project-sets/${id}`, 42 | ...configs, 43 | }); 44 | }; 45 | 46 | /** 新建项目集的请求数据 */ 47 | interface CreateProjectSetData { 48 | name: string; 49 | intro: string; 50 | allowApplyType: number; 51 | applicationCheckType: number; 52 | defaultRole: string; 53 | } 54 | /** 新建项目集 */ 55 | const createProjectSet = ({ 56 | teamID, 57 | data, 58 | configs, 59 | }: { 60 | teamID: string; 61 | data: CreateProjectSetData; 62 | configs?: AxiosRequestConfig; 63 | }) => { 64 | return request({ 65 | method: 'POST', 66 | url: `/v1/teams/${teamID}/project-sets`, 67 | data: toUnderScoreCase(data), 68 | ...configs, 69 | }); 70 | }; 71 | 72 | /** 修改项目集的请求数据 */ 73 | interface EditProjectSetData { 74 | name: string; 75 | intro: string; 76 | allowApplyType: number; 77 | applicationCheckType: number; 78 | defaultRole: string; 79 | } 80 | /** 修改项目集 */ 81 | const editProjectSet = ({ 82 | id, 83 | data, 84 | configs, 85 | }: { 86 | id: string; 87 | data: EditProjectSetData; 88 | configs?: AxiosRequestConfig; 89 | }) => { 90 | return request({ 91 | method: 'PUT', 92 | url: `/v1/project-sets/${id}`, 93 | data: toUnderScoreCase(data), 94 | ...configs, 95 | }); 96 | }; 97 | 98 | /** 解散项目集 */ 99 | const deleteProjectSet = ({ 100 | id, 101 | configs, 102 | }: { 103 | id: string; 104 | configs?: AxiosRequestConfig; 105 | }) => { 106 | return request({ 107 | method: 'DELETE', 108 | url: `/v1/project-sets/${id}`, 109 | ...configs, 110 | }); 111 | }; 112 | 113 | export default { 114 | getTeamProjectSets, 115 | getProjectSet, 116 | createProjectSet, 117 | editProjectSet, 118 | deleteProjectSet, 119 | }; 120 | -------------------------------------------------------------------------------- /src/components/shared/NavTab.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { FC } from '@/interfaces'; 3 | import { NavLinkProps, NavLink } from 'react-router-dom'; 4 | import style from '@/style'; 5 | import { clickEffect } from '@/utils/style'; 6 | import { useSelector } from 'react-redux'; 7 | import { AppState } from '@/store'; 8 | import { Icon } from '@/components'; 9 | import classNames from 'classnames'; 10 | 11 | /** 带导航的 Tab 的属性接口 */ 12 | interface NavTabProps { 13 | className?: string; 14 | } 15 | /** 16 | * 带导航的 Tab 17 | */ 18 | export const NavTab: FC = ({ 19 | className, 20 | children, 21 | ...navLinkProps 22 | }) => { 23 | const platform = useSelector((state: AppState) => state.site.platform); 24 | const isMobile = platform === 'mobile'; 25 | // 手机版 push,PC 版 replace 26 | if (!isMobile) { 27 | navLinkProps = { replace: true, ...navLinkProps }; 28 | } 29 | 30 | return ( 31 | 85 |
{children}
86 |
87 | 88 |
89 |
90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/components/project-file/ImageViewerSettingPanel.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import classNames from 'classnames'; 3 | import React from 'react'; 4 | import { useIntl } from 'react-intl'; 5 | import { Icon, Tooltip } from '@/components'; 6 | import { FC } from '@/interfaces'; 7 | import style from '@/style'; 8 | import { clickEffect } from '@/utils/style'; 9 | 10 | /** 图片浏览器设置面板的属性接口 */ 11 | interface ImageViewerSettingPanelProps { 12 | onSettingButtonClick?: () => void; 13 | className?: string; 14 | } 15 | /** 16 | * 图片浏览器设置面板 17 | */ 18 | export const ImageViewerSettingPanel: FC = ({ 19 | onSettingButtonClick, 20 | className, 21 | }) => { 22 | const { formatMessage } = useIntl(); 23 | 24 | return ( 25 |
61 |
e.stopPropagation()} 64 | > 65 | 70 |
75 | 79 |
80 |
81 |
82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /src/components/shared-form/UserPasswordEditForm.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Button, Form as AntdForm, Input, message } from 'antd'; 3 | import { useState } from 'react'; 4 | import { useIntl } from 'react-intl'; 5 | import { useDispatch } from 'react-redux'; 6 | import { useHistory } from 'react-router-dom'; 7 | import { Form, FormItem } from '@/components'; 8 | import api from '@/apis'; 9 | import { FC } from '@/interfaces'; 10 | import { setUserToken } from '@/store/user/slice'; 11 | import { toLowerCamelCase } from '@/utils'; 12 | 13 | /** 修改项目表单的属性接口 */ 14 | interface UserPasswordEditFormProps { 15 | className?: string; 16 | } 17 | /** 18 | * 修改项目表单 19 | * 从 redux 的 currentProject 中读取值,使用前必须先 20 | * dispatch(setCurrentProject({ id })); 21 | */ 22 | export const UserPasswordEditForm: FC = ({ 23 | className, 24 | }) => { 25 | const { formatMessage } = useIntl(); // i18n 26 | const [form] = AntdForm.useForm(); 27 | const dispatch = useDispatch(); 28 | const [submitting, setSubmitting] = useState(false); 29 | const history = useHistory(); 30 | 31 | const handleFinish = (values: any) => { 32 | setSubmitting(true); 33 | api 34 | .editUserPassword({ data: values }) 35 | .then((result) => { 36 | setSubmitting(false); 37 | const data = toLowerCamelCase(result.data); 38 | // 清空表单 39 | form.resetFields(); 40 | dispatch(setUserToken({ token: '' })); 41 | history.push('/login'); 42 | // 弹出提示 43 | message.success(data.message); 44 | }) 45 | .catch((error) => { 46 | error.default(form); 47 | setSubmitting(false); 48 | }); 49 | }; 50 | 51 | return ( 52 |
61 |
62 | 67 | 68 | 69 | 74 | 75 | 76 | 77 | 80 | 81 | 82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /src/components/project/ProjectImportFromLabelplusStatus.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React from 'react'; 3 | import { useIntl } from 'react-intl'; 4 | import { FC, Project } from '@/interfaces'; 5 | import classNames from 'classnames'; 6 | import { IMPORT_FROM_LABELPLUS_STATUS } from '@/constants'; 7 | import { useInterval } from 'react-use'; 8 | import apis from '../../apis'; 9 | import { toLowerCamelCase } from '@/utils'; 10 | import { useDispatch } from 'react-redux'; 11 | import { editProject, setCurrentProject } from '@/store/project/slice'; 12 | import { Progress, Result } from 'antd'; 13 | 14 | /** 导入进度的属性接口 */ 15 | interface ProjectImportFromLabelplusStatusProps { 16 | project: Project; 17 | className?: string; 18 | } 19 | /** 20 | * 导入进度 21 | */ 22 | export const ProjectImportFromLabelplusStatus: FC< 23 | ProjectImportFromLabelplusStatusProps 24 | > = ({ project, className }) => { 25 | const { formatMessage } = useIntl(); // i18n 26 | const dispatch = useDispatch(); 27 | const delay = 28 | project.importFromLabelplusStatus === 29 | IMPORT_FROM_LABELPLUS_STATUS.RUNNING || 30 | project.importFromLabelplusStatus === IMPORT_FROM_LABELPLUS_STATUS.PENDING 31 | ? 5000 32 | : null; 33 | 34 | useInterval(() => { 35 | apis.getProject({ id: project.id }).then((result) => { 36 | const data = toLowerCamelCase(result.data); 37 | dispatch(setCurrentProject(data)); 38 | dispatch(editProject(data)); 39 | }); 40 | }, delay); 41 | 42 | return ( 43 |
53 | {project.importFromLabelplusStatus === 54 | IMPORT_FROM_LABELPLUS_STATUS.PENDING && ( 55 | 60 | )} 61 | {project.importFromLabelplusStatus === 62 | IMPORT_FROM_LABELPLUS_STATUS.ERROR && ( 63 | 67 | )} 68 | {project.importFromLabelplusStatus === 69 | IMPORT_FROM_LABELPLUS_STATUS.RUNNING && ( 70 | 79 | } 80 | /> 81 | )} 82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /src/apis/file.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 文件相关 API 3 | */ 4 | import { request } from '.'; 5 | import { AxiosRequestConfig } from 'axios'; 6 | import { toUnderScoreCase } from '@/utils'; 7 | import { PaginationParams } from '.'; 8 | import { File } from '@/interfaces'; 9 | import { FileSafeStatuses } from '@/constants'; 10 | 11 | /** 获取项目中文件列表的请求数据 */ 12 | interface GetProjectFilesParams { 13 | target?: string; 14 | word?: string; 15 | } 16 | /** 获取项目中文件列表 */ 17 | const getProjectFiles = ({ 18 | projectID, 19 | params, 20 | configs, 21 | }: { 22 | projectID: string; 23 | params?: GetProjectFilesParams & PaginationParams; 24 | configs?: AxiosRequestConfig; 25 | }) => { 26 | return request({ 27 | method: 'GET', 28 | url: `/v1/projects/${projectID}/files`, 29 | params: toUnderScoreCase(params), 30 | ...configs, 31 | }); 32 | }; 33 | 34 | /** 获取文件的请求数据 */ 35 | interface GetFileParams { 36 | target?: string; 37 | } 38 | export interface GetFileReturn extends File { 39 | projectID: string; 40 | } 41 | /** 获取文件 */ 42 | const getFile = ({ 43 | fileID, 44 | params, 45 | configs, 46 | }: { 47 | fileID: string; 48 | params?: GetFileParams; 49 | configs?: AxiosRequestConfig; 50 | }) => { 51 | return request({ 52 | method: 'GET', 53 | url: `/v1/files/${fileID}`, 54 | params: toUnderScoreCase(params), 55 | ...configs, 56 | }); 57 | }; 58 | 59 | /** 删除文件 */ 60 | const deleteFile = ({ 61 | id, 62 | configs, 63 | }: { 64 | id: string; 65 | configs?: AxiosRequestConfig; 66 | }) => { 67 | return request({ 68 | method: 'DELETE', 69 | url: `/v1/files/${id}`, 70 | ...configs, 71 | }); 72 | }; 73 | 74 | /** 获取项目中文件列表的请求数据 */ 75 | interface AdminGetFilesParams { 76 | safeStatus?: FileSafeStatuses[]; 77 | } 78 | /** 获取项目中文件列表 */ 79 | const adminGetFiles = ({ 80 | params, 81 | configs, 82 | }: { 83 | params?: AdminGetFilesParams & PaginationParams; 84 | configs?: AxiosRequestConfig; 85 | }) => { 86 | return request({ 87 | method: 'GET', 88 | url: `/v1/admin/files`, 89 | params: toUnderScoreCase(params), 90 | ...configs, 91 | }); 92 | }; 93 | 94 | const adminSafeCheck = ({ 95 | safeFileIDs, 96 | unsafeFileIDs, 97 | configs, 98 | }: { 99 | safeFileIDs: string[]; 100 | unsafeFileIDs: string[]; 101 | configs?: AxiosRequestConfig; 102 | }) => { 103 | return request({ 104 | method: 'PUT', 105 | url: `/v1/admin/files/safe-status`, 106 | data: toUnderScoreCase({ 107 | safeFiles: safeFileIDs, 108 | unsafeFiles: unsafeFileIDs, 109 | }), 110 | ...configs, 111 | }); 112 | }; 113 | 114 | export default { 115 | getProjectFiles, 116 | getFile, 117 | deleteFile, 118 | adminGetFiles, 119 | adminSafeCheck, 120 | }; 121 | -------------------------------------------------------------------------------- /src/apis/translation.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import { request } from '.'; 3 | import { toUnderScoreCase } from '@/utils'; 4 | import { APISource } from './source'; 5 | import { APITarget } from './target'; 6 | import { APIUser } from './user'; 7 | 8 | export interface APITranslation { 9 | sourceID: string; 10 | id: string; 11 | mt: boolean; 12 | content: string; 13 | user: APIUser | null; 14 | proofreadContent: string; 15 | proofreader: APIUser | null; 16 | selected: boolean; 17 | selector: APIUser | null; 18 | createTime: string; 19 | editTime: string; 20 | target: APITarget; 21 | } 22 | 23 | /** 新增翻译的请求数据 */ 24 | export interface CreateTranslationData { 25 | content: string; 26 | targetID: string; 27 | } 28 | /** 新增翻译 */ 29 | const createTranslation = ({ 30 | sourceID, 31 | data, 32 | configs, 33 | }: { 34 | sourceID: string; 35 | data: CreateTranslationData; 36 | configs?: AxiosRequestConfig; 37 | }) => { 38 | return request({ 39 | method: 'POST', 40 | url: `/v1/sources/${sourceID}/translations`, 41 | data: toUnderScoreCase(data), 42 | ...configs, 43 | }); 44 | }; 45 | 46 | /** 修改翻译的请求数据 */ 47 | export interface EditTranslationData { 48 | content?: string; 49 | proofreadContent?: string; 50 | selected?: boolean; 51 | } 52 | /** 修改翻译 */ 53 | const editTranslation = ({ 54 | translationID, 55 | data, 56 | configs, 57 | }: { 58 | translationID: string; 59 | data: EditTranslationData; 60 | configs?: AxiosRequestConfig; 61 | }) => { 62 | return request({ 63 | method: 'PUT', 64 | url: `/v1/translations/${translationID}`, 65 | data: toUnderScoreCase(data), 66 | ...configs, 67 | }); 68 | }; 69 | 70 | /** 批量选择翻译的请求数据 */ 71 | export type BatchSelectTranslationData = { 72 | sourceID: string; 73 | translationID: string; 74 | }[]; 75 | /** 批量选择翻译 */ 76 | const batchSelectTranslation = ({ 77 | fileID, 78 | data, 79 | configs, 80 | }: { 81 | fileID: string; 82 | data: BatchSelectTranslationData; 83 | configs?: AxiosRequestConfig; 84 | }) => { 85 | return request({ 86 | method: 'PATCH', 87 | url: `/v1/files/${fileID}/sources`, 88 | data: toUnderScoreCase(data), 89 | ...configs, 90 | }); 91 | }; 92 | 93 | /** 删除翻译 */ 94 | const deleteTranslation = ({ 95 | translationID, 96 | configs, 97 | }: { 98 | translationID: string; 99 | configs?: AxiosRequestConfig; 100 | }) => { 101 | return request({ 102 | method: 'DELETE', 103 | url: `/v1/translations/${translationID}`, 104 | ...configs, 105 | }); 106 | }; 107 | 108 | export default { 109 | createTranslation, 110 | deleteTranslation, 111 | editTranslation, 112 | batchSelectTranslation, 113 | }; 114 | -------------------------------------------------------------------------------- /src/components/project-file/ImageTranslatorSettingMouse.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import React from 'react'; 3 | import { useIntl } from 'react-intl'; 4 | import { FC } from '@/interfaces'; 5 | import classNames from 'classnames'; 6 | 7 | /** 模板的属性接口 */ 8 | interface ImageTranslatorSettingMouseProps { 9 | className?: string; 10 | } 11 | /** 12 | * 模板 13 | */ 14 | export const ImageTranslatorSettingMouse: FC< 15 | ImageTranslatorSettingMouseProps 16 | > = ({ className }) => { 17 | const { formatMessage } = useIntl(); // i18n 18 | 19 | return ( 20 |
39 |
40 | {formatMessage({ id: 'mouse.function' })} 41 |
42 |
43 | {formatMessage({ id: 'mouse.key' })} 44 |
45 |
46 | {formatMessage({ id: 'mouse.function' })} 47 |
48 |
49 | {formatMessage({ id: 'mouse.key' })} 50 |
51 |
52 | {formatMessage({ id: 'mouse.addInLabel' })} 53 |
54 |
55 | {formatMessage({ id: 'mouse.leftOnImage' })} 56 |
57 |
58 | {formatMessage({ id: 'mouse.addOutLabel' })} 59 |
60 |
61 | {formatMessage({ id: 'mouse.rightOnImage' })} 62 |
63 |
64 | {formatMessage({ id: 'mouse.changeLabelPosition' })} 65 |
66 |
67 | {formatMessage({ id: 'mouse.middleOnLabel' })} 68 |
69 |
70 | {formatMessage({ id: 'mouse.deleteLabel' })} 71 |
72 |
73 | {formatMessage({ id: 'mouse.rightOnLabel' })} 74 |
75 |
76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/fontAwesome.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 建立 FontAwesome 图标库,供 index.tsx 和 test/setup.ts 直接引用 3 | * (以避免 test 中未引用图标报 Warning) 4 | */ 5 | import { library } from '@fortawesome/fontawesome-svg-core'; 6 | import { 7 | faSyncAlt, 8 | faBars, 9 | faSearchMinus, 10 | faSearchPlus, 11 | faArrowsAltH, 12 | faArrowsAltV, 13 | faImage, 14 | faCaretUp, 15 | faCaretDown, 16 | faChevronLeft, 17 | faChevronUp, 18 | faChevronCircleUp, 19 | faChevronDown, 20 | faChevronCircleDown, 21 | faCog, 22 | faAngleDoubleRight, 23 | faRobot, 24 | faTimes, 25 | faWifi, 26 | faHome, 27 | faBook, 28 | faAngleLeft, 29 | faEllipsisH, 30 | faPlus, 31 | faBan, 32 | faCaretLeft, 33 | faCaretRight, 34 | faCheck, 35 | faExclamationTriangle, 36 | faAngleRight, 37 | faUserCircle, 38 | faSignOutAlt, 39 | faBox, 40 | faLayerGroup, 41 | faCheckDouble, 42 | faCommentAlt, 43 | faCloudUploadAlt, 44 | faExclamationCircle, 45 | faLanguage, 46 | faCloud, 47 | faExchangeAlt, 48 | faPenNib, 49 | faPencilAlt, 50 | faStar, 51 | faSpinner, 52 | faTag, 53 | faDownload, 54 | faSync, 55 | faThLarge, 56 | faThList, 57 | faAngleDoubleDown, 58 | faAngleDoubleUp, 59 | faUserCheck, 60 | faLink, 61 | faSave, 62 | faPaste, 63 | } from '@fortawesome/free-solid-svg-icons'; 64 | import { faKissWinkHeart as faKissWinkHeartRegular } from '@fortawesome/free-regular-svg-icons'; 65 | import { faGithub } from '@fortawesome/free-brands-svg-icons'; 66 | library.add( 67 | ...[ 68 | faSyncAlt, 69 | faBars, 70 | faSearchMinus, 71 | faSearchPlus, 72 | faArrowsAltH, 73 | faArrowsAltV, 74 | faImage, 75 | faCaretUp, 76 | faCaretDown, 77 | faChevronUp, 78 | faChevronCircleUp, 79 | faChevronDown, 80 | faChevronCircleDown, 81 | faChevronLeft, 82 | faCog, 83 | faAngleDoubleRight, 84 | faRobot, 85 | faTimes, 86 | faWifi, 87 | faHome, 88 | faBook, 89 | faAngleLeft, 90 | faEllipsisH, 91 | faPlus, 92 | faBan, 93 | faCaretLeft, 94 | faCaretRight, 95 | faCheck, 96 | faExclamationTriangle, 97 | faAngleRight, 98 | faUserCircle, 99 | faSignOutAlt, 100 | faBox, 101 | faLayerGroup, 102 | faCheckDouble, 103 | faCommentAlt, 104 | faCloudUploadAlt, 105 | faExclamationCircle, 106 | faLanguage, 107 | faCloud, 108 | faExchangeAlt, 109 | faPenNib, 110 | faPencilAlt, 111 | faStar, 112 | faSpinner, 113 | faTag, 114 | faDownload, 115 | faSync, 116 | faThLarge, 117 | faThList, 118 | faAngleDoubleDown, 119 | faAngleDoubleUp, 120 | faUserCheck, 121 | faLink, 122 | faSave, 123 | faPaste, 124 | ], 125 | // Regular icons 126 | ...[faKissWinkHeartRegular], 127 | // Brand icons 128 | ...[faGithub], 129 | ); 130 | -------------------------------------------------------------------------------- /src/apis/team.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 角色相关 API 3 | */ 4 | import { request } from '.'; 5 | import { AxiosRequestConfig } from 'axios'; 6 | import { toUnderScoreCase } from '../utils'; 7 | import { PaginationParams } from '.'; 8 | 9 | /** 获取个人团队列表的请求数据 */ 10 | interface GetUserTeamsParams { 11 | word?: string; 12 | } 13 | /** 获取个人团队列表 */ 14 | const getUserTeams = ({ 15 | params, 16 | configs, 17 | }: { 18 | params?: GetUserTeamsParams & PaginationParams; 19 | configs?: AxiosRequestConfig; 20 | } = {}) => { 21 | return request({ 22 | method: 'GET', 23 | url: `/v1/user/teams`, 24 | params: toUnderScoreCase(params), 25 | ...configs, 26 | }); 27 | }; 28 | 29 | /** 获取团队列表的请求数据 */ 30 | interface GetTeamsParams { 31 | word: string; 32 | } 33 | /** 获取团队列表 */ 34 | const getTeams = ({ 35 | params, 36 | configs, 37 | }: { 38 | params: GetTeamsParams & PaginationParams; 39 | configs?: AxiosRequestConfig; 40 | }) => { 41 | return request({ 42 | method: 'GET', 43 | url: `/v1/teams`, 44 | params: toUnderScoreCase(params), 45 | ...configs, 46 | }); 47 | }; 48 | 49 | /** 获取团队 */ 50 | const getTeam = ({ 51 | id, 52 | configs, 53 | }: { 54 | id: string; 55 | configs?: AxiosRequestConfig; 56 | }) => { 57 | return request({ 58 | method: 'GET', 59 | url: `/v1/teams/${id}`, 60 | ...configs, 61 | }); 62 | }; 63 | 64 | /** 新建团队的请求数据 */ 65 | interface CreateTeamData { 66 | name: string; 67 | intro: string; 68 | allowApplyType: number; 69 | applicationCheckType: number; 70 | defaultRole: string; 71 | } 72 | /** 新建团队 */ 73 | const createTeam = ({ 74 | data, 75 | configs, 76 | }: { 77 | data: CreateTeamData; 78 | configs?: AxiosRequestConfig; 79 | }) => { 80 | return request({ 81 | method: 'POST', 82 | url: `/v1/teams`, 83 | data: toUnderScoreCase(data), 84 | ...configs, 85 | }); 86 | }; 87 | 88 | /** 修改团队的请求数据 */ 89 | interface EditTeamData { 90 | name: string; 91 | intro: string; 92 | allowApplyType: number; 93 | applicationCheckType: number; 94 | defaultRole: string; 95 | } 96 | /** 修改团队 */ 97 | const editTeam = ({ 98 | id, 99 | data, 100 | configs, 101 | }: { 102 | id: string; 103 | data: EditTeamData; 104 | configs?: AxiosRequestConfig; 105 | }) => { 106 | return request({ 107 | method: 'PUT', 108 | url: `/v1/teams/${id}`, 109 | data: toUnderScoreCase(data), 110 | ...configs, 111 | }); 112 | }; 113 | 114 | /** 解散团队 */ 115 | const deleteTeam = ({ 116 | id, 117 | configs, 118 | }: { 119 | id: string; 120 | configs?: AxiosRequestConfig; 121 | }) => { 122 | return request({ 123 | method: 'DELETE', 124 | url: `/v1/teams/${id}`, 125 | ...configs, 126 | }); 127 | }; 128 | 129 | export default { 130 | getUserTeams, 131 | getTeams, 132 | getTeam, 133 | createTeam, 134 | editTeam, 135 | deleteTeam, 136 | }; 137 | -------------------------------------------------------------------------------- /src/components/project-set/ProjectSetCreateForm.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Button, Form as AntdForm, Input, message } from 'antd'; 3 | import { useState } from 'react'; 4 | import { useIntl } from 'react-intl'; 5 | import { useDispatch } from 'react-redux'; 6 | import { useHistory } from 'react-router-dom'; 7 | import { Form, FormItem } from '@/components'; 8 | import api from '@/apis'; 9 | import { FC, ProjectSet } from '@/interfaces'; 10 | import { resetProjectsState } from '@/store/project/slice'; 11 | import { 12 | createProjectSet, 13 | resetProjectSetsState, 14 | } from '@/store/projectSet/slice'; 15 | import { toLowerCamelCase } from '@/utils'; 16 | 17 | /** 创建团队表单的属性接口 */ 18 | interface ProjectSetCreateFormProps { 19 | teamID: string; 20 | className?: string; 21 | } 22 | /** 23 | * 创建团队表单 24 | */ 25 | export const ProjectSetCreateForm: FC = ({ 26 | teamID, 27 | className, 28 | }) => { 29 | const { formatMessage } = useIntl(); // i18n 30 | const [form] = AntdForm.useForm(); 31 | const dispatch = useDispatch(); 32 | const history = useHistory(); 33 | const [submitting, setSubmitting] = useState(false); 34 | 35 | const handleFinish = (values: any) => { 36 | setSubmitting(true); 37 | api 38 | .createProjectSet({ teamID, data: values }) 39 | .then((result) => { 40 | setSubmitting(false); 41 | dispatch(resetProjectSetsState()); 42 | dispatch(resetProjectsState()); 43 | // 创建成功 44 | dispatch( 45 | createProjectSet({ 46 | projectSet: toLowerCamelCase(result.data.project_set), 47 | unshift: true, 48 | }), 49 | ); 50 | // 跳转到项目集 51 | history.replace( 52 | `/dashboard/teams/${teamID}/project-sets/${result.data.project_set.id}`, 53 | ); 54 | // 弹出提示 55 | message.success(result.data.message); 56 | }) 57 | .catch((error) => { 58 | error.default(form); 59 | setSubmitting(false); 60 | }); 61 | }; 62 | 63 | return ( 64 |
73 |
79 | 84 | 85 | 86 | 87 | 90 | 91 | 92 |
93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/pages/Index.tsx: -------------------------------------------------------------------------------- 1 | import { css, Global } from '@emotion/core'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { useIntl } from 'react-intl'; 4 | import { Header } from '../components'; 5 | import { Spin } from 'antd'; 6 | import brandJump from '../images/brand/mascot-jump1.png'; 7 | import { FC } from '../interfaces'; 8 | import { useTitle } from '../hooks'; 9 | import { api } from '../apis'; 10 | 11 | /** 首页的属性接口 */ 12 | interface IndexProps {} 13 | /** 14 | * 首页 15 | */ 16 | export const IndexPage: FC = () => { 17 | const { formatMessage } = useIntl(); // i18n 18 | useTitle({ suffix: formatMessage({ id: 'site.slogan' }) }); // 设置标题 19 | const [homepageHtml, setHomepageHtml] = useState(); 20 | const [homepageCss, setHomepageCss] = useState(); 21 | 22 | useEffect(() => { 23 | api.siteSetting 24 | .getHomepage({}) 25 | .then((res) => { 26 | setHomepageHtml(res.data.html); 27 | setHomepageCss(res.data.css); 28 | }) 29 | .catch((err) => { 30 | setHomepageHtml(''); 31 | setHomepageCss(''); 32 | }); 33 | }, []); 34 | 35 | return homepageHtml === undefined ? ( 36 |
46 | 47 |
48 | ) : homepageHtml === '' ? ( 49 |
75 | 83 |
84 |
85 | Mascot 86 |
87 |
{/* 备案号 */}
88 |
89 | ) : ( 90 | <> 91 | 96 |
101 | 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /src/components/project-set/ProjectSetEditForm.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Button, Form as AntdForm, Input, message } from 'antd'; 3 | import { useEffect, useState } from 'react'; 4 | import { useIntl } from 'react-intl'; 5 | import { useDispatch, useSelector } from 'react-redux'; 6 | import { Form, FormItem } from '@/components'; 7 | import api from '@/apis'; 8 | import { FC, UserProjectSet } from '@/interfaces'; 9 | import { AppState } from '@/store'; 10 | import { editProjectSet, setCurrentProjectSet } from '@/store/projectSet/slice'; 11 | import style from '@/style'; 12 | import { toLowerCamelCase } from '@/utils'; 13 | 14 | /** 修改项目集表单的属性接口 */ 15 | interface ProjectSetEditFormProps { 16 | className?: string; 17 | } 18 | /** 19 | * 修改项目集表单 20 | * 从 redux 的 currentProjectSet 中读取值,使用前必须先 21 | * dispatch(setCurrentProjectSet({ id })); 22 | */ 23 | export const ProjectSetEditForm: FC = ({ 24 | className, 25 | }) => { 26 | const { formatMessage } = useIntl(); // i18n 27 | const [form] = AntdForm.useForm(); 28 | const dispatch = useDispatch(); 29 | const currentProjectSet = useSelector( 30 | (state: AppState) => state.projectSet.currentProjectSet, 31 | ) as UserProjectSet; 32 | const [submitting, setSubmitting] = useState(false); 33 | 34 | // id 改变时,获取初始值 35 | useEffect(() => { 36 | form.setFieldsValue(toLowerCamelCase(currentProjectSet)); 37 | // eslint-disable-next-line react-hooks/exhaustive-deps 38 | }, [currentProjectSet.id]); 39 | 40 | const handleFinish = (values: any) => { 41 | setSubmitting(true); 42 | api 43 | .editProjectSet({ id: currentProjectSet.id, data: values }) 44 | .then((result) => { 45 | const data = toLowerCamelCase(result.data); 46 | // 修改成功 47 | dispatch(editProjectSet(data.projectSet)); 48 | dispatch(setCurrentProjectSet(data.projectSet)); 49 | // 弹出提示 50 | message.success(data.message); 51 | }) 52 | .catch((error) => { 53 | error.default(form); 54 | }) 55 | .finally(() => setSubmitting(false)); 56 | }; 57 | 58 | return ( 59 |
69 |
75 | 80 | 81 | 82 | 83 | 86 | 87 | 88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /src/store/projectSet/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { UserProjectSet } from '../../interfaces'; 3 | 4 | export interface ProjectSetsState { 5 | page: number; 6 | word: string; 7 | scrollTop: number; 8 | } 9 | export interface ProjectSetState { 10 | readonly currentProjectSet?: UserProjectSet; 11 | readonly projectSets: UserProjectSet[]; 12 | readonly projectSetsState: ProjectSetsState; 13 | } 14 | 15 | export const initialState: ProjectSetState = { 16 | projectSets: [], 17 | projectSetsState: { 18 | page: 1, 19 | word: '', 20 | scrollTop: 0, 21 | }, 22 | }; 23 | const slice = createSlice({ 24 | name: 'projectSet', 25 | initialState, 26 | reducers: { 27 | clearProjectSets(state) { 28 | state.projectSets = []; 29 | }, 30 | createProjectSet( 31 | state, 32 | action: PayloadAction<{ projectSet: UserProjectSet; unshift?: boolean }>, 33 | ) { 34 | const { projectSet, unshift = false } = action.payload; 35 | if (unshift) { 36 | // 如果第一个是 “未分组” 则插入到其后面 37 | if (state.projectSets[0]?.default) { 38 | state.projectSets.splice(1, 0, projectSet); 39 | } else { 40 | state.projectSets.unshift(projectSet); 41 | } 42 | } else { 43 | state.projectSets.push(projectSet); 44 | } 45 | }, 46 | editProjectSet(state, action: PayloadAction) { 47 | const index = state.projectSets.findIndex( 48 | (projectSet) => projectSet.id === action.payload.id, 49 | ); 50 | if (index > -1) { 51 | state.projectSets[index] = action.payload; 52 | } 53 | }, 54 | deleteProjectSet(state, action: PayloadAction<{ id: string }>) { 55 | const index = state.projectSets.findIndex( 56 | (projectSet) => projectSet.id === action.payload.id, 57 | ); 58 | if (index > -1) { 59 | state.projectSets.splice(index, 1); 60 | } 61 | }, 62 | /** 设置当前编辑/设置的团队 */ 63 | setCurrentProjectSet(state, action: PayloadAction) { 64 | state.currentProjectSet = action.payload; 65 | }, 66 | setCurrentProjectSetSaga(state, action: PayloadAction<{ id: string }>) { 67 | // saga:从服务器获取 ProjectSet 68 | }, 69 | clearCurrentProjectSet(state) { 70 | state.currentProjectSet = undefined; 71 | }, 72 | setProjectSetsState( 73 | state, 74 | action: PayloadAction>, 75 | ) { 76 | state.projectSetsState = { ...state.projectSetsState, ...action.payload }; 77 | }, 78 | resetProjectSetsState(state) { 79 | state.projectSetsState = initialState.projectSetsState; 80 | }, 81 | }, 82 | }); 83 | 84 | export const { 85 | clearProjectSets, 86 | createProjectSet, 87 | editProjectSet, 88 | deleteProjectSet, 89 | setCurrentProjectSet, 90 | setCurrentProjectSetSaga, 91 | clearCurrentProjectSet, 92 | setProjectSetsState, 93 | resetProjectSetsState, 94 | } = slice.actions; 95 | export default slice.reducer; 96 | -------------------------------------------------------------------------------- /src/apis/source.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import { PaginationParams, request } from '.'; 3 | import { toUnderScoreCase } from '@/utils'; 4 | import { APITip } from './tip'; 5 | import { APITranslation } from './translation'; 6 | 7 | export interface APISource { 8 | id: string; 9 | x: number; 10 | y: number; 11 | content: string; 12 | myTranslation?: APITranslation; 13 | positionType: number; 14 | translations: APITranslation[]; // 不含我的翻译数据 15 | hasOtherLanguageTranslation: boolean; 16 | tips: APITip[]; 17 | } 18 | 19 | /** 获取原文的请求数据 */ 20 | interface GetSourcesParams { 21 | targetID: string; 22 | } 23 | /** 获取原文 */ 24 | const getSources = ({ 25 | fileID, 26 | params, 27 | configs, 28 | }: { 29 | fileID: string; 30 | params: GetSourcesParams & PaginationParams; 31 | configs?: AxiosRequestConfig; 32 | }) => { 33 | return request({ 34 | method: 'GET', 35 | url: `/v1/files/${fileID}/sources`, 36 | params: { ...toUnderScoreCase(params), paging: 'false' }, 37 | ...configs, 38 | }); 39 | }; 40 | 41 | /** 新增原文的请求数据 */ 42 | interface CreateSourceData { 43 | x: number; 44 | y: number; 45 | content?: string; 46 | } 47 | /** 新增原文 */ 48 | const createSource = ({ 49 | fileID, 50 | data, 51 | configs, 52 | }: { 53 | fileID: string; 54 | data: CreateSourceData; 55 | configs?: AxiosRequestConfig; 56 | }) => { 57 | return request({ 58 | method: 'POST', 59 | url: `/v1/files/${fileID}/sources`, 60 | data: toUnderScoreCase(data), 61 | ...configs, 62 | }); 63 | }; 64 | 65 | /** 66 | * move source identified by {@name sourceID} to its new position identified by {@name nextSourceID} 67 | */ 68 | const rerankSource = ({ 69 | sourceID, 70 | nextSourceID, 71 | }: { 72 | sourceID: string; 73 | nextSourceID: string | 'end'; 74 | }) => 75 | request({ 76 | method: 'PUT', 77 | url: `/v1/sources/${sourceID}/rank`, 78 | data: toUnderScoreCase({ nextSourceID }), 79 | }); 80 | 81 | /** 修改原文的请求数据 */ 82 | interface EditSourceData { 83 | x?: number; 84 | y?: number; 85 | content?: string; 86 | positionType?: number; 87 | } 88 | /** 修改原文 */ 89 | const editSource = ({ 90 | sourceID, 91 | data, 92 | configs, 93 | }: { 94 | sourceID: string; 95 | data: EditSourceData; 96 | configs?: AxiosRequestConfig; 97 | }) => { 98 | return request({ 99 | method: 'PUT', 100 | url: `/v1/sources/${sourceID}`, 101 | data: toUnderScoreCase(data), 102 | ...configs, 103 | }); 104 | }; 105 | 106 | /** 删除原文 */ 107 | const deleteSource = ({ 108 | sourceID, 109 | configs, 110 | }: { 111 | sourceID: string; 112 | configs?: AxiosRequestConfig; 113 | }) => { 114 | return request({ 115 | method: 'DELETE', 116 | url: `/v1/sources/${sourceID}`, 117 | ...configs, 118 | }); 119 | }; 120 | 121 | export default { 122 | getSources, 123 | createSource, 124 | deleteSource, 125 | editSource, 126 | rerankSource, 127 | }; 128 | -------------------------------------------------------------------------------- /src/components/setting/UserEditForm.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Button, Form as AntdForm, Input, message } from 'antd'; 3 | import React, { useState } from 'react'; 4 | import { useIntl } from 'react-intl'; 5 | import { useDispatch, useSelector } from 'react-redux'; 6 | import { Form, FormItem } from '..'; 7 | import { api } from '@/apis'; 8 | import { FC } from '@/interfaces'; 9 | import { AppState } from '@/store'; 10 | import { setUserInfo } from '@/store/user/slice'; 11 | import { toLowerCamelCase } from '@/utils'; 12 | import { USER_NAME_REGEX } from '@/utils/regex'; 13 | 14 | /** 修改项目表单的属性接口 */ 15 | interface UserEditFormProps { 16 | className?: string; 17 | } 18 | /** 19 | * 修改项目表单 20 | * 从 redux 的 currentProject 中读取值,使用前必须先 21 | * dispatch(setCurrentProject({ id })); 22 | */ 23 | export const UserEditForm: FC = ({ className }) => { 24 | const { formatMessage } = useIntl(); // i18n 25 | const [form] = AntdForm.useForm(); 26 | const dispatch = useDispatch(); 27 | const [submitting, setSubmitting] = useState(false); 28 | const user = useSelector((state: AppState) => state.user); 29 | 30 | const handleFinish = (values: any) => { 31 | setSubmitting(true); 32 | api.user 33 | .editUser({ data: values }) 34 | .then((result) => { 35 | const data = toLowerCamelCase(result.data); 36 | // 修改成功 37 | dispatch(setUserInfo(data.user)); 38 | // 弹出提示 39 | message.success(data.message); 40 | }) 41 | .catch((error) => { 42 | error.default(form); 43 | }) 44 | .finally(() => setSubmitting(false)); 45 | }; 46 | 47 | return ( 48 |
57 |
63 | 76 | 77 | 78 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 92 | 93 | 94 |
95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /src/apis/insight.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 角色相关 API 3 | */ 4 | import { AxiosRequestConfig } from 'axios'; 5 | import { PaginationParams, request } from '.'; 6 | import { Role } from '../interfaces'; 7 | import { toUnderScoreCase } from '../utils'; 8 | import { APIProject } from './project'; 9 | import { APIUser } from './user'; 10 | import { APIOutput } from './output'; 11 | 12 | export type APIInsightUserProject = Omit; 13 | export interface APIInsightUser { 14 | user: APIUser; 15 | projects: APIInsightUserProject[]; 16 | count: number; 17 | } 18 | 19 | export type APIInsightProjectUser = APIUser & { role: Role }; 20 | export interface APIInsightProject { 21 | project: APIInsightUserProject; 22 | users: APIInsightProjectUser[]; 23 | outputs: APIOutput[]; 24 | count: number; 25 | } 26 | 27 | /** 获取团队人员洞悉的请求数据 */ 28 | interface GetTeamInsightUsersParams { 29 | word?: string; 30 | } 31 | /** 获取团队人员洞悉 */ 32 | const getTeamInsightUsers = ({ 33 | teamID, 34 | params, 35 | configs, 36 | }: { 37 | teamID: string; 38 | params?: GetTeamInsightUsersParams & PaginationParams; 39 | configs?: AxiosRequestConfig; 40 | }) => { 41 | return request({ 42 | method: 'GET', 43 | url: `/v1/teams/${teamID}/insight/users`, 44 | params: toUnderScoreCase(params), 45 | ...configs, 46 | }); 47 | }; 48 | 49 | /** 获取团队人员洞悉的项目列表 */ 50 | const getTeamInsightUserProjects = ({ 51 | teamID, 52 | userID, 53 | params, 54 | configs, 55 | }: { 56 | teamID: string; 57 | userID: string; 58 | params?: PaginationParams; 59 | configs?: AxiosRequestConfig; 60 | }) => { 61 | return request({ 62 | method: 'GET', 63 | url: `/v1/teams/${teamID}/insight/users/${userID}/projects`, 64 | params: toUnderScoreCase(params), 65 | ...configs, 66 | }); 67 | }; 68 | 69 | /** 获取团队项目洞悉的请求数据 */ 70 | interface GetTeamInsightProjectsParams { 71 | word?: string; 72 | } 73 | /** 获取团队项目洞悉 */ 74 | const getTeamInsightProjects = ({ 75 | teamID, 76 | params, 77 | configs, 78 | }: { 79 | teamID: string; 80 | params?: GetTeamInsightProjectsParams & PaginationParams; 81 | configs?: AxiosRequestConfig; 82 | }) => { 83 | return request({ 84 | method: 'GET', 85 | url: `/v1/teams/${teamID}/insight/projects`, 86 | params: toUnderScoreCase(params), 87 | ...configs, 88 | }); 89 | }; 90 | 91 | /** 获取团队项目洞悉人员列表 */ 92 | const getTeamInsightProjectUsers = ({ 93 | teamID, 94 | projectID, 95 | params, 96 | configs, 97 | }: { 98 | teamID: string; 99 | projectID: string; 100 | params?: PaginationParams; 101 | configs?: AxiosRequestConfig; 102 | }) => { 103 | return request({ 104 | method: 'GET', 105 | url: `/v1/teams/${teamID}/insight/projects/${projectID}/users`, 106 | params: toUnderScoreCase(params), 107 | ...configs, 108 | }); 109 | }; 110 | 111 | export default { 112 | getTeamInsightUsers, 113 | getTeamInsightUserProjects, 114 | getTeamInsightProjects, 115 | getTeamInsightProjectUsers, 116 | }; 117 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { initI18n } from './locales'; 2 | import { ConfigProvider } from 'antd'; 3 | import Bowser from 'bowser'; 4 | import 'pepjs'; // 指针事件垫片 5 | import React, { StrictMode } from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import { IntlProvider } from 'react-intl'; // i18n 8 | import { Provider } from 'react-redux'; 9 | import { BrowserRouter as Router } from 'react-router-dom'; 10 | import App from './App'; 11 | import './fontAwesome'; // Font Awesome 12 | import './index.css'; 13 | import store from './store'; 14 | import { setOSName, setPlatform, setRuntimeConfig } from './store/site/slice'; 15 | import { setUserToken } from './store/user/slice'; 16 | import { getToken } from './utils/cookie'; 17 | import { OSName, Platform } from './interfaces'; 18 | import { runtimeConfig } from './configs'; 19 | import { 20 | getDefaultHotKey, 21 | hotKeyInitialState, 22 | HotKeyState, 23 | setHotKey, 24 | } from './store/hotKey/slice'; 25 | import { loadHotKey } from './utils/storage'; 26 | import { createDebugLogger } from './utils/debug-logger'; 27 | const debugLogger = createDebugLogger('app'); 28 | 29 | // 时间插件 30 | if (false && process.env.NODE_ENV === 'development') { 31 | // 用于检测是什么导致 re-render 32 | const { default: whyDidYouRender } = await import( 33 | '@welldone-software/why-did-you-render' 34 | ); 35 | whyDidYouRender(React, { trackAllPureComponents: true }); 36 | } 37 | // 浏览器识别 38 | const browser = Bowser.getParser(window.navigator.userAgent); 39 | const platform = browser.getPlatformType() as Platform; 40 | const osName = browser.getOSName(true) as OSName; 41 | store.dispatch(setPlatform(platform)); 42 | store.dispatch(setOSName(osName)); 43 | // 恢复自定义快捷键 44 | for (const hotKeyName in hotKeyInitialState) { 45 | const name = hotKeyName as keyof HotKeyState; 46 | for (const index of [0, 1]) { 47 | const loadedHotKey = loadHotKey({ name, index }); 48 | if (loadedHotKey !== 'disabled') { 49 | let option; 50 | if (loadedHotKey) { 51 | option = loadedHotKey; 52 | } else { 53 | option = getDefaultHotKey({ name, index, osName }); 54 | } 55 | store.dispatch(setHotKey({ name, index, option })); 56 | } 57 | } 58 | } 59 | 60 | async function mountApp() { 61 | store.dispatch(setRuntimeConfig(await runtimeConfig)); 62 | const { intlMessages, locale, antdLocale, antdValidateMessages } = 63 | await initI18n; 64 | debugLogger('initial state', store.getState()); 65 | /** 66 | * Set user token from cookie 67 | */ 68 | const cookieToken = getToken(); 69 | if (cookieToken) { 70 | store.dispatch(setUserToken({ token: cookieToken, refresh: true })); 71 | } 72 | 73 | // 渲染 APP 74 | ReactDOM.render( 75 | 76 | 77 | 78 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | , 89 | document.getElementById('root'), 90 | ); 91 | } 92 | 93 | Promise.resolve().then(mountApp); 94 | -------------------------------------------------------------------------------- /src/store/team/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { UserTeam } from '../../interfaces'; 3 | 4 | export interface TeamsState { 5 | page: number; 6 | word: string; 7 | scrollTop: number; 8 | } 9 | export interface TeamState { 10 | readonly currentTeam?: UserTeam; 11 | readonly teams: UserTeam[]; 12 | readonly teamsState: TeamsState; 13 | } 14 | 15 | export const initialState: TeamState = { 16 | teams: [], 17 | teamsState: { 18 | page: 1, 19 | word: '', 20 | scrollTop: 0, 21 | }, 22 | }; 23 | const slice = createSlice({ 24 | name: 'team', 25 | initialState, 26 | reducers: { 27 | clearTeams(state) { 28 | state.teams = []; 29 | }, 30 | createTeam( 31 | state, 32 | action: PayloadAction<{ team: UserTeam; unshift?: boolean }>, 33 | ) { 34 | const { team, unshift = false } = action.payload; 35 | if (unshift) { 36 | state.teams.unshift(team); 37 | } else { 38 | state.teams.push(team); 39 | } 40 | }, 41 | editTeam(state, action: PayloadAction) { 42 | const index = state.teams.findIndex( 43 | (team) => team.id === action.payload.id, 44 | ); 45 | if (index > -1) { 46 | state.teams[index] = action.payload; 47 | } 48 | }, 49 | deleteTeam(state, action: PayloadAction<{ id: string }>) { 50 | const index = state.teams.findIndex( 51 | (team) => team.id === action.payload.id, 52 | ); 53 | if (index > -1) { 54 | state.teams.splice(index, 1); 55 | } 56 | }, 57 | /** 设置当前编辑/设置的团队 */ 58 | setCurrentTeam(state, action: PayloadAction) { 59 | state.currentTeam = action.payload; 60 | }, 61 | setCurrentTeamSaga(state, action: PayloadAction<{ id: string }>) { 62 | // saga:从服务器获取 Team 63 | }, 64 | setCurrentTeamInfo(state, action: PayloadAction>) { 65 | if (state.currentTeam) { 66 | let key: keyof typeof action.payload; 67 | for (key in action.payload) { 68 | state.currentTeam[key] = action.payload[key] as never; 69 | } 70 | const teamInList = state.teams.find( 71 | (team) => team.id === state.currentTeam?.id, 72 | ); 73 | if (teamInList) { 74 | let key: keyof typeof action.payload; 75 | for (key in action.payload) { 76 | teamInList[key] = action.payload[key] as never; 77 | } 78 | } 79 | } 80 | }, 81 | clearCurrentTeam(state) { 82 | state.currentTeam = undefined; 83 | }, 84 | setTeamsState(state, action: PayloadAction>) { 85 | state.teamsState = { ...state.teamsState, ...action.payload }; 86 | }, 87 | resetTeamsState(state) { 88 | state.teamsState = initialState.teamsState; 89 | }, 90 | }, 91 | }); 92 | 93 | export const { 94 | clearTeams, 95 | createTeam, 96 | editTeam, 97 | deleteTeam, 98 | setCurrentTeam, 99 | setCurrentTeamSaga, 100 | setCurrentTeamInfo, 101 | clearCurrentTeam, 102 | setTeamsState, 103 | resetTeamsState, 104 | } = slice.actions; 105 | export default slice.reducer; 106 | -------------------------------------------------------------------------------- /src/apis/application.ts: -------------------------------------------------------------------------------- 1 | import { GroupTypes } from './type'; 2 | import { request, PaginationParams } from '.'; 3 | import { toPlural, toUnderScoreCase } from '@/utils'; 4 | import { AxiosRequestConfig } from 'axios'; 5 | import { APIUser } from './user'; 6 | import { Role, UserTeam } from '@/interfaces'; 7 | import { APIProject } from './project'; 8 | import { ApplicationStatuses } from '@/constants'; 9 | 10 | export interface APIBaseApplication { 11 | id: string; 12 | user: APIUser; 13 | userRole: Role | null; 14 | groupRoles: Role[]; 15 | operator: APIUser | null; 16 | createTime: string; 17 | status: ApplicationStatuses; 18 | message: string; 19 | } 20 | export interface APITeamApplication extends APIBaseApplication { 21 | group: UserTeam; 22 | groupType: 'team'; 23 | } 24 | export interface APIProjectApplication extends APIBaseApplication { 25 | group: APIProject; 26 | groupType: 'project'; 27 | } 28 | export type APIApplication = APITeamApplication | APIProjectApplication; 29 | 30 | /** 获取团体申请列表的请求数据 */ 31 | interface GetApplicationsParams {} 32 | /** 获取团体申请列表 */ 33 | const getApplications = ({ 34 | groupType, 35 | groupID, 36 | params, 37 | configs, 38 | }: { 39 | groupType: GroupTypes; 40 | groupID: string; 41 | params: GetApplicationsParams & PaginationParams; 42 | configs?: AxiosRequestConfig; 43 | }) => { 44 | return request({ 45 | method: 'GET', 46 | url: `/v1/${toPlural(groupType)}/${groupID}/applications`, 47 | params: toUnderScoreCase(params), 48 | ...configs, 49 | }); 50 | }; 51 | 52 | /** 创建申请的请求数据 */ 53 | interface CreateApplicationData { 54 | message: string; 55 | } 56 | /** 创建申请 */ 57 | const createApplication = ({ 58 | groupType, 59 | groupID, 60 | data, 61 | configs, 62 | }: { 63 | groupType: GroupTypes; 64 | groupID: string; 65 | data: CreateApplicationData; 66 | configs?: AxiosRequestConfig; 67 | }) => { 68 | return request({ 69 | method: 'POST', 70 | url: `/v1/${toPlural(groupType)}/${groupID}/applications`, 71 | data: toUnderScoreCase(data), 72 | ...configs, 73 | }); 74 | }; 75 | 76 | /** 处理申请的请求数据 */ 77 | interface DealApplicationData { 78 | allow: boolean; 79 | } 80 | interface DealApplicationReturn { 81 | message: string; 82 | application: APIApplication; 83 | } 84 | /** 处理申请 */ 85 | const dealApplication = ({ 86 | applicationID, 87 | data, 88 | configs, 89 | }: { 90 | applicationID: string; 91 | data: DealApplicationData; 92 | configs?: AxiosRequestConfig; 93 | }) => { 94 | return request({ 95 | method: 'PATCH', 96 | url: `/v1/applications/${applicationID}`, 97 | data: toUnderScoreCase(data), 98 | ...configs, 99 | }); 100 | }; 101 | 102 | /** 删除申请 */ 103 | const deleteApplication = ({ 104 | applicationID, 105 | configs, 106 | }: { 107 | applicationID: string; 108 | configs?: AxiosRequestConfig; 109 | }) => { 110 | return request({ 111 | method: 'DELETE', 112 | url: `/v1/applications/${applicationID}`, 113 | ...configs, 114 | }); 115 | }; 116 | 117 | export default { 118 | getApplications, 119 | createApplication, 120 | dealApplication, 121 | deleteApplication, 122 | }; 123 | -------------------------------------------------------------------------------- /src/hooks/usePagination.ts: -------------------------------------------------------------------------------- 1 | import { Canceler } from 'axios'; 2 | import { set } from 'lodash-es'; 3 | import React, { useRef, useState } from 'react'; 4 | import { useDeepCompareEffect } from 'react-use'; 5 | import { BasicSuccessResult, resultTypes } from '@/apis'; 6 | import { toLowerCamelCase } from '@/utils'; 7 | import { getCancelToken } from '@/utils/api'; 8 | 9 | type RequestStatus = 'loading' | 'success' | 'failure'; 10 | interface UsePaginationParams { 11 | api: (params: APIParams) => Promise>; 12 | apiParams?: APIParams; 13 | defaultPage?: number; 14 | defaultLimit?: number; 15 | defaultItems?: T[]; 16 | } 17 | interface UsePaginationReturn { 18 | page: number; 19 | setPage: React.Dispatch>; 20 | limit: number; 21 | setLimit: React.Dispatch>; 22 | items: T[]; 23 | setItems: React.Dispatch>; 24 | total: number; 25 | setTotal: React.Dispatch>; 26 | status: RequestStatus; 27 | setStatus: React.Dispatch>; 28 | refresh: () => void; 29 | } 30 | export function usePagination({ 31 | api, 32 | apiParams = {} as APIParams, 33 | defaultPage = 1, 34 | defaultLimit = 50, 35 | defaultItems = [], 36 | }: UsePaginationParams): UsePaginationReturn { 37 | const [page, setPage] = useState(defaultPage); 38 | const [limit, setLimit] = useState(defaultLimit); 39 | const [total, setTotal] = useState(0); 40 | const [status, setStatus] = useState('loading'); 41 | const [refreshToken, setRefreshToken] = useState(''); 42 | const [items, setItems] = useState(defaultItems); 43 | const currentAPIParamsRef = useRef(apiParams); 44 | const cancelRef = useRef(); 45 | 46 | set(apiParams as any, 'params.page', page); 47 | set(apiParams as any, 'params.limit', limit); 48 | 49 | useDeepCompareEffect(() => { 50 | if (cancelRef.current) { 51 | cancelRef.current(); 52 | } 53 | const [cancelToken, cancel] = getCancelToken(); 54 | cancelRef.current = cancel; 55 | const configs = (apiParams as any).configs 56 | ? { ...(apiParams as any).configs, cancelToken } 57 | : { cancelToken }; 58 | 59 | currentAPIParamsRef.current = apiParams; 60 | 61 | setStatus('loading'); 62 | api({ ...apiParams, configs }) 63 | .then((result) => { 64 | if (apiParams === currentAPIParamsRef.current) { 65 | const data = toLowerCamelCase(result.data); 66 | setItems(data); 67 | setStatus('success'); 68 | setTotal(Number(result.headers['x-pagination-count'])); 69 | } 70 | }) 71 | .catch((error) => { 72 | // 如果是 cancel 的请求,则改变请求状态,因为肯定有下一个请求 73 | if (error.type !== resultTypes.CANCEL_FAILURE) { 74 | setStatus('failure'); 75 | } 76 | error.default(); 77 | }); 78 | }, [apiParams, refreshToken]); 79 | 80 | const refresh = () => { 81 | setRefreshToken(new Date().getTime().toString()); 82 | }; 83 | 84 | return { 85 | page, 86 | setPage, 87 | limit, 88 | setLimit, 89 | items, 90 | setItems, 91 | total, 92 | setTotal, 93 | status, 94 | setStatus, 95 | refresh, 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /src/components/shared/NavTabs.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import { Dropdown, MenuProps } from 'antd'; 3 | import classNames from 'classnames'; 4 | import React, { isValidElement } from 'react'; 5 | import { useSelector } from 'react-redux'; 6 | import { useMeasure } from 'react-use'; 7 | import { Icon } from '@/components'; 8 | import { FC } from '@/interfaces'; 9 | import { AppState } from '@/store'; 10 | import style from '@/style'; 11 | import { clickEffect } from '@/utils/style'; 12 | 13 | /** 标签栏的属性接口 */ 14 | interface NavTabsProps { 15 | className?: string; 16 | } 17 | 18 | /** 19 | * 标签栏 20 | */ 21 | export const NavTabs: FC = ({ className, children }) => { 22 | const platform = useSelector((state: AppState) => state.site.platform); 23 | const isMobile = platform === 'mobile'; 24 | const [wrapperRef, { width: wrapperWidth }] = useMeasure(); 25 | const [ref, { width }] = useMeasure(); 26 | 27 | const menuProps: MenuProps = { 28 | items: isValidElement(children) 29 | ? [ 30 | { 31 | label: children, 32 | key: 'single', 33 | }, 34 | ] 35 | : (children as React.ReactNodeArray).map((child, i) => ({ 36 | label: { child }, 37 | key: i, 38 | })), 39 | }; 40 | 41 | return ( 42 |
a:hover { 86 | color: ${style.primaryColor}; 87 | } 88 | `} 89 | ref={wrapperRef} 90 | > 91 |
92 | {children} 93 |
94 | {!isMobile && width >= wrapperWidth && ( 95 | 100 |
101 | 102 |
103 |
104 | )} 105 |
106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /src/components/shared/DebounceStatus.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | import classNames from 'classnames'; 3 | import { useIntl } from 'react-intl'; 4 | import { Icon } from '@/components'; 5 | import { FC, InputDebounceStatus } from '@/interfaces'; 6 | import style from '@/style'; 7 | 8 | /** 输入框防抖状态的属性接口 */ 9 | interface DebounceStatusProps { 10 | status?: InputDebounceStatus; 11 | tipVisible?: boolean; 12 | debouncingTip?: string; 13 | className?: string; 14 | } 15 | /** 16 | * 输入框防抖状态 17 | */ 18 | export const DebounceStatus: FC = ({ 19 | status, 20 | tipVisible = true, 21 | debouncingTip, 22 | className, 23 | } = {}) => { 24 | const { formatMessage } = useIntl(); 25 | 26 | if (!debouncingTip) { 27 | debouncingTip = formatMessage({ id: 'debounceStatus.debouncing' }); 28 | } 29 | let sign; 30 | let tip = ''; 31 | 32 | if (status === 'debouncing') { 33 | sign = ( 34 | 38 | ); 39 | tip = debouncingTip; 40 | } else if (status === 'saving') { 41 | sign = ( 42 | 47 | ); 48 | tip = formatMessage({ id: 'debounceStatus.saving' }); 49 | } else if (status === 'saveFailed') { 50 | sign = ( 51 | 55 | ); 56 | tip = formatMessage({ id: 'debounceStatus.saveFailed' }); 57 | } else if (status === 'saveSuccessful') { 58 | sign = ( 59 | 63 | ); 64 | tip = formatMessage({ id: 'debounceStatus.saveSuccessful' }); 65 | } 66 | 67 | return ( 68 |
108 | {sign} 109 | {tip && tipVisible && {tip}} 110 |
111 | ); 112 | }; 113 | --------------------------------------------------------------------------------