master
well 2025-01-24 14:26:30 +08:00
parent ba0825a019
commit 131db241a3
364 changed files with 3483 additions and 28439 deletions

15
.vscode/launch.json vendored 100644
View File

@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "debug",
"request": "launch",
"runtimeArgs": ["run-script", "debug"],
"autoAttachChildProcesses": true,
"console": "integratedTerminal",
"runtimeExecutable": "pnpm",
"skipFiles": ["<node_internals>/**"],
"type": "node"
}
]
}

View File

@ -1,28 +0,0 @@
dist
node_modules
pnpm-lock.yaml
package-lock.json
docker
Dockerfile*
LICENSE
yarn-error.log
.history
.dockerignore
.DS_Store
.eslintignore
.editorconfig
.gitignore
.prettierignore
.eslintcache
**/*.lock
**/*.png
**/*.eot
**/*.ttf
**/*.woff
**/*.svg
**/*.md
**/*.svg
**/*.ejs
**/*.html
**/*.png
**/*.toml

View File

@ -1,8 +0,0 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
parserOptions: {
project: './tsconfig.eslint.json',
},
extends: [require.resolve('@3rapp/core/eslint/react')],
};

26
apps/admin/.gitignore vendored
View File

@ -1,26 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,2 +0,0 @@
/** @format */
module.exports = require.resolve('@3rapp/core/prettier')

View File

@ -1,28 +0,0 @@
dist
node_modules
pnpm-lock.yaml
package-lock.json
docker
Dockerfile*
LICENSE
yarn-error.log
.history
.dockerignore
.DS_Store
.eslintignore
.editorconfig
.gitignore
.prettierignore
.eslintcache
**/*.lock
**/*.png
**/*.eot
**/*.ttf
**/*.woff
**/*.svg
**/*.md
**/*.svg
**/*.ejs
**/*.html
**/*.png
**/*.toml

View File

@ -1,28 +0,0 @@
dist
node_modules
pnpm-lock.yaml
package-lock.json
docker
Dockerfile*
LICENSE
yarn-error.log
.history
.dockerignore
.DS_Store
.eslintignore
.editorconfig
.gitignore
.prettierignore
.eslintcache
**/*.lock
**/*.png
**/*.eot
**/*.ttf
**/*.woff
**/*.svg
**/*.md
**/*.svg
**/*.ejs
**/*.html
**/*.png
**/*.toml

View File

@ -1,3 +0,0 @@
module.exports = {
extends: [require.resolve('@3rapp/core/stylelint')],
};

View File

@ -1,30 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,54 +0,0 @@
{
"name": "@3rapp/admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --no-clearScreen",
"build": "tsc && vite build",
"start": "pnpm preview",
"preview": "vite preview",
"lint": "pnpm lint:es && pnpm lint:style",
"lint:es": "eslint . --ext ts,tsx --fix --report-unused-disable-directives --max-warnings 0",
"lint:style": "stylelint \"**/*.css\" --fix --cache --cache-location node_modules/.cache/stylelint/"
},
"dependencies": {
"@3rapp/store": "workspace:*",
"@3rapp/utils": "workspace:*",
"@ant-design/cssinjs": "^1.21.1",
"antd": "^5.21.6",
"antd-style": "^3.7.1",
"axios": "^1.7.7",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"immer": "^10.1.1",
"lodash": "^4.17.21",
"lunar-typescript": "^1.7.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-use": "^17.5.1",
"utility-types": "^3.11.0",
"zustand": "^5.0.1"
},
"devDependencies": {
"@3rapp/core": "workspace:*",
"@3rapp/utils": "workspace:*",
"@types/lodash": "^4.17.13",
"@types/node": "^22.8.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"eslint": "^9.14.0",
"postcss-import": "^16.1.0",
"postcss-mixins": "^11.0.3",
"postcss-nested": "^7.0.2",
"postcss-nesting": "^13.0.1",
"prettier": "^3.3.3",
"stylelint": "^16.10.0",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +0,0 @@
module.exports = {
plugins: {
'postcss-import': {},
'postcss-nesting': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
'postcss-mixins': {},
},
};

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,38 +0,0 @@
import { pathResolve, deepMerge } from '@3rapp/utils';
import { ConfigEnv, UserConfig } from 'vite';
import { createPlugins } from './plugins';
import { Configure } from './types';
export const createConfig = (params: ConfigEnv, configure?: Configure): UserConfig => {
const isBuild = params.command === 'build';
return deepMerge<UserConfig, UserConfig>(
{
resolve: {
alias: {
'@': pathResolve('../src'),
},
},
css: {
modules: {
localsConvention: 'camelCaseOnly',
},
},
server: {
host: '0.0.0.0',
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:8080/api',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
cors: true,
},
plugins: createPlugins(isBuild),
},
typeof configure === 'function' ? configure(params, isBuild) : {},
'merge',
);
};

View File

@ -1,7 +0,0 @@
import react from '@vitejs/plugin-react';
import { PluginOption } from 'vite';
export function createPlugins(isBuild: boolean) {
const vitePlugins: (PluginOption | PluginOption[])[] = [react()];
return vitePlugins;
}

View File

@ -1,3 +0,0 @@
import { ConfigEnv, UserConfig } from 'vite';
export type Configure = (params: ConfigEnv, isBuild: boolean) => UserConfig;

View File

@ -1,6 +0,0 @@
import { clsx } from 'clsx';
export const app = clsx('tw-flex tw-flex-auto tw-flex-wrap tw-items-center tw-justify-center');
export const container = clsx(
' tw-bg-neutral-100/40 tw-shadow-black/20 tw-backdrop-blur-sm tw-shadow-md tw-rounded-md tw-p-5 tw-m-5 tw-min-w-[20rem]',
);

View File

@ -1,46 +0,0 @@
import { px2remTransformer, StyleProvider } from '@ant-design/cssinjs';
import { ConfigProvider, App as AntdApp } from 'antd';
import { FC, useMemo } from 'react';
import { app } from './app.css';
import { localeData } from './components/i18n/data';
import { useLocalStore } from './components/i18n/store';
import Setting from './components/setting';
import Theme from './components/theme';
import { useAntdAlgorithm } from './components/theme/hook';
const Wrapper: FC = () => {
const lang = useLocalStore((state) => state.lang);
const locale = useMemo(() => localeData[lang], [lang]);
const algorithm = useAntdAlgorithm();
return (
<ConfigProvider
locale={locale.antd}
theme={{
algorithm,
// 启用css变量
cssVar: true,
hashed: false,
token: {},
}}
>
<StyleProvider layer transformers={[px2remTransformer()]}>
<AntdApp>
<div className={app}>
<Setting />
</div>
</AntdApp>
</StyleProvider>
</ConfigProvider>
);
};
const App: FC = () => {
return (
<Theme>
<Wrapper />
</Theme>
);
};
export default App;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -1,8 +0,0 @@
export enum LangType {
EN_US = 'en_US',
ZH_CN = 'zh_CN',
}
/**
*
*/
export const langs: `${LangType}`[] = ['en_US', 'zh_CN'];

View File

@ -1,16 +0,0 @@
import enUS from 'antd/es/locale/en_US';
import zhCN from 'antd/es/locale/zh_CN';
import { LangType } from './constants';
import { LocaleItem } from './types';
export const localeData: Record<`${LangType}`, LocaleItem> = {
en_US: {
label: '🇺🇸 english(US)',
antd: enUS,
},
zh_CN: {
label: '🇨🇳 简体中文',
antd: zhCN,
},
};

View File

@ -1,13 +0,0 @@
import { isNil } from 'lodash';
import { FC, ReactNode } from 'react';
import { LangType } from './constants';
import { useLocalStore } from './store';
const Locale: FC<{ children?: ReactNode } & { lang: `${LangType}` }> = ({ children, lang }) => {
const changeLang = useLocalStore((state) => state.changeLang);
!isNil(changeLang) && changeLang(lang);
return <>{children}</>;
};
export default Locale;

View File

@ -1,23 +0,0 @@
import { createPersistStore } from '@3rapp/store';
import { isNil } from 'lodash';
import { LangType, langs } from './constants';
export const useLocalStore = createPersistStore<{
lang: `${LangType}`;
changeLang: (name: `${LangType}`) => void;
}>(
(set) => ({
lang: langs[0],
changeLang: (name) => {
set((state) => {
const item = langs.find((lang) => lang === name);
!isNil(item) && (state.lang = item);
});
},
}),
{
name: 'local',
},
);

View File

@ -1,7 +0,0 @@
// antd/es/locale es6版本 antd/lib/locale es5版本
import { Locale } from 'antd/es/locale';
export interface LocaleItem {
label: string;
antd: Locale;
}

View File

@ -1,89 +0,0 @@
import { Col, Radio, Row, Select } from 'antd';
import { Dayjs } from 'dayjs';
import { Lunar } from 'lunar-typescript';
import { FC } from 'react';
const getYearLabel = (year: number) => {
const d = Lunar.fromDate(new Date(year + 1, 0));
return `${d.getYearInChinese()}年(${d.getYearInGanZhi()}${d.getYearShengXiao()}年)`;
};
const getMonthLabel = (month: number, value: Dayjs) => {
const d = Lunar.fromDate(new Date(value.year(), month));
const lunar = d.getMonthInChinese();
return `${month + 1}月(${lunar}月)`;
};
interface CustomHeaderProps {
value: Dayjs;
type: string;
onChange: (date: Dayjs) => void;
onTypeChange: (e: any) => void;
}
const CustomHeader: FC<CustomHeaderProps> = ({ value, type, onChange, onTypeChange }) => {
const start = 0;
const end = 12;
const monthOptions = [];
let current = value.clone();
const localeDate = (value as any).localeData();
const months = [];
for (let i = 0; i < 12; i++) {
current = current.month(i);
months.push(localeDate.monthsShort(current));
}
for (let i = start; i < end; i++) {
monthOptions.push({
label: getMonthLabel(i, value),
value: i,
});
}
const year = value.year();
const month = value.month();
const options = [];
for (let i = year - 10; i < year + 10; i += 1) {
options.push({
label: getYearLabel(i),
value: i,
});
}
return (
<Row justify="end" gutter={8} style={{ padding: 8 }}>
<Col>
<Select
size="small"
className="my-year-select"
value={year}
options={options}
onChange={(newYear) => {
const now = value.clone().year(newYear);
onChange(now);
}}
/>
</Col>
<Col>
<Select
size="small"
value={month}
options={monthOptions}
onChange={(newMonth) => {
const now = value.clone().month(newMonth);
onChange(now);
}}
/>
</Col>
<Col>
<Radio.Group
size="small"
onChange={(e) => onTypeChange(e.target.value)}
value={type}
>
<Radio.Button value="month"></Radio.Button>
<Radio.Button value="year"></Radio.Button>
</Radio.Group>
</Col>
</Row>
);
};
export default CustomHeader;

View File

@ -1,235 +0,0 @@
/* eslint-disable react/no-unstable-nested-components */
import { Calendar, CalendarProps, Col, Row, Select, Switch, Radio } from 'antd';
import classNames from 'classnames';
import dayjs, { Dayjs } from 'dayjs';
import { debounce } from 'lodash';
import { HolidayUtil, Lunar } from 'lunar-typescript';
import { cloneElement, useCallback, useContext, useState } from 'react';
import { useStore } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { container } from '@/app.css';
import { langs } from '../i18n/constants';
import { localeData as langeLocalData } from '../i18n/data';
import { useLocalStore } from '../i18n/store';
import { ThemeContext } from '../theme/hook';
import { useStyle } from './settingStore';
const ThemeSetting: React.FC = () => {
const themeStore = useContext(ThemeContext);
// const { mode, compact } = themeStore.getState();
const { mode, compact } = useStore(themeStore, (state) => ({
mode: state.mode,
compact: state.compact,
}));
const dispatch = useShallow(themeStore((state) => state.dispatch));
const toggleMode = useCallback(
debounce(() => dispatch({ type: 'toggle_mode' }), 100, {}),
[],
);
const toggleCompact = useCallback(
debounce(() => dispatch({ type: 'toggle_compact' }), 100, {}),
[],
);
return (
<>
<Switch
checkedChildren="🌛"
unCheckedChildren="☀️"
onChange={toggleMode}
checked={mode === 'dark'}
defaultChecked={mode === 'dark'}
/>
<Switch
checkedChildren="紧凑"
unCheckedChildren="正常"
onChange={toggleCompact}
checked={compact}
defaultChecked={compact}
/>
</>
);
};
export const LocaleSetting: React.FC = () => {
const locale = useLocalStore((state) => state.lang);
const changeLocale = useLocalStore((state) => state.changeLang);
return (
<Select defaultValue={locale} style={{ width: 120 }} onChange={changeLocale}>
{langs.map((name) => (
<Select.Option key={name} value={name}>
{langeLocalData[name].label}
</Select.Option>
))}
</Select>
);
};
const Setting: React.FC = () => {
const { styles } = useStyle({ test: true });
const [selectDate, setSelectDate] = useState<Dayjs>(dayjs());
const [panelDateDate, setPanelDate] = useState<Dayjs>(dayjs());
const onPanelChange = (value: Dayjs, _mode: CalendarProps<Dayjs>['mode']) => {
setPanelDate(value);
};
const onDateChange: CalendarProps<Dayjs>['onSelect'] = (value, selectInfo) => {
if (selectInfo.source === 'date') {
setSelectDate(value);
}
};
const cellRender: CalendarProps<Dayjs>['fullCellRender'] = (date, info) => {
const d = Lunar.fromDate(date.toDate());
const lunar = d.getDayInChinese();
const solarTerm = d.getJieQi();
const isWeekend = date.day() === 6 || date.day() === 0;
const h = HolidayUtil.getHoliday(date.get('year'), date.get('month') + 1, date.get('date'));
const displayHoliday = h?.getTarget() === h?.getDay() ? h?.getName() : undefined;
if (info.type === 'date') {
return cloneElement(info.originNode, {
...info.originNode.props,
className: classNames(styles.dateCell, {
[styles.current]: selectDate.isSame(date, 'date'),
[styles.today]: date.isSame(dayjs(), 'date'),
}),
children: (
<div className={styles.text}>
<span
className={classNames({
[styles.weekend]: isWeekend,
gray: !panelDateDate.isSame(date, 'month'),
})}
>
{date.get('date')}
</span>
{info.type === 'date' && (
<div className={styles.lunar}>
{displayHoliday || solarTerm || lunar}
</div>
)}
</div>
),
});
}
if (info.type === 'month') {
// Due to the fact that a solar month is part of the lunar month X and part of the lunar month X+1,
// when rendering a month, always take X as the lunar month of the month
const d2 = Lunar.fromDate(new Date(date.get('year'), date.get('month')));
const month = d2.getMonthInChinese();
return (
<div
className={classNames(styles.monthCell, {
[styles.monthCellCurrent]: selectDate.isSame(date, 'month'),
})}
>
{date.get('month') + 1}{month}
</div>
);
}
return null;
};
const getYearLabel = (year: number) => {
const d = Lunar.fromDate(new Date(year + 1, 0));
return `${d.getYearInChinese()}年(${d.getYearInGanZhi()}${d.getYearShengXiao()}年)`;
};
const getMonthLabel = (month: number, value: Dayjs) => {
const d = Lunar.fromDate(new Date(value.year(), month));
const lunar = d.getMonthInChinese();
return `${month + 1}月(${lunar}月)`;
};
return (
<div className={container}>
<h2 className="tw-text-center">Setting Demo</h2>
<div className="tw-flex tw-items-center tw-flex-col">
<div className="tw-flex-auto tw-mb-5">
<ThemeSetting />
</div>
<div className="tw-flex-auto tw-mb-5">
<LocaleSetting />
</div>
<div className={styles.wrapper}>
<Calendar
fullCellRender={cellRender}
fullscreen={false}
onSelect={onDateChange}
onPanelChange={onPanelChange}
headerRender={({ value, type, onChange, onTypeChange }) => {
const start = 0;
const end = 12;
const monthOptions = [];
let current = value.clone();
const localeData = (value as any).localeData();
const months = [];
for (let i = 0; i < 12; i++) {
current = current.month(i);
months.push(localeData.monthsShort(current));
}
for (let i = start; i < end; i++) {
monthOptions.push({
label: getMonthLabel(i, value),
value: i,
});
}
const year = value.year();
const month = value.month();
const options = [];
for (let i = year - 10; i < year + 10; i += 1) {
options.push({
label: getYearLabel(i),
value: i,
});
}
return (
<Row justify="end" gutter={8} style={{ padding: 8 }}>
<Col>
<Select
size="small"
dropdownMatchSelectWidth={false}
className="my-year-select"
value={year}
options={options}
onChange={(newYear) => {
const now = value.clone().year(newYear);
onChange(now);
}}
/>
</Col>
<Col>
<Select
size="small"
dropdownMatchSelectWidth={false}
value={month}
options={monthOptions}
onChange={(newMonth) => {
const now = value.clone().month(newMonth);
onChange(now);
}}
/>
</Col>
<Col>
<Radio.Group
size="small"
onChange={(e) => onTypeChange(e.target.value)}
value={type}
>
<Radio.Button value="month"></Radio.Button>
<Radio.Button value="year"></Radio.Button>
</Radio.Group>
</Col>
</Row>
);
}}
/>
</div>
</div>
</div>
);
};
export default Setting;

View File

@ -1,85 +0,0 @@
import { createStyles } from 'antd-style';
export const useStyle = createStyles(({ token, css, cx }) => {
const lunar = css`
color: ${token.colorTextTertiary};
font-size: ${token.fontSizeSM}px;
`;
return {
wrapper: css`
width: 450px;
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusOuter};
padding: 5px;
`,
dateCell: css`
position: relative;
&:before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
max-width: 40px;
max-height: 40px;
background: transparent;
transition: background 300ms;
border-radius: ${token.borderRadiusOuter}px;
border: 1px solid transparent;
box-sizing: border-box;
}
&:hover:before {
background: rgba(0, 0, 0, 0.04);
}
`,
today: css`
&:before {
border: 1px solid ${token.colorPrimary};
}
`,
text: css`
position: relative;
z-index: 1;
`,
lunar,
current: css`
color: ${token.colorTextLightSolid};
&:before {
background: ${token.colorPrimary};
}
&:hover:before {
background: ${token.colorPrimary};
opacity: 0.8;
}
.${cx(lunar)} {
color: ${token.colorTextLightSolid};
opacity: 0.9;
}
`,
monthCell: css`
width: 120px;
color: ${token.colorTextBase};
border-radius: ${token.borderRadiusOuter}px;
padding: 5px 0;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
`,
monthCellCurrent: css`
color: ${token.colorTextLightSolid};
background: ${token.colorPrimary};
&:hover {
background: ${token.colorPrimary};
opacity: 0.8;
}
`,
weekend: css`
color: ${token.colorError};
&.gray {
opacity: 0.4;
}
`,
};
});

View File

@ -1,31 +0,0 @@
import { ThemeOptions } from './types';
/**
*
*/
export enum ThemeMode {
LIGHT = 'light',
DARK = 'dark',
}
/**
*
*/
export enum ThemeActions {
// 切换主题黑亮
CHANGE_MODE = 'change_mode',
// 反转主题黑亮
TOOGLE_MODE = 'toggle_mode',
// 切换紧凑主题
CHANGE_COMPACT = 'change_compact',
// 反转紧凑主题
TOOGLE_COMPACT = 'toggle_compact',
}
/**
*
*/
export const defaultThemeOptions: ThemeOptions = {
mode: 'light',
compact: false,
};

View File

@ -1,65 +0,0 @@
import { theme } from 'antd';
import { debounce } from 'lodash';
import { createContext, useCallback, useContext, useMemo } from 'react';
import { useStore } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { ThemeMode } from './constants';
import { ThemeState, ThemeStoreType } from './types';
export const ThemeContext = createContext<ThemeStoreType | null>(null);
// const themeStore = useContext(ThemeContext);
export function useThemeStore() {
const store = useContext(ThemeContext);
if (!store) throw new Error('Missing Theme Component in the tree');
return store;
}
export function useThemeState<T>(selector: (state: ThemeState) => T): T {
const store = useThemeStore();
return useStore(store, selector);
}
// const { mode, compact } = themeStore.getState();
export const useTheme = () =>
useThemeState((state) => ({ mode: state.mode, compact: state.compact }));
/**
* Antd
*/
export const useAntdAlgorithm = () => {
const { mode, compact } = useTheme();
return useMemo(() => {
const result = [compact ? theme.compactAlgorithm : theme.defaultAlgorithm];
if (mode === 'dark') result.push(theme.darkAlgorithm);
return result;
}, [mode, compact]);
};
/**
*
*/
export const useThemeActions = () => {
const dispatch = useShallow(useThemeState((state) => state.dispatch));
return {
changeMode: useCallback(
// 防抖限制,防止快速操作
debounce(
(v: `${ThemeMode}`) => () => dispatch({ type: 'change_mode', value: v }),
100,
{},
),
[],
),
toggleMode: useCallback(
debounce(() => dispatch({ type: 'toggle_mode' }), 100, {}),
[],
),
changeCompact: useCallback(
(v: boolean) => dispatch({ type: 'change_compact', value: v }),
[],
),
toggleCompact: useCallback(() => dispatch({ type: 'toggle_compact' }), []),
};
};

View File

@ -1,54 +0,0 @@
import { isNil } from 'lodash';
import { FC, PropsWithChildren, ReactNode, useContext, useRef } from 'react';
import { useLifecycles } from 'react-use';
import { ThemeContext } from './hook';
import { ThemeStoreType, createThemeStore } from './store';
import { ThemeOptions } from './types';
// 主题组件将主题配置存储在context中
const Theme: FC<PropsWithChildren<Partial<ThemeOptions>>> = ({ children, ...props }) => {
// 使用ref 持久化储存store
const storeRef = useRef<ThemeStoreType | null>(null);
if (!storeRef.current) {
storeRef.current = createThemeStore(props);
}
return (
<ThemeContext.Provider value={storeRef.current}>
<ThemeSubscriber>{children}</ThemeSubscriber>
</ThemeContext.Provider>
);
};
const ThemeSubscriber: FC<{ children: ReactNode }> = ({ children }) => {
const store = useContext(ThemeContext);
if (!store) {
throw new Error('ThemeSubscriber must be used within a Theme');
}
let unSub: () => void;
useLifecycles(
() => {
store.subscribe(
(state) => state.mode,
(m) => {
const body = document.getElementsByTagName('body');
if (body.length) {
body[0].classList.remove('light');
body[0].classList.remove('dark');
body[0].classList.add(m === 'dark' ? 'dark' : 'light');
}
},
{
fireImmediately: true,
},
);
},
() => {
if (!isNil(unSub)) unSub();
},
);
return <>{children}</>;
};
export default Theme;

View File

@ -1,36 +0,0 @@
import { createPersistReduxStore } from '@3rapp/store';
import { produce } from 'immer';
import { Reducer } from 'react';
import { ThemeActions, defaultThemeOptions } from './constants';
import { ThemeDispatchs, ThemeOptions } from './types';
// 生成dispatch的reducer 没有类型会在使用的时候报错
const ThemeReducer: Reducer<ThemeOptions, ThemeDispatchs> = produce((draft, action) => {
switch (action.type) {
case ThemeActions.CHANGE_MODE:
draft.mode = action.value;
break;
case ThemeActions.TOOGLE_MODE:
draft.mode = draft.mode === 'light' ? 'dark' : 'light';
break;
case ThemeActions.CHANGE_COMPACT:
draft.compact = action.value;
break;
case ThemeActions.TOOGLE_COMPACT:
draft.compact = !draft.compact;
break;
default:
break;
}
});
export const createThemeStore = (initialState: Partial<ThemeOptions> = {}) =>
createPersistReduxStore(
ThemeReducer,
{ ...initialState, ...defaultThemeOptions },
// partialize 用于过滤需要存储的数据
{ name: 'theme', partialize: (state) => ({ mode: state.mode, compact: state.compact }) },
{ name: 'theme' },
);
// 使用useref持久化储存store 以便在组件生命周期内共享
export type ThemeStoreType = ReturnType<typeof createThemeStore>;

View File

@ -1,31 +0,0 @@
import { ThemeActions, ThemeMode } from './constants';
import { createThemeStore } from './store';
/**
*
*/
export type ThemeOptions = {
mode: `${ThemeMode}`;
compact: boolean;
};
/**
* Redux dispatch
*/
export type ThemeDispatchs =
| { type: `${ThemeActions.CHANGE_MODE}`; value: `${ThemeMode}` }
| { type: `${ThemeActions.TOOGLE_MODE}` }
| { type: `${ThemeActions.CHANGE_COMPACT}`; value: boolean }
| { type: `${ThemeActions.TOOGLE_COMPACT}` };
/**
*
*/
export type ThemeState = ThemeOptions & {
dispatch: (action: ThemeDispatchs) => ThemeDispatchs;
};
/**
*
*/
export type ThemeStoreType = ReturnType<typeof createThemeStore>;

View File

@ -1,11 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './app.tsx';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@ -1,14 +0,0 @@
html,
body,
#root,
.ant-app {
@apply tw-h-[100vh] tw-w-full tw-flex tw-p-0 tw-m-0;
}
body {
@apply tw-bg-[url(@/assets/images/bg-light.png)] tw-bg-cover;
}
body.dark {
@apply tw-bg-[url(@/assets/images/bg-dark.png)] tw-bg-cover;
}

View File

@ -1,8 +0,0 @@
@import './vars.css';
@import 'tailwindcss/base';
@import './tailwind/base.css';
@import 'tailwindcss/components';
@import './tailwind/components.css';
@import 'tailwindcss/utilities';
@import './tailwind/utilities.css';
@import './app.css';

View File

@ -1,15 +0,0 @@
@layer tailwind-base , antd;
@layer tailwind-base {
@tailwind base;
}
@layer base {
html {
font-family: var(--font-family-standard);
}
body {
font-size: var(--font-size-base);
}
}

View File

@ -1,2 +0,0 @@
@layer components {
}

View File

@ -1,2 +0,0 @@
@layer utilities {
}

View File

@ -1,7 +0,0 @@
:root {
--font-size-base: 0.875rem;
--font-family-standard: 'Source Sans Pro', 'Hiragino Sans GB', 'Microsoft Yahei', simsun,
helvetica, arial, sans-serif, monospace;
--font-family-firacode: 'Fira Code', 'Source Sans Pro', 'Hiragino Sans GB', 'Microsoft Yahei',
simsun, helvetica, arial, sans-serif, monospace;
}

View File

@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@ -1,23 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
prefix: 'tw-',
darkMode: 'class',
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
screens: {
xs: '480px',
sm: '576px',
md: '768px',
lg: '992px',
xl: '1200px',
'2xl': '1400px',
},
extend: {
fontFamily: {
standard: 'var(--font-family-standard)',
firacode: 'var(--font-family-firacode)',
},
},
},
plugins: [],
};

View File

@ -1,5 +0,0 @@
{
"extends": "./tsconfig.json",
"include": ["./src", "./test", "./typings", "./scripts", "**.cjs", "**.ts"],
"exclude": ["node_modules"]
}

View File

@ -1,12 +0,0 @@
{
"extends": "@3rapp/core/tsconfig/react.json",
"compilerOptions": {
"outDir": "./dist",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts", "./scripts"]
}

View File

@ -1,8 +0,0 @@
import { ConfigEnv, UserConfig, defineConfig } from 'vite';
import { createConfig } from './scripts';
export default defineConfig((params: ConfigEnv): UserConfig => {
const config = createConfig(params);
return config;
});

View File

@ -1,12 +0,0 @@
APP_NAME=3rapp
APP_HOST=127.0.0.1
APP_PORT=3000
APP_SSL=false
APP_TIMEZONE=Asia/Shanghai
APP_LOCALE=zh_CN
APP_FALLBACK_LOCALE=en
DB_HOST=172.17.0.3
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=123456
DB_NAME=nestapp

View File

@ -1,25 +0,0 @@
dist
node_modules
pnpm-lock.yaml
package-lock.json
yarn.lock
docker
Dockerfile*
LICENSE
yarn-error.log
.history
.dockerignore
.DS_Store
.eslintignore
.editorconfig
.gitignore
.prettierignore
.eslintcache
*.lock
**/*.svg
**/*.md
**/*.svg
**/*.ejs
**/*.html
**/*.png
**/*.toml

View File

@ -1,8 +0,0 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
parserOptions: {
project: 'tsconfig.eslint.json',
},
extends: [require.resolve('@3rapp/core/eslint/node')],
};

56
apps/api/.gitignore vendored
View File

@ -1,56 +0,0 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

View File

@ -1,25 +0,0 @@
dist
node_modules
pnpm-lock.yaml
package-lock.json
yarn.lock
docker
Dockerfile*
LICENSE
yarn-error.log
.history
.dockerignore
.DS_Store
.eslintignore
.editorconfig
.gitignore
.prettierignore
.eslintcache
*.lock
**/*.svg
**/*.md
**/*.svg
**/*.ejs
**/*.html
**/*.png
**/*.toml

View File

@ -1 +0,0 @@
module.exports = require.resolve('@3rapp/core/prettier');

View File

@ -1,73 +0,0 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ pnpm install
```
## Running the app
```bash
# development
$ pnpm run start
# watch mode
$ pnpm run start:dev
# production mode
$ pnpm run start:prod
```
## Test
```bash
# unit tests
$ pnpm run test
# e2e tests
$ pnpm run test:e2e
# test coverage
$ pnpm run test:cov
```
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).

View File

@ -1,5 +0,0 @@
{
"app": {
"name": "NomcqhSli"
}
}

View File

@ -1,19 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": ["assets/**/*"],
"typeCheck": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"introspectComments": true,
"controllerKeyOfComment": "summary"
}
}
]
}
}

View File

@ -1,111 +0,0 @@
{
"name": "@3rapp/api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"start1:dev": "nest start --watch",
"cli": "bun --bun src/console/bin.ts",
"dev": "cross-env NODE_ENV=development pnpm cli start -w",
"prod": "cross-env NODE_ENV=production pnpm cli start -w -p",
"prodjs": "cross-env NODE_ENV=production pnpm cli start -w -p --no-ts",
"reload": "cross-env NODE_ENV=production pnpm cli start -r",
"build": "cross-env NODE_ENV=production pnpm cli build",
"start": "cross-env NODE_ENV=development nest start",
"start:dev": "cross-env NODE_ENV=development nest start --watch",
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
"start:prod": "cross-env NODE_ENV=production node dist/main"
},
"dependencies": {
"@3rapp/utils": "workspace:*",
"@faker-js/faker": "^9.1.0",
"@fastify/static": "^8.0.2",
"@nestjs/common": "^10.4.6",
"@nestjs/core": "^10.4.6",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.4.6",
"@nestjs/swagger": "^8.0.1",
"@nestjs/typeorm": "^10.0.2",
"bcrypt": "^5.1.1",
"chalk": "4",
"chokidar": "^4.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dayjs": "^1.11.13",
"dotenv": "^16.4.5",
"fastify": "^5.1.0",
"find-up": "5",
"fs-extra": "^11.2.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"meilisearch": "^0.45.0",
"mysql2": "^3.11.3",
"ora": "5",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pm2": "^5.4.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sanitize-html": "^2.13.1",
"typeorm": "^0.3.20",
"utility-types": "^3.11.0",
"uuid": "^11.0.2",
"validator": "^13.12.0",
"yaml": "^2.6.0",
"yargs": "^17.7.2"
},
"devDependencies": {
"@3rapp/core": "workspace:*",
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.6",
"@types/bcrypt": "^5.0.2",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.7",
"@types/lodash": "^4.17.13",
"@types/node": "^22.8.6",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/sanitize-html": "^2.13.0",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"@types/validator": "^13.12.2",
"@types/yargs": "^17.0.33",
"bun": "^1.1.33",
"bun-types": "^1.1.33",
"cross-env": "^7.0.3",
"eslint": "^9.14.0",
"jest": "^29.7.0",
"nodemon": "^3.1.7",
"prettier": "^3.3.3",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +0,0 @@
import { Configure } from '@/modules/config/configure';
import { ConfigureFactory } from '@/modules/config/types';
import * as contentControllers from '@/modules/content/controllers';
import { ApiConfig, VersionOption } from '@/modules/restful/types';
export const v1 = async (configure: Configure): Promise<VersionOption> => {
return {
routes: [
{
name: 'app',
path: '/',
controllers: [],
doc: {
// title: '应用接口',
description:
'3R教室《Nestjs实战开发》课程应用的客户端接口应用名称随机自动生成',
tags: [
{ name: '分类操作', description: '对分类进行CRUD操作' },
{ name: '标签操作', description: '对标签进行CRUD操作' },
{ name: '文章操作', description: '对文章进行CRUD操作' },
{ name: '评论操作', description: '对评论进行CRUD操作' },
],
},
children: [
{
name: 'app.content',
path: 'content',
controllers: Object.values(contentControllers),
},
],
},
],
};
};
export const api: ConfigureFactory<ApiConfig> = {
register: async (configure: Configure) => ({
title: configure.env.get(
'API_TITLE',
`${await configure.get<string>('app.name')} app的API接口`,
),
// description: configure.env.get('API_DESCRIPTION', '3R教室TS全栈开发教程'),
auth: true,
docuri: 'api/docs',
default: configure.env.get('API_DEFAULT_VERSION', 'v1'),
enabled: [],
versions: { v1: await v1(configure) },
}),
};

View File

@ -1,10 +0,0 @@
import { toNumber } from 'lodash';
import { createAppConfig } from '@/modules/core/config';
export const app = createAppConfig((configure) => {
return {
port: configure.env.get('APP_PORT', (v) => toNumber(v), 8080),
prefix: configure.env.get('APP_PREFIX', '/apis'),
};
});

View File

@ -1,5 +0,0 @@
import { ContentConfig } from '@/modules/content/types';
export const content = (): ContentConfig => ({
searchType: 'meilisearch',
});

View File

@ -1,17 +0,0 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { Configure } from '@/modules/config/configure';
export const database = (configure: Configure): TypeOrmModuleOptions => {
return {
type: 'mysql',
host: configure.env.get('DB_HOST'),
port: configure.env.get('DB_PORT', 3306),
username: configure.env.get('DB_USER', 'root'),
password: configure.env.get('DB_PASSWORD', '123456'),
database: configure.env.get('DB_NAME', 'nestapp'),
synchronize: true,
autoLoadEntities: true,
subscribers: [],
};
};

View File

@ -1,6 +0,0 @@
export * from './app.config';
export * from './database.config';
export * from './content.config';
export * from './meili.config';
export * from './api.config';
export * from './user.config';

View File

@ -1,8 +0,0 @@
import { MelliConfig } from '@/modules/meilisearch/types';
export const meili = (): MelliConfig => [
{
name: 'default',
host: 'http://192.168.31.43:7700/',
},
];

View File

@ -1,3 +0,0 @@
import { createUserConfig } from '@/modules/user/config';
export const user = createUserConfig(() => ({}));

View File

@ -1,36 +0,0 @@
// import { NestFactory } from '@nestjs/core';
// import { NestFastifyApplication, FastifyAdapter } from '@nestjs/platform-fastify';
// import * as configs from '@/configs';
// import { ContentModule } from './modules/content/content.module';
// import { CreateOptions } from './modules/core/core.types';
// import { DatabaseModule } from './modules/database/database.module';
// import { meiliForRoot } from './modules/meilisearch/melli.module';
// import { Restful } from './modules/restful/restful';
// import { RestfulModuleForRoot } from './modules/restful/restful.module';
// export const createData: CreateOptions = {
// config: { factories: configs as any, storage: { enabled: true } },
// imports: async (configure) => [
// DatabaseModule.forRoot(configure),
// meiliForRoot(configure),
// RestfulModuleForRoot(configure),
// ContentModule.forRoot(configure),
// ],
// globals: {},
// builder: async ({ configure, BootModule }) => {
// const container = await NestFactory.create<NestFastifyApplication>(
// BootModule,
// new FastifyAdapter(),
// {
// cors: true,
// logger: ['error', 'warn'],
// },
// );
// // 在此处构建swagger文档
// const restful = container.get(Restful);
// await restful.factoryDocs(container);
// return container;
// },
// };

View File

@ -1,4 +0,0 @@
import { createApp, startApp } from './modules/core/helpers/app';
import { createOptions } from './options';
startApp(createApp(createOptions));

View File

@ -1,17 +0,0 @@
import { Configure } from './configure';
export class ConfigModule {
static forRoot(configure: Configure) {
return {
global: true,
exports: [Configure],
module: ConfigModule,
providers: [
{
provide: Configure,
useValue: configure,
},
],
};
}
}

View File

@ -1,219 +0,0 @@
import { deepMerge, isAsyncFn } from '@3rapp/utils';
import { get, has, isArray, isFunction, isNil, isObject, omit, set } from 'lodash';
import { Env } from './env';
import { ConfigStorage } from './storage';
import { ConfigStorageOption, ConfigureFactory, ConfigureRegister } from './types';
interface SetStorageOption {
/**
*
*/
enabled?: boolean;
/**
* config.yml
*/
change?: boolean;
}
/**
*
*/
export class Configure {
/**
*
*/
protected inited = false;
/**
*
*/
protected factories: Record<string, ConfigureFactory<Record<string, any>>> = {};
/**
*
*/
protected config: Record<string, any> = {};
/**
*
*/
protected _env: Env;
/**
*
*/
protected storage: ConfigStorage;
/**
*
* @param configs
* @param option
*/
async initilize(
configs: Record<string, ConfigureFactory<Record<string, any>>> = {},
option: ConfigStorageOption = {},
) {
if (this.inited) return this;
this._env = new Env();
await this._env.load();
const { enabled, filePath } = option;
this.storage = new ConfigStorage(enabled, filePath);
for (const key in configs) {
this.add(key, configs[key]);
}
await this.sync();
this.inited = true;
return this;
}
get env() {
return this._env;
}
/**
*
*/
all() {
return this.config;
}
/**
*
* @param key
*/
has(key: string) {
return has(this.config, key);
}
/**
*
* @param key
* @param defaultValue
*/
async get<T>(key: string, defaultValue?: T): Promise<T> {
if (!has(this.config, key) && defaultValue === undefined && has(this.factories, key)) {
await this.syncFactory(key);
return this.get(key, defaultValue);
}
return get(this.config, key, defaultValue) as T;
}
/**
*
* storage,config.yml
* storage,enabled,changeconfig.yml
* @param key
* @param value
* @param storage boolean: ; : enabled , change config.yml
* @param append true,,使,
*/
set<T>(key: string, value: T, storage: SetStorageOption | boolean = false, append = false) {
const storageEnable = typeof storage === 'boolean' ? storage : !!storage.enabled;
const storageChange = typeof storage === 'boolean' ? false : !!storage.change;
if (storageEnable && this.storage.enabled) {
this.changeStorageValue(key, value, storageChange, append);
} else {
set(this.config, key, value);
}
return this;
}
/**
*
* @param key
* @param register
*/
add<T extends Record<string, any>>(
key: string,
register: ConfigureRegister<T> | ConfigureFactory<T>,
) {
if (!isFunction(register) && 'register' in register) {
this.factories[key] = register as any;
} else if (isFunction(register)) {
this.factories[key] = { register };
}
return this;
}
/**
*
* ,
* @param key
*/
remove(key: string) {
if (has(this.storage.config, key) && this.storage.enabled) {
this.storage.remove(key);
this.config = deepMerge(this.config, this.storage.config, 'replace');
} else if (has(this.config, key)) {
this.config = omit(this.config, [key]);
}
return this;
}
/**
*
* @param key
* @param change config.yml
* @param append true,,使,
*/
async store(key: string, change = false, append = false) {
if (!this.storage.enabled) throw new Error('Must enable storage at first!');
this.changeStorageValue(key, await this.get(key, null), change, append);
return this;
}
/**
*
* 使
*/
async sync(name?: string) {
if (!isNil(name)) await this.syncFactory(name);
else {
for (const key in this.factories) {
await this.syncFactory(key);
}
}
}
/**
*
* @param key
*/
protected async syncFactory(key: string) {
if (has(this.config, key) || !has(this.factories, key)) return this;
const { register, defaultRegister, storage, hook, append } = this.factories[key];
let defaultValue = {};
let value = isAsyncFn(register) ? await register(this) : register(this);
if (!isNil(defaultRegister)) {
defaultValue = isAsyncFn(defaultRegister)
? await defaultRegister(this)
: defaultRegister(this);
value = deepMerge(defaultValue, value, 'replace');
}
if (!isNil(hook)) {
value = isAsyncFn(hook) ? await hook(this, value) : hook(this, value);
}
if (this.storage.enabled) {
value = deepMerge(value, get(this.storage.config, key, isArray(value) ? [] : {}));
}
this.set(key, value, storage && isNil(await this.get(key, null)), append);
return this;
}
protected changeStorageValue<T>(key: string, value: T, change = false, append = false) {
if (change || !has(this.storage.config, key)) {
this.storage.set(key, value);
} else if (isObject(get(this.storage.config, key))) {
this.storage.set(
key,
deepMerge(value, get(this.storage.config, key), append ? 'merge' : 'replace'),
);
}
this.config = deepMerge(this.config, this.storage.config, append ? 'merge' : 'replace');
}
}
const a = new Configure();
a.initilize();
console.log(a.env);

View File

@ -1,8 +0,0 @@
export enum EnvironmentType {
DEVELOPMENT = 'development',
DEV = 'dev',
PRODUCTION = 'production',
PROD = 'prod',
TEST = 'test',
PREVIEW = 'preview',
}

View File

@ -1,140 +0,0 @@
import { readFileSync } from 'fs';
import { parse } from 'dotenv';
import findUp from 'find-up';
import { isNil, isFunction } from 'lodash';
import { EnvironmentType } from './constants';
export class Env {
/**
*
*/
async load() {
/**
*
* eslint turbo.jsoon"tasks": {"build": {"dependsOn": ["$NODE_ENV"]
*/
if (isNil(process.env.NODE_ENV)) process.env.NODE_ENV = EnvironmentType.DEVELOPMENT;
// 从当前运行应用的目录开始,向上查找.env文件,直到找到第一个文件为止
// 没有找到则返回undefined
const search = [findUp.sync(['.env'])];
// 从当前运行应用的目录开始,向上寻找.env.{环境变量文件},直到找到第一个文件为止,如.env.local
// 如果是development、dev、production、prod环境则同时查找两个
// 没有找到则返回undefined
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (this.isDev()) {
search.push(
findUp.sync([`.env.${EnvironmentType.DEVELOPMENT}`, `.env.${EnvironmentType.DEV}`]),
);
} else if (this.isProd()) {
search.push(
findUp.sync([`.env.${EnvironmentType.PRODUCTION}`, `.env.${EnvironmentType.PROD}`]),
);
} else {
search.push(findUp.sync([`.env.${this.run()}`]));
}
// 过滤掉undefined,把找到的环境变量文件放入envFiles数组
const envFiles = search.filter((file) => file !== undefined);
// 转义每个环境变量文件中的内容为一个对象并让前者覆盖合并后者
// 如.env.{环境变量文件}会覆盖合并.env
// 然后,得到所有文件中配置的环境变量对象
const fileEnvs = envFiles
.map((filePath) => parse(readFileSync(filePath)))
.reduce(
(oc, nc) => ({
...oc,
...nc,
}),
{},
);
// 文件环境变量与系统环境变量合并
const envs = { ...process.env, ...fileEnvs };
// 过滤出在envs中存在而在process.env中不存在的键
const keys = Object.keys(envs).filter((key) => !(key in process.env));
// 把.env*中存在而系统环境变量中不存在的键值对追加到process.env中
// 这样就可以得到最终环境变量 process.env
keys.forEach((key) => {
process.env[key] = envs[key];
});
}
/**
* ,production, development
*/
run() {
return process.env.NODE_ENV as EnvironmentType & RecordAny;
}
/**
*
*/
isProd() {
return this.run() === EnvironmentType.PRODUCTION || this.run() === EnvironmentType.PROD;
}
/**
*
*/
isDev() {
return this.run() === EnvironmentType.DEVELOPMENT || this.run() === EnvironmentType.DEV;
}
/**
*
*/
get(): { [key: string]: string };
/**
*
* @param key
*/
get<T extends BaseType = string>(key: string): T;
/**
*
* @param key
* @param parseTo
*/
get<T extends BaseType = string>(key: string, parseTo: ParseType<T>): T;
/**
* ,
* @param key
* @param defaultValue
*/
get<T extends BaseType = string>(key: string, defaultValue: T): T;
/**
* ,
* @param key
* @param parseTo
* @param defaultValue
*/
get<T extends BaseType = string>(key: string, parseTo: ParseType<T>, defaultValue: T): T;
/**
*
* @param key
* @param parseTo
* @param defaultValue
*/
get<T extends BaseType = string>(key?: string, parseTo?: ParseType<T> | T, defaultValue?: T) {
if (!key) return process.env;
const value = process.env[key];
if (value !== undefined) {
if (parseTo && isFunction(parseTo)) {
return parseTo(value);
}
return value as T;
}
if (parseTo === undefined && defaultValue === undefined) {
return undefined;
}
if (parseTo && defaultValue === undefined) {
return isFunction(parseTo) ? undefined : parseTo;
}
return defaultValue! as T;
}
}

View File

@ -1,71 +0,0 @@
import findUp from 'find-up';
import { ensureFileSync, readFileSync, writeFileSync } from 'fs-extra';
import { isNil, set, omit, has } from 'lodash';
import YAML from 'yaml';
/** storage.ts
*
*/
export class ConfigStorage {
/**
*
*/
protected _enabled = false;
/**
* yaml
*/
protected _path = findUp.sync(['config.yml']);
/**
* yaml
*/
protected _config: Record<string, any> = {};
get enabled() {
return this._enabled;
}
get path() {
return this._path;
}
get config() {
return this._config;
}
/**
*
* @param enabled
* @param filePath
*/
constructor(enabled?: boolean, filePath?: string) {
this._enabled = isNil(enabled) ? this._enabled : enabled;
if (this._enabled) {
if (!isNil(filePath)) this._path = filePath;
ensureFileSync(this._path);
const config = YAML.parse(readFileSync(this._path, 'utf8'));
this._config = isNil(config) ? {} : config;
}
}
/**
*
* @param key
* @param value
*/
set<T>(key: string, value: T) {
ensureFileSync(this.path);
set(this._config, key, value);
writeFileSync(this.path, JSON.stringify(this._config, null, 4));
}
/**
*
* @param key
*/
remove(key: string) {
this._config = omit(this._config, [key]);
if (has(this._config, key)) omit(this._config, [key]);
writeFileSync(this.path, JSON.stringify(this._config, null, 4));
}
}

View File

@ -1,77 +0,0 @@
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { CommandModule } from 'yargs';
import { Configure } from './configure';
export interface ConfigStorageOption {
/**
*
*/
enabled?: boolean;
/**
* yaml,distconfig.yaml
*/
filePath?: string;
}
/**
*
*/
export type ConfigureRegister<T extends Record<string, any>> = (
configure: Configure,
) => T | Promise<T>;
/**
*
*/
export interface ConfigureFactory<
T extends Record<string, any>,
C extends Record<string, any> = T,
> {
/**
*
*/
register: ConfigureRegister<RePartial<T>>;
/**
*
*/
defaultRegister?: ConfigureRegister<T>;
/**
*
*/
storage?: boolean;
/**
*
* @param configure
* @param value register
*/
hook?: (configure: Configure, value: T) => C | Promise<C>;
/**
* , false
*/
append?: boolean;
}
/**
*
*/
export type ConnectionOption<T extends Record<string, any>> = { name?: string } & T;
/**
*
*/
export type ConnectionRst<T extends Record<string, any>> = Array<{ name: string } & T>;
/**
* App
*/
export type App = {
// 应用容器实例
container?: NestFastifyApplication;
// 配置类实例
configure: Configure;
/**
*
*/
commands?: CommandModule<RecordAny, RecordAny>[];
};

View File

@ -1,26 +0,0 @@
/**
*
*/
export enum PostBodyType {
HTML = 'html',
MD = 'markdown',
}
/**
*
*/
export enum PostOrderType {
CREATED = 'createdAt',
UPDATED = 'updatedAt',
PUBLISHED = 'publishedAt',
COMMENTCOUNT = 'commentCounts',
CUSTOM = 'custom',
}
/**
* all - only - none -
*/
export enum SelectTrashMode {
ALL = 'all',
ONLY = 'only',
NONE = 'none',
}

View File

@ -1,71 +0,0 @@
import { ModuleMetadata } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Configure } from '../config/configure';
import { DatabaseModule } from '../database/database.module';
import * as controllers from './controllers';
import * as entities from './entities';
import * as repositories from './repositories';
import * as services from './services';
import { MeiliSearchService } from './services/meili.search.service';
import { PostService } from './services/post.service';
import { SanitizeService } from './services/sanitize.service';
import { CommentSubscriber } from './subscribers/comment.subscriber';
import { PostSubscriber } from './subscribers/post.subscriber';
import { ContentConfig } from './types';
export const forRoot = async (configure: Configure) => {
const config: Required<ContentConfig> = await configure.get('content');
const providers: ModuleMetadata['providers'] = [
...Object.values(services),
SanitizeService,
PostSubscriber,
CommentSubscriber,
{
provide: 'CONTENT_CONFIG',
useValue: config,
},
{
provide: PostService,
inject: [
repositories.PostRepository,
repositories.CategoryRepository,
services.CategoryService,
repositories.TagRepository,
MeiliSearchService,
],
useFactory(
postRepository: repositories.PostRepository,
categoryRepository: repositories.CategoryRepository,
categoryService: services.CategoryService,
tagRepository: repositories.TagRepository,
searchService: MeiliSearchService,
) {
return new PostService(
postRepository,
categoryRepository,
categoryService,
tagRepository,
searchService,
config.searchType,
);
},
},
];
providers.push(MeiliSearchService);
return {
module: class ContentModule {},
imports: [
TypeOrmModule.forFeature(Object.values(entities)),
DatabaseModule.forRepository(Object.values(repositories)),
],
controllers: Object.values(controllers),
providers,
exports: [
...Object.values(services),
PostService,
DatabaseModule.forRepository(Object.values(repositories)),
],
};
};

View File

@ -1,79 +0,0 @@
import {
Controller,
Get,
SerializeOptions,
Query,
Param,
ParseUUIDPipe,
Post,
Body,
Patch,
Delete,
} from '@nestjs/common';
import { SelectTrashMode } from '../constants';
import { QueryCategoryDto, CreateCategoryDto, UpdateCategoryDto } from '../dtos';
import { CategoryService } from '../services';
@Controller('categories')
export class CategoryController {
constructor(protected service: CategoryService) {}
@Get('tree')
@SerializeOptions({ groups: ['category-tree'] })
async tree() {
return this.service.findTrees({ trashed: SelectTrashMode.NONE });
}
@Get()
@SerializeOptions({ groups: ['category-list'] })
async list(
@Query()
options: QueryCategoryDto,
) {
return this.service.paginate(options);
}
@Get(':id')
@SerializeOptions({ groups: ['category-detail'] })
async detail(
@Param('id', new ParseUUIDPipe())
id: string,
) {
return this.service.detail(id);
}
@Post()
@SerializeOptions({ groups: ['category-detail', 'create'] })
async store(
@Body()
data: CreateCategoryDto,
) {
return this.service.create(data);
}
@Patch()
@SerializeOptions({ groups: ['category-detail'] })
async update(
@Body()
data: UpdateCategoryDto,
) {
return this.service.update(data);
}
@Delete()
@SerializeOptions({ groups: ['category-detail'] })
async delete(@Body() data: { ids: string[] }) {
const { ids } = data;
return this.service.delete(ids, true);
}
@Post('restore')
@SerializeOptions({ groups: ['category-detail'] })
async restore(@Body() data: { ids: string[] }) {
const { ids } = data;
return this.service.restore(ids);
}
}

View File

@ -1,44 +0,0 @@
import { Controller, Get, SerializeOptions, Query, Post, Body, Delete } from '@nestjs/common';
import { DeleteDto } from '@/modules/restful/dtos/delete.dto';
import { QueryCommentTreeDto, QueryCommentDto, CreateCommentDto } from '../dtos';
import { CommentService } from '../services';
@Controller('comments')
export class CommentController {
constructor(protected service: CommentService) {}
@Get('tree')
@SerializeOptions({ groups: ['comment-tree'] })
async tree(
@Query()
query: QueryCommentTreeDto,
) {
return this.service.findTrees(query);
}
@Get()
@SerializeOptions({ groups: ['comment-list'] })
async list(
@Query()
query: QueryCommentDto,
) {
return this.service.paginate(query);
}
@Post()
@SerializeOptions({ groups: ['comment-detail'] })
async store(
@Body()
data: CreateCommentDto,
) {
return this.service.create(data);
}
@Delete()
@SerializeOptions({ groups: ['comment-detail'] })
async delete(@Body() ids: DeleteDto) {
return this.service.delete(ids.ids);
}
}

View File

@ -1,4 +0,0 @@
export * from './category.controller';
export * from './tag.controller';
export * from './post.controller';
export * from './comment.controller';

View File

@ -1,69 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
SerializeOptions,
} from '@nestjs/common';
import { DeleteWithTrashDto, RestoreDto } from '@/modules/restful/dtos';
import { QueryPostDto, UpdatePostDto } from '../dtos/post.dto';
import { PostService } from '../services/post.service';
@Controller('posts')
export class PostController {
constructor(protected postservice: PostService) {}
@Get()
@SerializeOptions({ groups: ['post-list'] })
async list(
@Query()
options: QueryPostDto,
) {
return this.postservice.paginate(options);
}
@Get(':id')
@SerializeOptions({ groups: ['post-detail'] })
async detail(
@Param('id', new ParseUUIDPipe())
id: string,
) {
return this.postservice.detail(id);
}
@Post()
@SerializeOptions({ groups: ['post-detail'] })
async store(
@Body()
data: any,
) {
return this.postservice.create(data);
}
@Patch()
@SerializeOptions({ groups: ['post-detail'] })
async update(
@Body()
data: UpdatePostDto,
) {
return this.postservice.update(data);
}
@Post('delete')
@SerializeOptions({ groups: ['post-list'] })
async delete(@Body() data: DeleteWithTrashDto) {
return this.postservice.delete(data.ids, data.transh);
}
@Post('restore')
@SerializeOptions({ groups: ['post-list'] })
async restore(@Body() data: RestoreDto) {
return this.postservice.restore(data.ids);
}
}

View File

@ -1,62 +0,0 @@
import {
Controller,
Get,
SerializeOptions,
Query,
Param,
ParseUUIDPipe,
Post,
Body,
Patch,
Delete,
} from '@nestjs/common';
import { QueryCategoryDto, CreateTagDto, UpdateTagDto } from '../dtos';
import { TagService } from '../services';
@Controller('tags')
export class TagController {
constructor(protected service: TagService) {}
@Get()
@SerializeOptions({})
async list(
@Query()
options: QueryCategoryDto,
) {
return this.service.paginate(options);
}
@Get(':id')
@SerializeOptions({})
async detail(
@Param('id', new ParseUUIDPipe())
id: string,
) {
return this.service.detail(id);
}
@Post()
@SerializeOptions({})
async store(
@Body()
data: CreateTagDto,
) {
return this.service.create(data);
}
@Patch()
@SerializeOptions({})
async update(
@Body()
data: UpdateTagDto,
) {
return this.service.update(data);
}
@Delete(':id')
@SerializeOptions({})
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
return this.service.delete(id);
}
}

View File

@ -1,86 +0,0 @@
import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
Min,
IsNumber,
IsOptional,
MaxLength,
IsNotEmpty,
IsUUID,
ValidateIf,
IsDefined,
IsEnum,
} from 'class-validator';
import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorators/dto-validation.decorator';
import { IsDataExist } from '@/modules/database/constraints';
import { PaginateOptions } from '@/modules/database/types';
import { SelectTrashMode } from '../constants';
import { CategoryEntity } from '../entities';
/**
*
*/
@DtoValidation({ type: 'query' })
export class QueryCategoryTreeDto {
@IsEnum(SelectTrashMode)
@IsOptional()
trashed?: SelectTrashMode;
}
@DtoValidation({ type: 'query' })
export class QueryCategoryDto extends QueryCategoryTreeDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '当前页必须大于1' })
@IsNumber()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '每页显示数据必须大于1' })
@IsNumber()
@IsOptional()
limit = 10;
}
/**
*
*/
@DtoValidation({ groups: ['create'] })
export class CreateCategoryDto {
// @IsTreeUnique(CategoryEntity, { always: true, message: '分类名称已存在' })
// @IsTreeUniqueExist(CategoryEntity, { always: true, message: '父分类不存在1' })
@MaxLength(25, {
always: true,
message: '分类名称长度不得超过$constraint1',
})
@IsNotEmpty({ groups: ['create'], message: '分类名称不得为空' })
@IsOptional({ groups: ['update'] })
name: string;
@IsDataExist({ entity: CategoryEntity, map: 'id' }, { always: true, message: '父分类不存在' })
@IsUUID(undefined, { always: true, message: '父分类ID格式不正确' })
@ValidateIf((value) => value.parent !== null && value.parent)
@IsOptional({ always: true })
@Transform(({ value }) => (value === 'null' ? null : value))
parent?: string;
@Transform(({ value }) => toNumber(value))
@Min(0, { always: true, message: '排序值必须大于0' })
@IsNumber(undefined, { always: true })
@IsOptional({ always: true })
customOrder?: number = 0;
}
/**
*
*/
@DtoValidation({ groups: ['update'] })
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {
@IsUUID(undefined, { groups: ['update'], message: 'ID格式错误' })
@IsDefined({ groups: ['update'], message: 'ID必须指定' })
id: string;
}

View File

@ -1,72 +0,0 @@
import { PickType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsUUID,
IsOptional,
Min,
IsNumber,
MaxLength,
IsNotEmpty,
IsDefined,
ValidateIf,
} from 'class-validator';
import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorators/dto-validation.decorator';
import { IsDataExist } from '@/modules/database/constraints';
import { PaginateOptions } from '@/modules/database/types';
import { CommentEntity, PostEntity } from '../entities';
/**
*
*/
@DtoValidation({ type: 'query' })
export class QueryCommentDto implements PaginateOptions {
@IsDataExist(PostEntity, { always: true, message: '文章不存在' })
@IsUUID(undefined, { message: 'ID格式错误' })
@IsOptional()
post?: string;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '当前页必须大于1' })
@IsNumber()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '每页显示数据必须大于1' })
@IsNumber()
@IsOptional()
limit = 10;
}
/**
*
*/
@DtoValidation({ type: 'query' })
export class QueryCommentTreeDto extends PickType(QueryCommentDto, ['post']) {}
/**
*
*/
@DtoValidation()
export class CreateCommentDto {
@MaxLength(1000, { message: '评论内容不能超过$constraint1个字' })
@IsNotEmpty({ message: '评论内容不能为空' })
body: string;
@IsDataExist(PostEntity, { always: true, message: '文章不存在' })
@IsUUID(undefined, { message: 'ID格式错误' })
@IsDefined({ message: 'ID必须指定' })
post: string;
@IsDataExist(CommentEntity, { always: true, message: '评论ID错误' })
@IsUUID(undefined, { always: true, message: 'ID格式错误' })
@ValidateIf((value) => value.parent !== null && value.parent)
@IsOptional({ always: true })
@Transform(({ value }) => (value === 'null' ? null : value))
parent?: string;
}

View File

@ -1,5 +0,0 @@
export * from './category.dto';
export * from './comment.dto';
export * from './post.dto';
export * from './tag.dto';
// export * from ''

View File

@ -1,151 +0,0 @@
import { toBoolean } from '@3rapp/utils';
import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsOptional,
IsEnum,
Min,
IsNumber,
MaxLength,
IsNotEmpty,
ValidateIf,
IsUUID,
IsDefined,
Max,
} from 'class-validator';
import { toNumber, isNil } from 'lodash';
import { DtoValidation } from '@/modules/core/decorators/dto-validation.decorator';
import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint';
import { PaginateOptions } from '@/modules/database/types';
import { PostOrderType, SelectTrashMode } from '../constants';
import { CategoryEntity, TagEntity } from '../entities';
@DtoValidation({ type: 'query' })
export class QueryPostDto implements PaginateOptions {
@Transform(({ value }) => toBoolean(value))
@IsBoolean()
@IsOptional()
isPublished?: boolean;
@IsEnum(PostOrderType, {
message: `排序规则必须是${Object.values(PostOrderType).join(',')}其中一项`,
})
@IsOptional()
orderBy?: PostOrderType;
@Transform(({ value }) => {
return toNumber(value);
})
@Min(1, { message: '当前页必须大于1' })
@Max(100, { message: '当前页必须小于100' })
@IsNumber()
@IsOptional()
page: number;
// DTO 设置默认值是无效的,因为它是一个类,而不是一个对象。如果要设置默认值,可以在构造函数中设置默认值。
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '每页显示数据必须大于1' })
@IsNumber()
@IsOptional()
limit: number;
@IsDataExist(CategoryEntity, { always: true, message: '分类不存在' })
@IsUUID(undefined, { message: 'ID格式错误' })
@IsOptional()
category?: string;
@IsDataExist(TagEntity, { always: true, message: '标签不存在' })
@IsUUID(undefined, { message: 'ID格式错误' })
@IsOptional()
tag?: string;
@MaxLength(100, {
always: true,
message: '搜索关键字最大长度为$constraint1',
})
@IsOptional({ always: true })
search?: string;
@IsEnum(SelectTrashMode)
@IsOptional()
transhed?: SelectTrashMode;
}
/**
*
*/
@DtoValidation({ type: 'body', groups: ['create'] })
export class CreatePostDto {
@IsDataExist(CategoryEntity, { always: true, message: '分类不存在' })
@IsUUID(undefined, {
always: true,
message: 'ID格式错误',
})
@IsOptional({ always: true })
category?: string;
/**
* ID
*/
@IsDataExist(TagEntity, { always: true, each: true, message: '标签不存在' })
@IsUUID(undefined, {
always: true,
each: true,
message: 'ID格式错误',
})
@IsOptional({ always: true })
tags?: string[];
@MaxLength(255, {
always: true,
message: '文章标题长度最大为$constraint1',
})
@IsNotEmpty({ groups: ['create'], message: '文章标题必须填写' })
@IsOptional({ groups: ['update'] })
title: string;
@MaxLength(4, { always: true, groups: ['create'], message: '文章标题长度最大为$constraint1' })
@IsNotEmpty({ groups: ['create'], message: '文章内容必须填写' })
@IsOptional({ groups: ['update'] })
body: string;
@MaxLength(500, {
always: true,
message: '文章描述长度最大为$constraint1',
})
@IsOptional({ always: true })
summary?: string;
@Transform(({ value }) => toBoolean(value))
@IsBoolean({ always: true })
@ValidateIf((value) => !isNil(value.publish))
@IsOptional({ always: true })
publish?: boolean;
@MaxLength(20, {
each: true,
always: true,
message: '每个关键字长度最大为$constraint1',
})
@IsOptional({ always: true })
keywords?: string[];
@Transform(({ value }) => toNumber(value))
@Min(0, { always: true, message: '排序值必须大于0' })
@IsNumber(undefined, { always: true })
@IsOptional({ always: true })
customOrder?: number = 0;
}
/**
*
*/
@DtoValidation({ groups: ['update'] })
export class UpdatePostDto extends PartialType(CreatePostDto) {
@IsUUID(undefined, { groups: ['update'], message: '文章ID格式错误' })
@IsDefined({ groups: ['update'], message: '文章ID必须指定' })
id: string;
}

View File

@ -1,68 +0,0 @@
import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
Min,
IsNumber,
IsOptional,
MaxLength,
IsNotEmpty,
IsUUID,
IsDefined,
} from 'class-validator';
import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorators/dto-validation.decorator';
import { IsUnique } from '@/modules/database/constraints/unique.constraint';
import { PaginateOptions } from '@/modules/database/types';
import { TagEntity } from '../entities';
/**
*
*/
@DtoValidation({ type: 'query' })
export class QueryTagDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '当前页必须大于1' })
@IsNumber()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '每页显示数据必须大于1' })
@IsNumber()
@IsOptional()
limit = 10;
}
/**
*
*/
@DtoValidation({ groups: ['create'] })
export class CreateTagDto {
@IsUnique(TagEntity, { groups: ['create'], message: '标签名称已存在' })
@MaxLength(255, {
always: true,
message: '标签名称长度最大为$constraint1',
})
@IsNotEmpty({ groups: ['create'], message: '标签名称必须填写' })
@IsOptional({ groups: ['update'] })
name: string;
@MaxLength(500, {
always: true,
message: '标签描述长度最大为$constraint1',
})
@IsOptional({ always: true })
description?: string;
}
/**
*
*/
@DtoValidation({ groups: ['update'] })
export class UpdateTagDto extends PartialType(CreateTagDto) {
@IsUUID(undefined, { groups: ['update'], message: 'ID格式错误' })
@IsDefined({ groups: ['update'], message: 'ID必须指定' })
id: string;
}

View File

@ -1,4 +0,0 @@
## 序列化 响应拦截器
@Exclude() @Expose({ groups: ['post-detail'] })
@SerializeOptions({ groups: ['post-detail'] })
@UseInterceptors(AppInterceptor)

View File

@ -1,54 +0,0 @@
import { Exclude, Expose, Type } from 'class-transformer';
import {
Entity,
BaseEntity,
PrimaryColumn,
Column,
OneToMany,
Relation,
Tree,
TreeParent,
TreeChildren,
Index,
DeleteDateColumn,
} from 'typeorm';
import { PostEntity } from './post.entity';
@Exclude()
@Tree('materialized-path')
@Entity('content_categories')
export class CategoryEntity extends BaseEntity {
@Expose()
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 })
id: string;
@Expose()
@Column({ comment: '分类名称' })
@Index({ fulltext: true, unique: true })
name: string;
@Expose({ groups: ['category-tree', 'category-list', 'category-detail'] })
@Column({ comment: '分类排序', default: 0 })
customOrder: number;
@Expose()
@OneToMany(() => PostEntity, (post) => post.category, { cascade: true })
posts: Relation<PostEntity[]>;
@Expose({ groups: ['category-list'] })
depth = 0;
@Expose({ groups: ['category-list', 'category-detail'] })
@TreeParent({ onDelete: 'NO ACTION' })
parent: Relation<CategoryEntity> | null;
@Expose({ groups: ['category-tree'] })
@TreeChildren({ cascade: true })
children: Relation<CategoryEntity>[];
@Expose()
@Type(() => Date)
@DeleteDateColumn({ comment: '删除时间', nullable: true })
deletedAt: Date;
}

View File

@ -1,65 +0,0 @@
import { Exclude, Expose } from 'class-transformer';
import {
Entity,
BaseEntity,
PrimaryColumn,
Column,
CreateDateColumn,
Relation,
ManyToOne,
Tree,
TreeChildren,
TreeParent,
Index,
} from 'typeorm';
import { UserEntity } from '@/modules/user/entities/user.entity';
import { PostEntity } from './post.entity';
@Exclude()
@Tree('materialized-path')
@Entity('content_comments')
export class CommentEntity extends BaseEntity {
@Expose({ groups: ['comment-detail', 'comment-list'] })
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 })
id: string;
@Expose({ groups: ['post-detail', 'post-list'] })
@Column({ comment: '评论内容', type: 'text' })
@Index({ fulltext: true })
body: string;
@Expose({ groups: ['comment-detail', 'comment-list'] })
@CreateDateColumn({
comment: '创建时间',
})
createdAt: Date;
@Expose()
@ManyToOne(() => PostEntity, (post) => post.comments, {
nullable: false,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
post: Relation<PostEntity>;
@Expose({ groups: ['comment-list'] })
depth = 0;
@Expose({ groups: ['comment-detail', 'comment-list'] })
@TreeParent({ onDelete: 'CASCADE' })
parent: Relation<CommentEntity> | null;
@Expose({ groups: ['comment-tree'] })
@TreeChildren({ cascade: true })
children: Relation<CommentEntity>[];
@Expose()
@ManyToOne((type) => UserEntity, (user) => user.comments, {
nullable: false,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
author: Relation<UserEntity>;
}

View File

@ -1,4 +0,0 @@
export * from './category.entity';
export * from './comment.entity';
export * from './post.entity';
export * from './tag.entity';

View File

@ -1,128 +0,0 @@
import { Exclude, Expose, Type } from 'class-transformer';
import {
BaseEntity,
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { UserEntity } from '@/modules/user/entities/user.entity';
import { PostBodyType } from '../constants';
import { CategoryEntity } from './category.entity';
import { CommentEntity } from './comment.entity';
import { TagEntity } from './tag.entity';
@Exclude()
@Entity('content_posts')
export class PostEntity extends BaseEntity {
@Expose()
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 })
id: string;
@Expose()
@Column({ comment: '文章标题' })
@Index({ fulltext: true })
title: string;
@Expose()
@Column({ comment: '文章内容', type: 'text' })
@Index({ fulltext: true })
body: string;
@Expose({ groups: ['post-detail'] })
@Column({ comment: '文章描述', nullable: true })
@Index({ fulltext: true })
summary?: string;
@Expose()
@Column({ comment: '关键字', type: 'simple-array', nullable: true })
keywords?: string[];
@Expose()
@Column({
comment: '文章类型',
type: 'varchar',
// 如果是mysql或者postgresql你可以使用enum类型
// enum: PostBodyType,
default: PostBodyType.MD,
})
type: PostBodyType;
@Expose()
@Column({
comment: '发布时间',
type: 'varchar',
nullable: true,
})
publishedAt?: Date | null;
@Expose()
@Column({ comment: '自定义文章排序', default: 0 })
customOrder: number;
@Expose()
@Type(() => Date)
@CreateDateColumn({
comment: '创建时间',
})
createdAt: Date;
@Expose()
@Type(() => Date)
@UpdateDateColumn({
comment: '更新时间',
})
updatedAt: Date;
@Expose()
@Type(() => Date)
@DeleteDateColumn({
comment: '删除时间',
nullable: true,
})
deleteAt: Date;
@Expose({ groups: ['post-detail'] })
@ManyToOne(() => CategoryEntity, (category) => category.posts, {
nullable: true,
onDelete: 'SET NULL',
})
category: Relation<CategoryEntity>;
@Expose()
@JoinTable()
@ManyToMany(() => TagEntity, (tag) => tag.posts, {
cascade: true,
})
tags: Relation<TagEntity[]>;
@Expose()
@OneToMany(() => CommentEntity, (comment) => comment.post)
comments: Relation<CommentEntity[]>;
@Expose()
@Column({ comment: '文章评论量', default: 0, select: false })
commentCount: number;
@Expose()
commentCounts: number;
@Expose()
@ManyToOne(() => UserEntity, (user) => user.posts, {
nullable: false,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
author: Relation<UserEntity>;
}

View File

@ -1,28 +0,0 @@
import { Exclude, Expose } from 'class-transformer';
import { Entity, PrimaryColumn, Column, ManyToMany, Index } from 'typeorm';
import { PostEntity } from './post.entity';
@Exclude()
@Entity('content_tags')
export class TagEntity {
@Expose()
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 })
id: string;
@Expose({ groups: ['tag-list', 'tag-detail', 'post-list', 'post-detail'] })
@Column({ comment: '标签名称' })
@Index({ fulltext: true, unique: true })
name: string;
@Expose()
@Column({ comment: '标签描述', nullable: true })
description?: string;
@Expose()
@ManyToMany(() => PostEntity, (post) => post.tags)
posts: PostEntity[];
@Expose()
postCount: number;
}

View File

@ -1,55 +0,0 @@
import { instanceToPlain } from 'class-transformer';
import { isNil, pick } from 'lodash';
import { PostEntity } from './entities';
import { CategoryRepository, CommentRepository } from './repositories';
export async function getSearchItem(
catRepo: CategoryRepository,
cmtRepo: CommentRepository,
post: PostEntity,
) {
const categories = isNil(post.category)
? []
: (await catRepo.flatAncestorsTree(post.category)).map((item) => ({
id: item.id,
name: item.name,
}));
const comments = (
await cmtRepo.find({
relations: ['post'],
where: { post: { id: post.id } },
})
).map((item) => ({ id: item.id, body: item.body }));
return [
{
...pick(instanceToPlain(post), [
'id',
'title',
'body',
'summary',
'commentCount',
'commentCounts',
'deletedAt',
'publishedAt',
'createdAt',
'updatedAt',
]),
categories,
// tags: post.tags.map((item) => ({ id: item.id, name: item.name })),
comments,
},
];
}
export const getSearchData = async (
posts: PostEntity[],
catRepo: CategoryRepository,
cmtRepo: CommentRepository,
) =>
(await Promise.all(posts.map(async (post) => getSearchItem(catRepo, cmtRepo, post)))).reduce(
(o, n) => [...o, ...n],
[],
);

View File

@ -1,202 +0,0 @@
import { isNil, pick, unset } from 'lodash';
import { FindOptionsUtils, FindTreeOptions, TreeRepository, TreeRepositoryUtils } from 'typeorm';
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
import { CategoryEntity } from '../entities';
interface CateFindTreeOptions extends FindTreeOptions {
onlyTrashed?: boolean;
withTrashed?: boolean;
}
@CustomRepository(CategoryEntity)
export class CategoryRepository extends TreeRepository<CategoryEntity> {
buildBaseQB() {
return this.createQueryBuilder('category').leftJoinAndSelect('category.parent', 'parent');
}
/**
*
* @param options
*/
async findTrees(options?: CateFindTreeOptions) {
const roots = await this.findRoots(options);
await Promise.all(roots.map((root) => this.findDescendantsTree(root, options)));
return roots;
}
/**
*
* @param options
*/
findRoots(options?: CateFindTreeOptions) {
const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias);
const escapeColumn = (column: string) => this.manager.connection.driver.escape(column);
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
const parentPropertyName = joinColumn.givenDatabaseName || joinColumn.databaseName;
const qb = this.buildBaseQB().orderBy('category.customOrder', 'ASC');
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options);
if (options?.withTrashed) {
qb.withDeleted();
options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL');
}
qb.where(`${escapeAlias('category')}.${escapeColumn(parentPropertyName)} IS NULL`);
return qb.getMany();
}
/**
*
* @param entity
* @param options
*/
findDescendants(entity: CategoryEntity, options?: CateFindTreeOptions) {
const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity);
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options);
if (options?.withTrashed) {
qb.withDeleted();
options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL');
}
qb.orderBy('category.customOrder', 'ASC');
return qb.getMany();
}
/**
*
* @param entity
* @param options
*/
findAncestors(entity: CategoryEntity, options?: CateFindTreeOptions) {
const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity);
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options);
if (options?.withTrashed) {
qb.withDeleted();
options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL');
}
qb.orderBy('category.customOrder', 'ASC');
return qb.getMany();
}
/**
*
* @param entity
* @param options
*/
async findDescendantsTree(entity: CategoryEntity, options?: CateFindTreeOptions) {
const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity)
.leftJoinAndSelect('category.parent', 'parent')
.orderBy('category.customOrder', 'ASC');
if (options?.withTrashed) {
qb.withDeleted();
options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL');
}
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth']));
const entities = await qb.getRawAndEntities();
const relationMaps = TreeRepositoryUtils.createRelationMaps(
this.manager,
this.metadata,
'category',
entities.raw,
);
TreeRepositoryUtils.buildChildrenEntityTree(
this.metadata,
entity,
entities.entities,
relationMaps,
{
depth: -1,
...pick(options, ['relations']),
},
);
return entity;
}
/**
*
* @param entity
* @param options
*/
async findAncestorsTree(entity: CategoryEntity, options?: CateFindTreeOptions) {
const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity)
.leftJoinAndSelect('category.parent', 'parent')
.orderBy('category.customOrder', 'ASC');
if (options?.withTrashed) {
qb.withDeleted();
options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL');
}
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options);
const entities = await qb.getRawAndEntities();
const relationMaps = TreeRepositoryUtils.createRelationMaps(
this.manager,
this.metadata,
'category',
entities.raw,
);
TreeRepositoryUtils.buildParentEntityTree(
this.metadata,
entity,
entities.entities,
relationMaps,
);
return entity;
}
/**
*
* @param entity
*/
async countDescendants(entity: CategoryEntity, options?: CateFindTreeOptions) {
const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity);
if (options?.withTrashed) {
qb.withDeleted();
options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL');
}
return qb.getCount();
}
/**
*
* @param entity
*/
async countAncestors(entity: CategoryEntity, options?: CateFindTreeOptions) {
const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity);
if (options?.withTrashed) {
qb.withDeleted();
options?.onlyTrashed && qb.where('category.deletedAt IS NOT NULL');
}
return qb.getCount();
}
/**
*
* @param trees
* @param depth
* @param parent
*/
async toFlatTrees(trees: CategoryEntity[], depth = 0, parent: CategoryEntity | null = null) {
const data: Omit<CategoryEntity, 'children'>[] = [];
for (const item of trees) {
item.depth = depth;
item.parent = parent;
const { children } = item;
unset(item, 'children');
data.push(item);
data.push(...(await this.toFlatTrees(children, depth + 1, item)));
}
return data as CategoryEntity[];
}
async flatAncestorsTree(item: CategoryEntity) {
let data: Omit<CategoryEntity, 'children'>[] = [];
const category = await this.findAncestorsTree(item);
const { parent } = category;
unset(category, 'children');
unset(category, 'parent');
data.push(item);
if (!isNil(parent)) data = [...(await this.flatAncestorsTree(parent)), ...data];
return data as CategoryEntity[];
}
}

View File

@ -1,129 +0,0 @@
import { pick, unset } from 'lodash';
import {
FindOptionsUtils,
FindTreeOptions,
SelectQueryBuilder,
TreeRepository,
TreeRepositoryUtils,
} from 'typeorm';
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
import { CommentEntity } from '../entities';
type FindCommentTreeOptions = FindTreeOptions & {
addQuery?: (query: SelectQueryBuilder<CommentEntity>) => SelectQueryBuilder<CommentEntity>;
};
@CustomRepository(CommentEntity)
export class CommentRepository extends TreeRepository<CommentEntity> {
/**
*
*/
buildBaseQB(qb: SelectQueryBuilder<CommentEntity>): SelectQueryBuilder<CommentEntity> {
return qb
.leftJoinAndSelect(`comment.parent`, 'parent')
.leftJoinAndSelect(`comment.post`, 'post')
.orderBy('comment.createdAt', 'DESC');
}
/**
*
* @param options
*/
async findTrees(options: FindCommentTreeOptions = {}) {
options.relations = ['parent', 'children'];
const roots = await this.findRoots(options);
await Promise.all(roots.map((root) => this.findDescendantsTree(root, options)));
return roots;
}
/**
*
* @param options
*/
findRoots(options: FindCommentTreeOptions = {}) {
const { addQuery, ...rest } = options;
const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias);
const escapeColumn = (column: string) => this.manager.connection.driver.escape(column);
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
const parentPropertyName = joinColumn.givenDatabaseName || joinColumn.databaseName;
let qb = this.buildBaseQB(this.createQueryBuilder('comment'));
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, rest);
qb.where(`${escapeAlias('comment')}.${escapeColumn(parentPropertyName)} IS NULL`);
qb = addQuery ? addQuery(qb) : qb;
return qb.getMany();
}
/**
*
* @param closureTableAlias
* @param entity
* @param options
*/
createDtsQueryBuilder(
closureTableAlias: string,
entity: CommentEntity,
options: FindCommentTreeOptions = {},
): SelectQueryBuilder<CommentEntity> {
const { addQuery } = options;
const qb = this.buildBaseQB(
super.createDescendantsQueryBuilder('comment', closureTableAlias, entity),
);
return addQuery ? addQuery(qb) : qb;
}
/**
*
* @param entity
* @param options
*/
async findDescendantsTree(
entity: CommentEntity,
options: FindCommentTreeOptions = {},
): Promise<CommentEntity> {
const qb: SelectQueryBuilder<CommentEntity> = this.createDtsQueryBuilder(
'treeClosure',
entity,
options,
);
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth']));
const entities = await qb.getRawAndEntities();
const relationMaps = TreeRepositoryUtils.createRelationMaps(
this.manager,
this.metadata,
'comment',
entities.raw,
);
TreeRepositoryUtils.buildChildrenEntityTree(
this.metadata,
entity,
entities.entities,
relationMaps,
{
depth: -1,
...pick(options, ['relations']),
},
);
return entity;
}
/**
*
* @param trees
* @param depth
*/
async toFlatTrees(trees: CommentEntity[], depth = 0) {
const data: Omit<CommentEntity, 'children'>[] = [];
for (const item of trees) {
item.depth = depth;
const { children } = item;
unset(item, 'children');
data.push(item);
data.push(...(await this.toFlatTrees(children, depth + 1)));
}
return data as CommentEntity[];
}
}

View File

@ -1,4 +0,0 @@
export * from './category.repository';
export * from './post.repository';
export * from './comment.repository';
export * from './tag.repository';

View File

@ -1,28 +0,0 @@
import { BaseRepository } from '@/modules/database/base/repository';
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
import { CommentEntity } from '../entities';
import { PostEntity } from '../entities/post.entity';
@CustomRepository(PostEntity)
export class PostRepository extends BaseRepository<PostEntity> {
protected _qbName = 'post';
protected orderBy = 'createdAt';
// addselect 添加的列必须在entity中有定义 from 可以是实体也可以是表名 'content_comments'
// loadRelationCountAndMap 用于加载关系的数量
postBuildBaseQB() {
return this.createQueryBuilder(this.qbName)
.leftJoinAndSelect(`${this.qbName}.category`, 'category')
.leftJoinAndSelect(`${this.qbName}.tags`, 'tags')
.leftJoinAndSelect(`${this.qbName}.comments`, 'comments')
.addSelect((subQuery) => {
return subQuery
.select('COUNT(c.id)', 'count')
.from(CommentEntity, 'c')
.where(`c.post.id = ${this.qbName}.id`);
}, 'commentCounts')
.loadRelationCountAndMap(`${this.qbName}.commentCounts`, `${this.qbName}.comments`);
}
}

View File

@ -1,26 +0,0 @@
import { BaseRepository } from '@/modules/database/base/repository';
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
import { TagEntity } from '../entities';
@CustomRepository(TagEntity)
export class TagRepository extends BaseRepository<TagEntity> {
protected _qbName = 'tag';
protected orderBy = 'name';
tagbuildBaseQB() {
return this.buildBaseQB()
.leftJoinAndSelect(`${this.qbName}.posts`, 'posts')
.addSelect(
(subQuery) =>
subQuery
.select('COUNT(p.id)', 'count')
.from('content_posts', 'p')
.where(`p.id = ${this.qbName}.id`),
'postCount',
)
.orderBy('postCount', 'DESC')
.loadRelationCountAndMap(`${this.qbName}.postCount`, `${this.qbName}.posts`);
}
}

Some files were not shown because too many files have changed in this diff Show More