first commit
This commit is contained in:
3776
src/.umi/appData.json
Normal file
3776
src/.umi/appData.json
Normal file
File diff suppressed because one or more lines are too long
9
src/.umi/core/EmptyRoute.tsx
Normal file
9
src/.umi/core/EmptyRoute.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import React from 'react';
|
||||
import { Outlet, useOutletContext } from 'umi';
|
||||
export default function EmptyRoute() {
|
||||
const context = useOutletContext();
|
||||
return <Outlet context={context} />;
|
||||
}
|
||||
18
src/.umi/core/defineApp.ts
Normal file
18
src/.umi/core/defineApp.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import type { IRuntimeConfig as Plugin0 } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/src/.umi/plugin-antd/runtimeConfig.d'
|
||||
import type { IRuntimeConfig as Plugin1 } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/src/.umi/plugin-request/runtimeConfig.d'
|
||||
interface IDefaultRuntimeConfig {
|
||||
onRouteChange?: (props: { routes: any, clientRoutes: any, location: any, action: any, isFirst: boolean }) => void;
|
||||
patchRoutes?: (props: { routes: any }) => void;
|
||||
patchClientRoutes?: (props: { routes: any }) => void;
|
||||
render?: (oldRender: () => void) => void;
|
||||
rootContainer?: (lastRootContainer: JSX.Element, args?: any) => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
export type RuntimeConfig = IDefaultRuntimeConfig & Plugin0 & Plugin1
|
||||
|
||||
export function defineApp(config: RuntimeConfig): RuntimeConfig {
|
||||
return config;
|
||||
}
|
||||
10
src/.umi/core/helmet.ts
Normal file
10
src/.umi/core/helmet.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import React from 'react';
|
||||
import { HelmetProvider } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/@umijs/renderer-react';
|
||||
import { context } from './helmetContext';
|
||||
|
||||
export const innerProvider = (container) => {
|
||||
return React.createElement(HelmetProvider, { context }, container);
|
||||
}
|
||||
4
src/.umi/core/helmetContext.ts
Normal file
4
src/.umi/core/helmetContext.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
export const context = {};
|
||||
72
src/.umi/core/history.ts
Normal file
72
src/.umi/core/history.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import { createHashHistory, createMemoryHistory, createBrowserHistory } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/@umijs/renderer-react';
|
||||
import type { UmiHistory } from './historyIntelli';
|
||||
|
||||
let history: UmiHistory;
|
||||
let basename: string = '/';
|
||||
export function createHistory(opts: any) {
|
||||
let h;
|
||||
if (opts.type === 'hash') {
|
||||
h = createHashHistory();
|
||||
} else if (opts.type === 'memory') {
|
||||
h = createMemoryHistory(opts);
|
||||
} else {
|
||||
h = createBrowserHistory();
|
||||
}
|
||||
if (opts.basename) {
|
||||
basename = opts.basename;
|
||||
}
|
||||
|
||||
|
||||
history = {
|
||||
...h,
|
||||
push(to, state) {
|
||||
h.push(patchTo(to, h), state);
|
||||
},
|
||||
replace(to, state) {
|
||||
h.replace(patchTo(to, h), state);
|
||||
},
|
||||
get location() {
|
||||
return h.location;
|
||||
},
|
||||
get action() {
|
||||
return h.action;
|
||||
}
|
||||
}
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
export function setHistory(h: UmiHistory) {
|
||||
if (h) {
|
||||
history = h;
|
||||
}
|
||||
}
|
||||
|
||||
// Patch `to` to support basename
|
||||
// Refs:
|
||||
// https://github.com/remix-run/history/blob/3e9dab4/packages/history/index.ts#L484
|
||||
// https://github.com/remix-run/history/blob/dev/docs/api-reference.md#to
|
||||
function patchTo(to: any, h: History) {
|
||||
if (typeof to === 'string') {
|
||||
return `${stripLastSlash(basename)}${to}`;
|
||||
} else if (typeof to === 'object') {
|
||||
|
||||
const currentPathname = h.location.pathname;
|
||||
|
||||
return {
|
||||
...to,
|
||||
pathname: to.pathname? `${stripLastSlash(basename)}${to.pathname}` : currentPathname,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unexpected to: ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stripLastSlash(path) {
|
||||
return path.slice(-1) === '/' ? path.slice(0, -1) : path;
|
||||
}
|
||||
|
||||
export { history };
|
||||
132
src/.umi/core/historyIntelli.ts
Normal file
132
src/.umi/core/historyIntelli.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import { getRoutes } from './route'
|
||||
import type { History } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/@umijs/renderer-react'
|
||||
|
||||
type Routes = Awaited<ReturnType<typeof getRoutes>>['routes']
|
||||
type AllRoute = Routes[keyof Routes]
|
||||
type IsRoot<T extends any> = 'parentId' extends keyof T ? false : true
|
||||
|
||||
// show `/` in not `layout / wrapper` only
|
||||
type GetAllRouteWithoutLayout<Item extends AllRoute> = Item extends any
|
||||
? 'isWrapper' extends keyof Item
|
||||
? never
|
||||
: 'isLayout' extends keyof Item
|
||||
? never
|
||||
: Item
|
||||
: never
|
||||
type AllRouteWithoutLayout = GetAllRouteWithoutLayout<AllRoute>
|
||||
type IndexRoutePathname = '/' extends AllRouteWithoutLayout['path']
|
||||
? '/'
|
||||
: never
|
||||
|
||||
type GetChildrens<T extends any> = T extends any
|
||||
? IsRoot<T> extends true
|
||||
? never
|
||||
: T
|
||||
: never
|
||||
type Childrens = GetChildrens<AllRoute>
|
||||
type Root = Exclude<AllRoute, Childrens>
|
||||
type AllIds = AllRoute['id']
|
||||
|
||||
type GetChildrensByParentId<
|
||||
Id extends AllIds,
|
||||
Item = AllRoute
|
||||
> = Item extends any
|
||||
? 'parentId' extends keyof Item
|
||||
? Item['parentId'] extends Id
|
||||
? Item
|
||||
: never
|
||||
: never
|
||||
: never
|
||||
|
||||
type RouteObject<
|
||||
Id extends AllIds,
|
||||
Item = GetChildrensByParentId<Id>
|
||||
> = IsNever<Item> extends true
|
||||
? ''
|
||||
: Item extends AllRoute
|
||||
? {
|
||||
[Key in Item['path'] as TrimSlash<Key>]: UnionMerge<
|
||||
RouteObject<Item['id']>
|
||||
>
|
||||
}
|
||||
: never
|
||||
|
||||
type GetRootRouteObject<Item extends Root> = Item extends Root
|
||||
? {
|
||||
[K in Item['path'] as TrimSlash<K>]: UnionMerge<RouteObject<Item['id']>>
|
||||
}
|
||||
: never
|
||||
type MergedResult = UnionMerge<GetRootRouteObject<Root>>
|
||||
|
||||
// --- patch history types ---
|
||||
|
||||
type HistoryTo = Parameters<History['push']>['0']
|
||||
type HistoryPath = Exclude<HistoryTo, string>
|
||||
|
||||
type UmiPathname = Path<MergedResult> | (string & {})
|
||||
interface UmiPath extends HistoryPath {
|
||||
pathname: UmiPathname
|
||||
}
|
||||
type UmiTo = UmiPathname | UmiPath
|
||||
|
||||
type UmiPush = (to: UmiTo, state?: any) => void
|
||||
type UmiReplace = (to: UmiTo, state?: any) => void
|
||||
|
||||
|
||||
export interface UmiHistory extends History {
|
||||
push: UmiPush
|
||||
replace: UmiReplace
|
||||
}
|
||||
|
||||
// --- type utils ---
|
||||
type TrimLeftSlash<T extends string> = T extends `/${infer R}`
|
||||
? TrimLeftSlash<R>
|
||||
: T
|
||||
type TrimRightSlash<T extends string> = T extends `${infer R}/`
|
||||
? TrimRightSlash<R>
|
||||
: T
|
||||
type TrimSlash<T extends string> = TrimLeftSlash<TrimRightSlash<T>>
|
||||
|
||||
type IsNever<T> = [T] extends [never] ? true : false
|
||||
type IsEqual<A, B> = (<G>() => G extends A ? 1 : 2) extends <G>() => G extends B
|
||||
? 1
|
||||
: 2
|
||||
? true
|
||||
: false
|
||||
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
|
||||
k: infer I
|
||||
) => void
|
||||
? I
|
||||
: never
|
||||
type UnionMerge<U> = UnionToIntersection<U> extends infer O
|
||||
? { [K in keyof O]: O[K] }
|
||||
: never
|
||||
|
||||
type ExcludeEmptyKey<T> = IsEqual<T, ''> extends true ? never : T
|
||||
|
||||
type PathConcat<
|
||||
TKey extends string,
|
||||
TValue,
|
||||
N = TrimSlash<TKey>
|
||||
> = TValue extends string
|
||||
? ExcludeEmptyKey<N>
|
||||
:
|
||||
| ExcludeEmptyKey<N>
|
||||
| `${N & string}${IsNever<ExcludeEmptyKey<N>> extends true
|
||||
? ''
|
||||
: '/'}${UnionPath<TValue>}`
|
||||
|
||||
type UnionPath<T> = {
|
||||
[K in keyof T]-?: PathConcat<K & string, T[K]>
|
||||
}[keyof T]
|
||||
|
||||
type MakeSureLeftSlash<T> = T extends any
|
||||
? `/${TrimRightSlash<T & string>}`
|
||||
: never
|
||||
|
||||
// exclude `/*`, because it always at the top of the IDE tip list
|
||||
type Path<T, K = UnionPath<T>> = Exclude<MakeSureLeftSlash<K>, '/*'> | IndexRoutePathname
|
||||
50
src/.umi/core/plugin.ts
Normal file
50
src/.umi/core/plugin.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import * as Plugin_0 from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/src/app.ts';
|
||||
import * as Plugin_1 from '@@/core/helmet.ts';
|
||||
import * as Plugin_2 from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/src/.umi/plugin-model/runtime.tsx';
|
||||
import { PluginManager } from 'umi';
|
||||
|
||||
function __defaultExport (obj) {
|
||||
if (obj.default) {
|
||||
return typeof obj.default === 'function' ? obj.default() : obj.default
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
export function getPlugins() {
|
||||
return [
|
||||
{
|
||||
apply: __defaultExport(Plugin_0),
|
||||
path: process.env.NODE_ENV === 'production' ? void 0 : 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/src/app.ts',
|
||||
},
|
||||
{
|
||||
apply: Plugin_1,
|
||||
path: process.env.NODE_ENV === 'production' ? void 0 : '@@/core/helmet.ts',
|
||||
},
|
||||
{
|
||||
apply: Plugin_2,
|
||||
path: process.env.NODE_ENV === 'production' ? void 0 : 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/src/.umi/plugin-model/runtime.tsx',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getValidKeys() {
|
||||
return ['patchRoutes','patchClientRoutes','modifyContextOpts','modifyClientRenderOpts','rootContainer','innerProvider','i18nProvider','accessProvider','dataflowProvider','outerProvider','render','onRouteChange','antd','qiankun','request',];
|
||||
}
|
||||
|
||||
let pluginManager = null;
|
||||
|
||||
export function createPluginManager() {
|
||||
pluginManager = PluginManager.create({
|
||||
plugins: getPlugins(),
|
||||
validKeys: getValidKeys(),
|
||||
});
|
||||
|
||||
|
||||
return pluginManager;
|
||||
}
|
||||
|
||||
export function getPluginManager() {
|
||||
return pluginManager;
|
||||
}
|
||||
406
src/.umi/core/pluginConfig.ts
Normal file
406
src/.umi/core/pluginConfig.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import { IConfigFromPluginsJoi } from "./pluginConfigJoi.d";
|
||||
|
||||
interface IConfigTypes {
|
||||
codeSplitting: {
|
||||
jsStrategy: "bigVendors" | "depPerChunk" | "granularChunks";
|
||||
jsStrategyOptions?: ({
|
||||
|
||||
} | undefined);
|
||||
cssStrategy?: ("mergeAll" | undefined);
|
||||
cssStrategyOptions?: ({
|
||||
|
||||
} | undefined);
|
||||
};
|
||||
title: string;
|
||||
styles: Array<string | {
|
||||
src?: (string | undefined);
|
||||
} | {
|
||||
content?: (string | undefined);
|
||||
} | { [x: string]: any }>;
|
||||
scripts: Array<string | {
|
||||
src?: (string | undefined);
|
||||
} | {
|
||||
content?: (string | undefined);
|
||||
} | { [x: string]: any }>;
|
||||
routes: Array<{
|
||||
component?: (string | undefined);
|
||||
layout?: (false | undefined);
|
||||
path?: (string | undefined);
|
||||
redirect?: (string | undefined);
|
||||
routes?: IConfigTypes['routes'];
|
||||
wrappers?: (Array<string> | undefined);
|
||||
} | { [x: string]: any }>;
|
||||
routeLoader: {
|
||||
moduleType: "esm" | "cjs";
|
||||
};
|
||||
reactRouter5Compat: boolean | {
|
||||
|
||||
};
|
||||
presets: Array<string>;
|
||||
plugins: Array<string>;
|
||||
npmClient: "pnpm" | "tnpm" | "cnpm" | "yarn" | "npm";
|
||||
mountElementId: string;
|
||||
metas: Array<{
|
||||
charset?: (string | undefined);
|
||||
content?: (string | undefined);
|
||||
"http-equiv"?: (string | undefined);
|
||||
name?: (string | undefined);
|
||||
} | { [x: string]: any }>;
|
||||
links: Array<{
|
||||
crossorigin?: (string | undefined);
|
||||
href?: (string | undefined);
|
||||
hreflang?: (string | undefined);
|
||||
media?: (string | undefined);
|
||||
referrerpolicy?: (string | undefined);
|
||||
rel?: (string | undefined);
|
||||
sizes?: (any | undefined);
|
||||
title?: (any | undefined);
|
||||
type?: (any | undefined);
|
||||
} | { [x: string]: any }>;
|
||||
historyWithQuery: {
|
||||
|
||||
};
|
||||
history: {
|
||||
type: "browser" | "hash" | "memory";
|
||||
};
|
||||
headScripts: Array<string | {
|
||||
src?: (string | undefined);
|
||||
} | {
|
||||
content?: (string | undefined);
|
||||
} | { [x: string]: any }>;
|
||||
esbuildMinifyIIFE: boolean;
|
||||
conventionRoutes: {
|
||||
base?: (string | undefined);
|
||||
exclude?: (Array<any> | undefined);
|
||||
};
|
||||
conventionLayout: boolean;
|
||||
base: string;
|
||||
analyze: {
|
||||
|
||||
};
|
||||
writeToDisk: boolean;
|
||||
transformRuntime: { [x: string]: any };
|
||||
theme: { [x: string]: any };
|
||||
targets: { [x: string]: any };
|
||||
svgr: { [x: string]: any };
|
||||
svgo: { [x: string]: any } | boolean;
|
||||
stylusLoader: { [x: string]: any };
|
||||
styleLoader: { [x: string]: any };
|
||||
srcTranspilerOptions: {
|
||||
esbuild?: ({ [x: string]: any } | undefined);
|
||||
swc?: ({ [x: string]: any } | undefined);
|
||||
};
|
||||
srcTranspiler: "babel" | "esbuild" | "swc";
|
||||
sassLoader: { [x: string]: any };
|
||||
runtimePublicPath: {
|
||||
|
||||
};
|
||||
purgeCSS: { [x: string]: any };
|
||||
publicPath: string;
|
||||
proxy: { [x: string]: any } | Array<any>;
|
||||
postcssLoader: { [x: string]: any };
|
||||
outputPath: string;
|
||||
normalCSSLoaderModules: { [x: string]: any };
|
||||
mfsu: {
|
||||
cacheDirectory?: (string | undefined);
|
||||
chainWebpack?: (((...args: any[]) => unknown) | undefined);
|
||||
esbuild?: (boolean | undefined);
|
||||
exclude?: (Array<string | any> | undefined);
|
||||
include?: (Array<string> | undefined);
|
||||
mfName?: (string | undefined);
|
||||
remoteAliases?: (Array<string> | undefined);
|
||||
remoteName?: (string | undefined);
|
||||
runtimePublicPath?: (boolean | undefined);
|
||||
shared?: ({ [x: string]: any } | undefined);
|
||||
strategy?: ("eager" | "normal" | undefined);
|
||||
} | boolean;
|
||||
mdx: {
|
||||
loader?: (string | undefined);
|
||||
loaderOptions?: ({ [x: string]: any } | undefined);
|
||||
};
|
||||
manifest: {
|
||||
basePath?: (string | undefined);
|
||||
fileName?: (string | undefined);
|
||||
};
|
||||
lessLoader: { [x: string]: any };
|
||||
jsMinifierOptions: { [x: string]: any };
|
||||
jsMinifier: "esbuild" | "swc" | "terser" | "uglifyJs" | "none";
|
||||
inlineLimit: number;
|
||||
ignoreMomentLocale: boolean;
|
||||
https: {
|
||||
cert?: (string | undefined);
|
||||
hosts?: (Array<string> | undefined);
|
||||
http2?: (boolean | undefined);
|
||||
key?: (string | undefined);
|
||||
};
|
||||
hash: boolean;
|
||||
forkTSChecker: { [x: string]: any };
|
||||
fastRefresh: boolean;
|
||||
extraPostCSSPlugins: Array<any>;
|
||||
extraBabelPresets: Array<string | Array<any>>;
|
||||
extraBabelPlugins: Array<string | Array<any>>;
|
||||
extraBabelIncludes: Array<string | any>;
|
||||
externals: { [x: string]: any } | string | ((...args: any[]) => unknown);
|
||||
esm: {
|
||||
|
||||
};
|
||||
devtool: "cheap-source-map" | "cheap-module-source-map" | "eval" | "eval-source-map" | "eval-cheap-source-map" | "eval-cheap-module-source-map" | "eval-nosources-cheap-source-map" | "eval-nosources-cheap-module-source-map" | "eval-nosources-source-map" | "source-map" | "hidden-source-map" | "hidden-nosources-cheap-source-map" | "hidden-nosources-cheap-module-source-map" | "hidden-nosources-source-map" | "hidden-cheap-source-map" | "hidden-cheap-module-source-map" | "inline-source-map" | "inline-cheap-source-map" | "inline-cheap-module-source-map" | "inline-nosources-cheap-source-map" | "inline-nosources-cheap-module-source-map" | "inline-nosources-source-map" | "nosources-source-map" | "nosources-cheap-source-map" | "nosources-cheap-module-source-map" | boolean;
|
||||
depTranspiler: "babel" | "esbuild" | "swc" | "none";
|
||||
define: { [x: string]: any };
|
||||
deadCode: {
|
||||
context?: (string | undefined);
|
||||
detectUnusedExport?: (boolean | undefined);
|
||||
detectUnusedFiles?: (boolean | undefined);
|
||||
exclude?: (Array<string> | undefined);
|
||||
failOnHint?: (boolean | undefined);
|
||||
patterns?: (Array<string> | undefined);
|
||||
};
|
||||
cssPublicPath: string;
|
||||
cssMinifierOptions: { [x: string]: any };
|
||||
cssMinifier: "cssnano" | "esbuild" | "parcelCSS" | "none";
|
||||
cssLoaderModules: { [x: string]: any };
|
||||
cssLoader: { [x: string]: any };
|
||||
copy: Array<{
|
||||
from: string;
|
||||
to: string;
|
||||
} | string>;
|
||||
checkDepCssModules?: boolean;
|
||||
cacheDirectoryPath: string;
|
||||
babelLoaderCustomize: string;
|
||||
autoprefixer: { [x: string]: any };
|
||||
autoCSSModules: boolean;
|
||||
alias: { [x: string]: any };
|
||||
crossorigin: boolean | {
|
||||
includes?: (Array<any> | undefined);
|
||||
};
|
||||
esmi: {
|
||||
cdnOrigin: string;
|
||||
shimUrl?: (string | undefined);
|
||||
};
|
||||
exportStatic: {
|
||||
extraRoutePaths?: (((...args: any[]) => unknown) | Array<string> | undefined);
|
||||
ignorePreRenderError?: (boolean | undefined);
|
||||
};
|
||||
favicons: Array<string>;
|
||||
helmet: boolean;
|
||||
icons: {
|
||||
autoInstall?: ({
|
||||
|
||||
} | undefined);
|
||||
defaultComponentConfig?: ({
|
||||
|
||||
} | undefined);
|
||||
alias?: ({
|
||||
|
||||
} | undefined);
|
||||
include?: (Array<string> | undefined);
|
||||
};
|
||||
mock: {
|
||||
exclude?: (Array<string> | undefined);
|
||||
include?: (Array<string> | undefined);
|
||||
};
|
||||
mpa: {
|
||||
template?: (string | undefined);
|
||||
layout?: (string | undefined);
|
||||
getConfigFromEntryFile?: (boolean | undefined);
|
||||
entry?: ({
|
||||
|
||||
} | undefined);
|
||||
};
|
||||
phantomDependency: {
|
||||
exclude?: (Array<string> | undefined);
|
||||
};
|
||||
polyfill: {
|
||||
imports?: (Array<string> | undefined);
|
||||
};
|
||||
routePrefetch: {
|
||||
defaultPrefetch?: ("none" | "intent" | "render" | "viewport" | undefined);
|
||||
defaultPrefetchTimeout?: (number | undefined);
|
||||
};
|
||||
terminal: {
|
||||
|
||||
};
|
||||
tmpFiles: boolean;
|
||||
clientLoader: {
|
||||
|
||||
};
|
||||
routeProps: {
|
||||
|
||||
};
|
||||
ssr: {
|
||||
serverBuildPath?: (string | undefined);
|
||||
serverBuildTarget?: ("express" | "worker" | undefined);
|
||||
platform?: (string | undefined);
|
||||
builder?: ("esbuild" | "webpack" | "mako" | undefined);
|
||||
__INTERNAL_DO_NOT_USE_OR_YOU_WILL_BE_FIRED?: ({
|
||||
pureApp?: (boolean | undefined);
|
||||
pureHtml?: (boolean | undefined);
|
||||
} | undefined);
|
||||
useStream?: (boolean | undefined);
|
||||
};
|
||||
lowImport: {
|
||||
libs?: (Array<any> | undefined);
|
||||
css?: (string | undefined);
|
||||
};
|
||||
vite: {
|
||||
|
||||
};
|
||||
apiRoute: {
|
||||
platform?: (string | undefined);
|
||||
};
|
||||
monorepoRedirect: boolean | {
|
||||
srcDir?: (Array<string> | undefined);
|
||||
exclude?: (Array<any> | undefined);
|
||||
peerDeps?: (boolean | undefined);
|
||||
};
|
||||
test: {
|
||||
|
||||
};
|
||||
clickToComponent: {
|
||||
/** 默认情况下,点击将默认编辑器为vscode, 你可以设置编辑器 vscode 或者 vscode-insiders */
|
||||
editor?: (string | undefined);
|
||||
};
|
||||
legacy: {
|
||||
buildOnly?: (boolean | undefined);
|
||||
nodeModulesTransform?: (boolean | undefined);
|
||||
checkOutput?: (boolean | undefined);
|
||||
};
|
||||
/** 设置 babel class-properties 启用 loose
|
||||
@doc https://umijs.org/docs/api/config#classpropertiesloose */
|
||||
classPropertiesLoose: boolean | {
|
||||
|
||||
};
|
||||
ui: {
|
||||
|
||||
};
|
||||
mako: {
|
||||
plugins?: (Array<{
|
||||
load?: (((...args: any[]) => unknown) | undefined);
|
||||
generateEnd?: (((...args: any[]) => unknown) | undefined);
|
||||
}> | undefined);
|
||||
px2rem?: ({
|
||||
root?: (number | undefined);
|
||||
propBlackList?: (Array<string> | undefined);
|
||||
propWhiteList?: (Array<string> | undefined);
|
||||
selectorBlackList?: (Array<string> | undefined);
|
||||
selectorWhiteList?: (Array<string> | undefined);
|
||||
selectorDoubleList?: (Array<string> | undefined);
|
||||
} | undefined);
|
||||
experimental?: ({
|
||||
webpackSyntaxValidate?: (Array<string> | undefined);
|
||||
} | undefined);
|
||||
flexBugs?: (boolean | undefined);
|
||||
optimization?: ({
|
||||
skipModules?: (boolean | undefined);
|
||||
} | undefined);
|
||||
};
|
||||
utoopack: {
|
||||
|
||||
};
|
||||
hmrGuardian: boolean;
|
||||
forget: {
|
||||
ReactCompilerConfig?: ({
|
||||
|
||||
} | undefined);
|
||||
};
|
||||
verifyCommit: {
|
||||
scope?: (Array<string> | undefined);
|
||||
allowEmoji?: (boolean | undefined);
|
||||
};
|
||||
run: {
|
||||
globals?: (Array<string> | undefined);
|
||||
};
|
||||
access: { [x: string]: any };
|
||||
analytics: {
|
||||
baidu?: (string | undefined);
|
||||
ga?: (string | undefined);
|
||||
ga_v2?: (string | undefined);
|
||||
};
|
||||
antd: {
|
||||
dark?: (boolean | undefined);
|
||||
compact?: (boolean | undefined);
|
||||
import?: (boolean | undefined);
|
||||
style?: ("less" | "css" | undefined);
|
||||
theme?: ({
|
||||
components: { [x: string]: { [x: string]: any } };
|
||||
} | { [x: string]: any } | undefined);
|
||||
appConfig?: ({ [x: string]: any } | undefined);
|
||||
momentPicker?: (boolean | undefined);
|
||||
styleProvider?: ({ [x: string]: any } | undefined);
|
||||
configProvider?: ({
|
||||
theme: {
|
||||
components: { [x: string]: { [x: string]: any } };
|
||||
} | { [x: string]: any };
|
||||
} | { [x: string]: any } | undefined);
|
||||
};
|
||||
dva: {
|
||||
extraModels?: (Array<string> | undefined);
|
||||
immer?: ({ [x: string]: any } | undefined);
|
||||
skipModelValidate?: (boolean | undefined);
|
||||
};
|
||||
initialState: {
|
||||
loading?: (string | undefined);
|
||||
};
|
||||
layout: { [x: string]: any };
|
||||
locale: {
|
||||
default?: (string | undefined);
|
||||
useLocalStorage?: (boolean | undefined);
|
||||
baseNavigator?: (boolean | undefined);
|
||||
title?: (boolean | undefined);
|
||||
antd?: (boolean | undefined);
|
||||
baseSeparator?: (string | undefined);
|
||||
};
|
||||
mf: {
|
||||
name?: (string | undefined);
|
||||
remotes?: (Array<{
|
||||
aliasName?: (string | undefined);
|
||||
name: string;
|
||||
entry?: (string | undefined);
|
||||
entries?: ({
|
||||
|
||||
} | undefined);
|
||||
keyResolver?: (string | undefined);
|
||||
}> | undefined);
|
||||
shared?: ({ [x: string]: any } | undefined);
|
||||
library?: ({ [x: string]: any } | undefined);
|
||||
remoteHash?: (boolean | undefined);
|
||||
};
|
||||
model: {
|
||||
extraModels?: (Array<string> | undefined);
|
||||
sort?: ((((...args: any[]) => unknown) | undefined) | undefined);
|
||||
};
|
||||
moment2dayjs: {
|
||||
preset?: ("antd" | "antdv3" | "none" | undefined);
|
||||
plugins?: (Array<string> | undefined);
|
||||
};
|
||||
qiankun: {
|
||||
slave?: ({ [x: string]: any } | undefined);
|
||||
master?: ({ [x: string]: any } | undefined);
|
||||
externalQiankun?: (boolean | undefined);
|
||||
};
|
||||
reactQuery: {
|
||||
devtool?: ({ [x: string]: any } | boolean | undefined);
|
||||
queryClient?: ({ [x: string]: any } | boolean | undefined);
|
||||
};
|
||||
request: {
|
||||
dataField?: (string | undefined);
|
||||
};
|
||||
styledComponents: {
|
||||
babelPlugin?: ({ [x: string]: any } | undefined);
|
||||
};
|
||||
tailwindcss: { [x: string]: any };
|
||||
valtio: {
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
type PrettifyWithCloseable<T> = {
|
||||
[K in keyof T]: T[K] | false;
|
||||
} & {};
|
||||
|
||||
export type IConfigFromPlugins = PrettifyWithCloseable<
|
||||
IConfigFromPluginsJoi & Partial<IConfigTypes>
|
||||
>;
|
||||
7
src/.umi/core/pluginConfigJoi.d.ts
vendored
Normal file
7
src/.umi/core/pluginConfigJoi.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
// Created by Umi Plugin
|
||||
|
||||
export interface IConfigFromPluginsJoi {
|
||||
stagewise?: unknown
|
||||
}
|
||||
220
src/.umi/core/polyfill.ts
Normal file
220
src/.umi/core/polyfill.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.error.cause.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.aggregate-error.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.aggregate-error.cause.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.array.at.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.array.find-last.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.array.find-last-index.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.array.push.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.array.reduce.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.array.reduce-right.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.array.to-reversed.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.array.to-sorted.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.array.to-spliced.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.array.with.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.map.group-by.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.object.group-by.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.object.has-own.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.promise.any.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.promise.with-resolvers.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.reflect.to-string-tag.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.regexp.flags.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.string.at-alternative.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.string.is-well-formed.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.string.replace-all.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.string.to-well-formed.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.typed-array.at.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.typed-array.find-last.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.typed-array.find-last-index.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.typed-array.set.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.typed-array.to-reversed.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.typed-array.to-sorted.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/es.typed-array.with.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.suppressed-error.constructor.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array.from-async.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array.filter-out.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array.filter-reject.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array.group.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array.group-by.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array.group-by-to-map.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array.group-to-map.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array.is-template-object.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array.last-index.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array.last-item.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array.unique-by.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array-buffer.detached.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array-buffer.transfer.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.array-buffer.transfer-to-fixed-length.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-disposable-stack.constructor.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.constructor.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.as-indexed-pairs.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.async-dispose.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.drop.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.every.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.filter.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.find.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.flat-map.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.for-each.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.from.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.indexed.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.map.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.reduce.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.some.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.take.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.async-iterator.to-array.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.bigint.range.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.composite-key.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.composite-symbol.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.data-view.get-float16.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.data-view.get-uint8-clamped.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.data-view.set-float16.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.data-view.set-uint8-clamped.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.disposable-stack.constructor.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.function.demethodize.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.function.is-callable.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.function.is-constructor.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.function.metadata.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.function.un-this.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.constructor.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.as-indexed-pairs.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.dispose.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.drop.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.every.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.filter.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.find.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.flat-map.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.for-each.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.from.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.indexed.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.map.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.range.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.reduce.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.some.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.take.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.to-array.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.iterator.to-async.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.json.is-raw-json.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.json.parse.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.json.raw-json.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.delete-all.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.emplace.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.every.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.filter.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.find.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.find-key.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.from.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.includes.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.key-by.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.key-of.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.map-keys.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.map-values.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.merge.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.of.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.reduce.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.some.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.update.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.update-or-insert.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.map.upsert.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.clamp.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.deg-per-rad.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.degrees.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.fscale.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.f16round.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.iaddh.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.imulh.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.isubh.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.rad-per-deg.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.radians.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.scale.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.seeded-prng.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.signbit.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.math.umulh.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.number.from-string.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.number.range.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.object.iterate-entries.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.object.iterate-keys.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.object.iterate-values.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.observable.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.promise.try.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.reflect.define-metadata.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.reflect.delete-metadata.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.reflect.get-metadata.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.reflect.get-metadata-keys.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.reflect.get-own-metadata.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.reflect.get-own-metadata-keys.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.reflect.has-metadata.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.reflect.has-own-metadata.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.reflect.metadata.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.regexp.escape.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.add-all.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.delete-all.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.difference.v2.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.difference.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.every.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.filter.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.find.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.from.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.intersection.v2.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.intersection.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.is-disjoint-from.v2.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.is-disjoint-from.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.is-subset-of.v2.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.is-subset-of.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.is-superset-of.v2.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.is-superset-of.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.join.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.map.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.of.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.reduce.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.some.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.symmetric-difference.v2.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.symmetric-difference.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.union.v2.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.set.union.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.string.at.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.string.cooked.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.string.code-points.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.string.dedent.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.symbol.async-dispose.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.symbol.dispose.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.symbol.is-registered-symbol.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.symbol.is-registered.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.symbol.is-well-known-symbol.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.symbol.is-well-known.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.symbol.matcher.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.symbol.metadata.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.symbol.metadata-key.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.symbol.observable.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.symbol.pattern-match.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.symbol.replace-all.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.typed-array.from-async.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.typed-array.filter-out.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.typed-array.filter-reject.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.typed-array.group-by.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.typed-array.to-spliced.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.typed-array.unique-by.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.uint8-array.from-base64.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.uint8-array.from-hex.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.uint8-array.to-base64.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.uint8-array.to-hex.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.weak-map.delete-all.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.weak-map.from.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.weak-map.of.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.weak-map.emplace.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.weak-map.upsert.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.weak-set.add-all.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.weak-set.delete-all.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.weak-set.from.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/esnext.weak-set.of.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/web.dom-exception.stack.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/web.immediate.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/web.self.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/web.structured-clone.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/web.url.can-parse.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/web.url-search-params.delete.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/web.url-search-params.has.js";
|
||||
import "C:/Users/admin/Desktop/filedata/project/\u6CFD\u6797-frontend/node_modules/core-js/modules/web.url-search-params.size.js";
|
||||
import 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/regenerator-runtime/runtime.js';
|
||||
export {};
|
||||
40
src/.umi/core/route.tsx
Normal file
40
src/.umi/core/route.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import React from 'react';
|
||||
|
||||
export async function getRoutes() {
|
||||
const routes = {"1":{"path":"/","redirect":"/home","id":"1"},"2":{"path":"/home","id":"2"},"3":{"path":"/jobs","id":"3"},"4":{"path":"/manage","id":"4"},"5":{"path":"/manage/about","name":"关于","parentId":"4","id":"5"},"6":{"path":"/manage/user","name":"用户管理","parentId":"4","id":"6"},"7":{"path":"/appointment","id":"7"},"8":{"path":"/resume","id":"8"},"9":{"path":"/resume/create","id":"9"},"10":{"path":"/resume/preview","id":"10"},"11":{"path":"/admin","id":"11"},"12":{"path":"/admin","redirect":"/admin/college","parentId":"11","id":"12"},"13":{"path":"/admin/college","parentId":"11","id":"13"},"14":{"path":"/admin/staff","parentId":"11","id":"14"},"15":{"path":"/admin/student","parentId":"11","id":"15"},"16":{"path":"/admin/role","parentId":"11","id":"16"},"17":{"path":"/admin/overview","parentId":"11","id":"17"},"18":{"path":"/admin/appointment-list","parentId":"11","id":"18"},"19":{"path":"/admin/appointment-users","parentId":"11","id":"19"},"20":{"path":"/admin/task-list","parentId":"11","id":"20"},"21":{"path":"/admin/banner","parentId":"11","id":"21"},"22":{"path":"/admin/security","parentId":"11","id":"22"},"23":{"path":"/admin/user-manage","parentId":"11","id":"23"},"24":{"path":"/admin/menu-manage","parentId":"11","id":"24"},"25":{"path":"/admin/operation-log","parentId":"11","id":"25"},"26":{"path":"/login","id":"26"},"27":{"path":"*","id":"27"}} as const;
|
||||
return {
|
||||
routes,
|
||||
routeComponents: {
|
||||
'1': React.lazy(() => import('./EmptyRoute')),
|
||||
'2': React.lazy(() => import(/* webpackChunkName: "p__Home__index" */'@/pages/Home/index.tsx')),
|
||||
'3': React.lazy(() => import(/* webpackChunkName: "p__Jobs__index" */'@/pages/Jobs/index.tsx')),
|
||||
'4': React.lazy(() => import(/* webpackChunkName: "layouts__BasicLayout" */'@/layouts/BasicLayout.tsx')),
|
||||
'5': React.lazy(() => import(/* webpackChunkName: "p__About__index" */'@/pages/About/index.tsx')),
|
||||
'6': React.lazy(() => import(/* webpackChunkName: "p__User__index" */'@/pages/User/index.tsx')),
|
||||
'7': React.lazy(() => import(/* webpackChunkName: "p__Appointment__index" */'@/pages/Appointment/index.tsx')),
|
||||
'8': React.lazy(() => import(/* webpackChunkName: "p__Resume__index" */'@/pages/Resume/index.tsx')),
|
||||
'9': React.lazy(() => import(/* webpackChunkName: "p__Resume__Create__index" */'@/pages/Resume/Create/index.tsx')),
|
||||
'10': React.lazy(() => import(/* webpackChunkName: "p__Resume__Preview__index" */'@/pages/Resume/Preview/index.tsx')),
|
||||
'11': React.lazy(() => import(/* webpackChunkName: "p__Admin__index" */'@/pages/Admin/index.tsx')),
|
||||
'12': React.lazy(() => import('./EmptyRoute')),
|
||||
'13': React.lazy(() => import(/* webpackChunkName: "p__Admin__College__index" */'@/pages/Admin/College/index.tsx')),
|
||||
'14': React.lazy(() => import(/* webpackChunkName: "p__Admin__Staff__index" */'@/pages/Admin/Staff/index.tsx')),
|
||||
'15': React.lazy(() => import(/* webpackChunkName: "p__Admin__Student__index" */'@/pages/Admin/Student/index.tsx')),
|
||||
'16': React.lazy(() => import(/* webpackChunkName: "p__Admin__Role__index" */'@/pages/Admin/Role/index.tsx')),
|
||||
'17': React.lazy(() => import(/* webpackChunkName: "p__Statistics__Overview__index" */'@/pages/Statistics/Overview/index.tsx')),
|
||||
'18': React.lazy(() => import(/* webpackChunkName: "p__Admin__Placeholder" */'@/pages/Admin/Placeholder.tsx')),
|
||||
'19': React.lazy(() => import(/* webpackChunkName: "p__Admin__Placeholder" */'@/pages/Admin/Placeholder.tsx')),
|
||||
'20': React.lazy(() => import(/* webpackChunkName: "p__Admin__Placeholder" */'@/pages/Admin/Placeholder.tsx')),
|
||||
'21': React.lazy(() => import(/* webpackChunkName: "p__Admin__Placeholder" */'@/pages/Admin/Placeholder.tsx')),
|
||||
'22': React.lazy(() => import(/* webpackChunkName: "p__Admin__Placeholder" */'@/pages/Admin/Placeholder.tsx')),
|
||||
'23': React.lazy(() => import(/* webpackChunkName: "p__Admin__Placeholder" */'@/pages/Admin/Placeholder.tsx')),
|
||||
'24': React.lazy(() => import(/* webpackChunkName: "p__Admin__Placeholder" */'@/pages/Admin/Placeholder.tsx')),
|
||||
'25': React.lazy(() => import(/* webpackChunkName: "p__Admin__Placeholder" */'@/pages/Admin/Placeholder.tsx')),
|
||||
'26': React.lazy(() => import(/* webpackChunkName: "p__Login__index" */'@/pages/Login/index.tsx')),
|
||||
'27': React.lazy(() => import(/* webpackChunkName: "p__404__index" */'@/pages/404/index.tsx')),
|
||||
},
|
||||
};
|
||||
}
|
||||
37
src/.umi/core/terminal.ts
Normal file
37
src/.umi/core/terminal.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
let count = 0;
|
||||
let groupLevel = 0;
|
||||
function send(type: string, message?: string) {
|
||||
if(process.env.NODE_ENV==='production'){
|
||||
return;
|
||||
}else{
|
||||
const encodedMessage = message ? `&m=${encodeURI(message)}` : '';
|
||||
fetch(`/__umi/api/terminal?type=${type}&t=${Date.now()}&c=${count++}&g=${groupLevel}${encodedMessage}`, { mode: 'no-cors' })
|
||||
}
|
||||
}
|
||||
function prettyPrint(obj: any) {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
function stringifyObjs(objs: any[]) {
|
||||
const obj = objs.length > 1 ? objs.map(stringify).join(' ') : objs[0];
|
||||
return typeof obj === 'object' ? `${prettyPrint(obj)}` : obj.toString();
|
||||
}
|
||||
function stringify(obj: any) {
|
||||
return typeof obj === 'object' ? `${JSON.stringify(obj)}` : obj.toString();
|
||||
}
|
||||
const terminal = {
|
||||
log(...objs: any[]) { send('log', stringifyObjs(objs)) },
|
||||
info(...objs: any[]) { send('info', stringifyObjs(objs)) },
|
||||
warn(...objs: any[]) { send('warn', stringifyObjs(objs)) },
|
||||
error(...objs: any[]) { send('error', stringifyObjs(objs)) },
|
||||
group() { groupLevel++ },
|
||||
groupCollapsed() { groupLevel++ },
|
||||
groupEnd() { groupLevel && --groupLevel },
|
||||
clear() { send('clear') },
|
||||
trace(...args: any[]) { console.trace(...args) },
|
||||
profile(...args: any[]) { console.profile(...args) },
|
||||
profileEnd(...args: any[]) { console.profileEnd(...args) },
|
||||
};
|
||||
export { terminal };
|
||||
23
src/.umi/exports.ts
Normal file
23
src/.umi/exports.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
// defineApp
|
||||
export { defineApp } from './core/defineApp'
|
||||
export type { RuntimeConfig } from './core/defineApp'
|
||||
// plugins
|
||||
export { Provider, useModel } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/src/.umi/plugin-model';
|
||||
export { useRequest, UseRequestProvider, request, getRequestInstance } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/src/.umi/plugin-request';
|
||||
// plugins types.d.ts
|
||||
export * from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/src/.umi/plugin-antd/types.d';
|
||||
export * from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/src/.umi/plugin-request/types.d';
|
||||
// @umijs/renderer-*
|
||||
export { createBrowserHistory, createHashHistory, createMemoryHistory, Helmet, HelmetProvider, createSearchParams, generatePath, matchPath, matchRoutes, Navigate, NavLink, Outlet, resolvePath, useLocation, useMatch, useNavigate, useOutlet, useOutletContext, useParams, useResolvedPath, useRoutes, useSearchParams, useAppData, useClientLoaderData, useLoaderData, useRouteProps, useSelectedRoutes, useServerLoaderData, renderClient, __getRoot, Link, useRouteData, __useFetcher, withRouter } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/@umijs/renderer-react';
|
||||
export type { History, ClientLoader } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/@umijs/renderer-react'
|
||||
// umi/client/client/plugin
|
||||
export { ApplyPluginsType, PluginManager } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/umi/client/client/plugin.js';
|
||||
export { history, createHistory } from './core/history';
|
||||
export { terminal } from './core/terminal';
|
||||
// react ssr
|
||||
export const useServerInsertedHTML: Function = () => {};
|
||||
// test
|
||||
export { TestBrowser } from './testBrowser';
|
||||
53
src/.umi/plugin-antd/runtime.tsx
Normal file
53
src/.umi/plugin-antd/runtime.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import React from 'react';
|
||||
import {
|
||||
ConfigProvider,
|
||||
} from 'antd';
|
||||
import { ApplyPluginsType } from 'umi';
|
||||
import { getPluginManager } from '../core/plugin';
|
||||
|
||||
let cacheAntdConfig = null;
|
||||
|
||||
const getAntdConfig = () => {
|
||||
if(!cacheAntdConfig){
|
||||
cacheAntdConfig = getPluginManager().applyPlugins({
|
||||
key: 'antd',
|
||||
type: ApplyPluginsType.modify,
|
||||
initialValue: {
|
||||
},
|
||||
});
|
||||
}
|
||||
return cacheAntdConfig;
|
||||
}
|
||||
|
||||
function AntdProvider({ children }) {
|
||||
let container = children;
|
||||
|
||||
const [antdConfig, _setAntdConfig] = React.useState(() => {
|
||||
const {
|
||||
appConfig: _,
|
||||
...finalConfigProvider
|
||||
} = getAntdConfig();
|
||||
return finalConfigProvider
|
||||
});
|
||||
const setAntdConfig: typeof _setAntdConfig = (newConfig) => {
|
||||
_setAntdConfig(prev => {
|
||||
return merge({}, prev, typeof newConfig === 'function' ? newConfig(prev) : newConfig)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
export function rootContainer(children) {
|
||||
return (
|
||||
<AntdProvider>
|
||||
{children}
|
||||
</AntdProvider>
|
||||
);
|
||||
}
|
||||
6
src/.umi/plugin-antd/runtimeConfig.d.ts
vendored
Normal file
6
src/.umi/plugin-antd/runtimeConfig.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import type { RuntimeAntdConfig } from './types.d';
|
||||
export type IRuntimeConfig = {
|
||||
antd?: RuntimeAntdConfig
|
||||
};
|
||||
12
src/.umi/plugin-antd/types.d.ts
vendored
Normal file
12
src/.umi/plugin-antd/types.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & {};
|
||||
|
||||
type AntdConfig = Prettify<{}
|
||||
|
||||
|
||||
>;
|
||||
|
||||
export type RuntimeAntdConfig = (memo: AntdConfig) => AntdConfig;
|
||||
183
src/.umi/plugin-model/index.tsx
Normal file
183
src/.umi/plugin-model/index.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
// @ts-ignore
|
||||
import type { models as rawModels } from '@@/plugin-model/model';
|
||||
import isEqual from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/fast-deep-equal/index.js';
|
||||
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
type Models = typeof rawModels;
|
||||
|
||||
type GetNamespaces<M> = {
|
||||
[K in keyof M]: M[K] extends { namespace: string }
|
||||
? M[K]['namespace']
|
||||
: never;
|
||||
}[keyof M];
|
||||
|
||||
type Namespaces = GetNamespaces<Models>;
|
||||
|
||||
// @ts-ignore
|
||||
const Context = React.createContext<{ dispatcher: Dispatcher }>(null);
|
||||
|
||||
class Dispatcher {
|
||||
callbacks: Record<Namespaces, Set<Function>> = {};
|
||||
data: Record<Namespaces, unknown> = {};
|
||||
update = (namespace: Namespaces) => {
|
||||
if (this.callbacks[namespace]) {
|
||||
this.callbacks[namespace].forEach((cb) => {
|
||||
try {
|
||||
const data = this.data[namespace];
|
||||
cb(data);
|
||||
} catch (e) {
|
||||
cb(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface ExecutorProps {
|
||||
hook: () => any;
|
||||
onUpdate: (val: any) => void;
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
function Executor(props: ExecutorProps) {
|
||||
const { hook, onUpdate, namespace } = props;
|
||||
|
||||
const updateRef = useRef(onUpdate);
|
||||
const initialLoad = useRef(false);
|
||||
|
||||
let data: any;
|
||||
try {
|
||||
data = hook();
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`plugin-model: Invoking '${namespace || 'unknown'}' model failed:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
|
||||
// 首次执行时立刻返回初始值
|
||||
useMemo(() => {
|
||||
updateRef.current(data);
|
||||
}, []);
|
||||
|
||||
// React 16.13 后 update 函数用 useEffect 包裹
|
||||
useEffect(() => {
|
||||
if (initialLoad.current) {
|
||||
updateRef.current(data);
|
||||
} else {
|
||||
initialLoad.current = true;
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const dispatcher = new Dispatcher();
|
||||
|
||||
export function Provider(props: {
|
||||
models: Record<string, any>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Context.Provider value={{ dispatcher }}>
|
||||
{Object.keys(props.models).map((namespace) => {
|
||||
return (
|
||||
<Executor
|
||||
key={namespace}
|
||||
hook={props.models[namespace]}
|
||||
namespace={namespace}
|
||||
onUpdate={(val) => {
|
||||
dispatcher.data[namespace] = val;
|
||||
dispatcher.update(namespace);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{props.children}
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type GetModelByNamespace<M, N> = {
|
||||
[K in keyof M]: M[K] extends { namespace: string; model: unknown }
|
||||
? M[K]['namespace'] extends N
|
||||
? M[K]['model'] extends (...args: any) => any
|
||||
? ReturnType<M[K]['model']>
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
}[keyof M];
|
||||
|
||||
type Model<N> = GetModelByNamespace<Models, N>;
|
||||
type Selector<N, S> = (model: Model<N>) => S;
|
||||
|
||||
type SelectedModel<N, T> = T extends (...args: any) => any
|
||||
? ReturnType<NonNullable<T>>
|
||||
: Model<N>;
|
||||
|
||||
export function useModel<N extends Namespaces>(namespace: N): Model<N>;
|
||||
|
||||
export function useModel<N extends Namespaces, S>(
|
||||
namespace: N,
|
||||
selector: Selector<N, S>,
|
||||
): SelectedModel<N, typeof selector>;
|
||||
|
||||
export function useModel<N extends Namespaces, S>(
|
||||
namespace: N,
|
||||
selector?: Selector<N, S>,
|
||||
): SelectedModel<N, typeof selector> {
|
||||
const { dispatcher } = useContext<{ dispatcher: Dispatcher }>(Context);
|
||||
const selectorRef = useRef(selector);
|
||||
selectorRef.current = selector;
|
||||
const [state, setState] = useState(() =>
|
||||
selectorRef.current
|
||||
? selectorRef.current(dispatcher.data[namespace])
|
||||
: dispatcher.data[namespace],
|
||||
);
|
||||
const stateRef = useRef<any>(state);
|
||||
stateRef.current = state;
|
||||
|
||||
const isMount = useRef(false);
|
||||
useEffect(() => {
|
||||
isMount.current = true;
|
||||
return () => {
|
||||
isMount.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (data: any) => {
|
||||
if (!isMount.current) {
|
||||
// 如果 handler 执行过程中,组件被卸载了,则强制更新全局 data
|
||||
// TODO: 需要加个 example 测试
|
||||
setTimeout(() => {
|
||||
dispatcher.data[namespace] = data;
|
||||
dispatcher.update(namespace);
|
||||
});
|
||||
} else {
|
||||
const currentState = selectorRef.current
|
||||
? selectorRef.current(data)
|
||||
: data;
|
||||
const previousState = stateRef.current;
|
||||
if (!isEqual(currentState, previousState)) {
|
||||
// 避免 currentState 拿到的数据是老的,从而导致 isEqual 比对逻辑有问题
|
||||
stateRef.current = currentState;
|
||||
setState(currentState);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
dispatcher.callbacks[namespace] ||= new Set() as any; // rawModels 是 umi 动态生成的文件,导致前面 callback[namespace] 的类型无法推导出来,所以用 as any 来忽略掉
|
||||
dispatcher.callbacks[namespace].add(handler);
|
||||
dispatcher.update(namespace);
|
||||
|
||||
return () => {
|
||||
dispatcher.callbacks[namespace].delete(handler);
|
||||
};
|
||||
}, [namespace]);
|
||||
|
||||
return state;
|
||||
}
|
||||
6
src/.umi/plugin-model/model.ts
Normal file
6
src/.umi/plugin-model/model.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
export const models = {
|
||||
|
||||
} as const
|
||||
20
src/.umi/plugin-model/runtime.tsx
Normal file
20
src/.umi/plugin-model/runtime.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import React from 'react';
|
||||
import { Provider } from './';
|
||||
import { models as rawModels } from './model';
|
||||
|
||||
function ProviderWrapper(props: any) {
|
||||
const models = React.useMemo(() => {
|
||||
return Object.keys(rawModels).reduce((memo, key) => {
|
||||
memo[rawModels[key].namespace] = rawModels[key].model;
|
||||
return memo;
|
||||
}, {});
|
||||
}, []);
|
||||
return <Provider models={models} {...props}>{ props.children }</Provider>
|
||||
}
|
||||
|
||||
export function dataflowProvider(container, opts) {
|
||||
return <ProviderWrapper {...opts}>{ container }</ProviderWrapper>;
|
||||
}
|
||||
9
src/.umi/plugin-request/index.ts
Normal file
9
src/.umi/plugin-request/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
export {
|
||||
useRequest,
|
||||
UseRequestProvider,
|
||||
request,
|
||||
getRequestInstance,
|
||||
} from './request';
|
||||
270
src/.umi/plugin-request/request.ts
Normal file
270
src/.umi/plugin-request/request.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import axios, {
|
||||
type AxiosInstance,
|
||||
type AxiosRequestConfig,
|
||||
type AxiosResponse,
|
||||
type AxiosError,
|
||||
} from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/axios';
|
||||
import useUmiRequest, { UseRequestProvider } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/@umijs/plugins/node_modules/@ahooksjs/use-request';
|
||||
import { ApplyPluginsType } from 'umi';
|
||||
import { getPluginManager } from '../core/plugin';
|
||||
|
||||
import {
|
||||
BaseOptions,
|
||||
BasePaginatedOptions,
|
||||
BaseResult,
|
||||
CombineService,
|
||||
LoadMoreFormatReturn,
|
||||
LoadMoreOptions,
|
||||
LoadMoreOptionsWithFormat,
|
||||
LoadMoreParams,
|
||||
LoadMoreResult,
|
||||
OptionsWithFormat,
|
||||
PaginatedFormatReturn,
|
||||
PaginatedOptionsWithFormat,
|
||||
PaginatedParams,
|
||||
PaginatedResult,
|
||||
} from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/@umijs/plugins/node_modules/@ahooksjs/use-request/es/types';
|
||||
|
||||
type ResultWithData< T = any > = { data?: T; [key: string]: any };
|
||||
|
||||
function useRequest<
|
||||
R = any,
|
||||
P extends any[] = any,
|
||||
U = any,
|
||||
UU extends U = any,
|
||||
>(
|
||||
service: CombineService<R, P>,
|
||||
options: OptionsWithFormat<R, P, U, UU>,
|
||||
): BaseResult<U, P>;
|
||||
function useRequest<R extends ResultWithData = any, P extends any[] = any>(
|
||||
service: CombineService<R, P>,
|
||||
options?: BaseOptions<R['data'], P>,
|
||||
): BaseResult<R['data'], P>;
|
||||
function useRequest<R extends LoadMoreFormatReturn = any, RR = any>(
|
||||
service: CombineService<RR, LoadMoreParams<R>>,
|
||||
options: LoadMoreOptionsWithFormat<R, RR>,
|
||||
): LoadMoreResult<R>;
|
||||
function useRequest<
|
||||
R extends ResultWithData<LoadMoreFormatReturn | any> = any,
|
||||
RR extends R = any,
|
||||
>(
|
||||
service: CombineService<R, LoadMoreParams<R['data']>>,
|
||||
options: LoadMoreOptions<RR['data']>,
|
||||
): LoadMoreResult<R['data']>;
|
||||
|
||||
function useRequest<R = any, Item = any, U extends Item = any>(
|
||||
service: CombineService<R, PaginatedParams>,
|
||||
options: PaginatedOptionsWithFormat<R, Item, U>,
|
||||
): PaginatedResult<Item>;
|
||||
function useRequest<Item = any, U extends Item = any>(
|
||||
service: CombineService<
|
||||
ResultWithData<PaginatedFormatReturn<Item>>,
|
||||
PaginatedParams
|
||||
>,
|
||||
options: BasePaginatedOptions<U>,
|
||||
): PaginatedResult<Item>;
|
||||
function useRequest(service: any, options: any = {}) {
|
||||
return useUmiRequest(service, {
|
||||
formatResult: result => result?.data,
|
||||
requestMethod: (requestOptions: any) => {
|
||||
if (typeof requestOptions === 'string') {
|
||||
return request(requestOptions);
|
||||
}
|
||||
if (typeof requestOptions === 'object') {
|
||||
const { url, ...rest } = requestOptions;
|
||||
return request(url, rest);
|
||||
}
|
||||
throw new Error('request options error');
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
// request 方法 opts 参数的接口
|
||||
interface IRequestOptions extends AxiosRequestConfig {
|
||||
skipErrorHandler?: boolean;
|
||||
requestInterceptors?: IRequestInterceptorTuple[];
|
||||
responseInterceptors?: IResponseInterceptorTuple[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface IRequestOptionsWithResponse extends IRequestOptions {
|
||||
getResponse: true;
|
||||
}
|
||||
|
||||
interface IRequestOptionsWithoutResponse extends IRequestOptions{
|
||||
getResponse: false;
|
||||
}
|
||||
|
||||
interface IRequest{
|
||||
<T = any>(url: string, opts: IRequestOptionsWithResponse): Promise<AxiosResponse<T>>;
|
||||
<T = any>(url: string, opts: IRequestOptionsWithoutResponse): Promise<T>;
|
||||
<T = any>(url: string, opts: IRequestOptions): Promise<T>; // getResponse 默认是 false, 因此不提供该参数时,只返回 data
|
||||
<T = any>(url: string): Promise<T>; // 不提供 opts 时,默认使用 'GET' method,并且默认返回 data
|
||||
}
|
||||
|
||||
type RequestError = AxiosError | Error
|
||||
|
||||
interface IErrorHandler {
|
||||
(error: RequestError, opts: IRequestOptions): void;
|
||||
}
|
||||
type WithPromise<T> = T | Promise<T>;
|
||||
type IRequestInterceptorAxios = (config: IRequestOptions) => WithPromise<IRequestOptions>;
|
||||
type IRequestInterceptorUmiRequest = (url: string, config : IRequestOptions) => WithPromise<{ url: string, options: IRequestOptions }>;
|
||||
type IRequestInterceptor = IRequestInterceptorAxios | IRequestInterceptorUmiRequest;
|
||||
type IErrorInterceptor = (error: Error) => Promise<Error>;
|
||||
type IResponseInterceptor = <T = any>(response : AxiosResponse<T>) => WithPromise<AxiosResponse<T>> ;
|
||||
type IRequestInterceptorTuple = [IRequestInterceptor , IErrorInterceptor] | [IRequestInterceptor] | IRequestInterceptor
|
||||
type IResponseInterceptorTuple = [IResponseInterceptor, IErrorInterceptor] | [IResponseInterceptor] | IResponseInterceptor
|
||||
|
||||
export interface RequestConfig<T = any> extends AxiosRequestConfig {
|
||||
errorConfig?: {
|
||||
errorHandler?: IErrorHandler;
|
||||
errorThrower?: ( res: T ) => void
|
||||
};
|
||||
requestInterceptors?: IRequestInterceptorTuple[];
|
||||
responseInterceptors?: IResponseInterceptorTuple[];
|
||||
}
|
||||
|
||||
let requestInstance: AxiosInstance;
|
||||
let config: RequestConfig;
|
||||
const getConfig = (): RequestConfig => {
|
||||
if (config) return config;
|
||||
config = getPluginManager().applyPlugins({
|
||||
key: 'request',
|
||||
type: ApplyPluginsType.modify,
|
||||
initialValue: {},
|
||||
});
|
||||
return config;
|
||||
};
|
||||
|
||||
const getRequestInstance = (): AxiosInstance => {
|
||||
if (requestInstance) return requestInstance;
|
||||
const config = getConfig();
|
||||
requestInstance = axios.create(config);
|
||||
|
||||
config?.requestInterceptors?.forEach((interceptor) => {
|
||||
if(interceptor instanceof Array){
|
||||
requestInstance.interceptors.request.use(async (config) => {
|
||||
const { url } = config;
|
||||
if(interceptor[0].length === 2){
|
||||
const { url: newUrl, options } = await interceptor[0](url, config);
|
||||
return { ...options, url: newUrl };
|
||||
}
|
||||
return interceptor[0](config);
|
||||
}, interceptor[1]);
|
||||
} else {
|
||||
requestInstance.interceptors.request.use(async (config) => {
|
||||
const { url } = config;
|
||||
if(interceptor.length === 2){
|
||||
const { url: newUrl, options } = await interceptor(url, config);
|
||||
return { ...options, url: newUrl };
|
||||
}
|
||||
return interceptor(config);
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
config?.responseInterceptors?.forEach((interceptor) => {
|
||||
interceptor instanceof Array ?
|
||||
requestInstance.interceptors.response.use(interceptor[0], interceptor[1]):
|
||||
requestInstance.interceptors.response.use(interceptor);
|
||||
});
|
||||
|
||||
// 当响应的数据 success 是 false 的时候,抛出 error 以供 errorHandler 处理。
|
||||
requestInstance.interceptors.response.use((response) => {
|
||||
const { data } = response;
|
||||
if(data?.success === false && config?.errorConfig?.errorThrower){
|
||||
config.errorConfig.errorThrower(data);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
return requestInstance;
|
||||
};
|
||||
|
||||
const request: IRequest = (url: string, opts: any = { method: 'GET' }) => {
|
||||
const requestInstance = getRequestInstance();
|
||||
const config = getConfig();
|
||||
const { getResponse = false, requestInterceptors, responseInterceptors } = opts;
|
||||
const requestInterceptorsToEject = requestInterceptors?.map((interceptor) => {
|
||||
if(interceptor instanceof Array){
|
||||
return requestInstance.interceptors.request.use(async (config) => {
|
||||
const { url } = config;
|
||||
if(interceptor[0].length === 2){
|
||||
const { url: newUrl, options } = await interceptor[0](url, config);
|
||||
return { ...options, url: newUrl };
|
||||
}
|
||||
return interceptor[0](config);
|
||||
}, interceptor[1]);
|
||||
} else {
|
||||
return requestInstance.interceptors.request.use(async (config) => {
|
||||
const { url } = config;
|
||||
if(interceptor.length === 2){
|
||||
const { url: newUrl, options } = await interceptor(url, config);
|
||||
return { ...options, url: newUrl };
|
||||
}
|
||||
return interceptor(config);
|
||||
})
|
||||
}
|
||||
});
|
||||
const responseInterceptorsToEject = responseInterceptors?.map((interceptor) => {
|
||||
return interceptor instanceof Array ?
|
||||
requestInstance.interceptors.response.use(interceptor[0], interceptor[1]):
|
||||
requestInstance.interceptors.response.use(interceptor);
|
||||
});
|
||||
return new Promise((resolve, reject)=>{
|
||||
requestInstance
|
||||
.request({...opts, url})
|
||||
.then((res)=>{
|
||||
requestInterceptorsToEject?.forEach((interceptor) => {
|
||||
requestInstance.interceptors.request.eject(interceptor);
|
||||
});
|
||||
responseInterceptorsToEject?.forEach((interceptor) => {
|
||||
requestInstance.interceptors.response.eject(interceptor);
|
||||
});
|
||||
resolve(getResponse ? res : res.data);
|
||||
})
|
||||
.catch((error)=>{
|
||||
requestInterceptorsToEject?.forEach((interceptor) => {
|
||||
requestInstance.interceptors.request.eject(interceptor);
|
||||
});
|
||||
responseInterceptorsToEject?.forEach((interceptor) => {
|
||||
requestInstance.interceptors.response.eject(interceptor);
|
||||
});
|
||||
try {
|
||||
const handler =
|
||||
config?.errorConfig?.errorHandler;
|
||||
if(handler)
|
||||
handler(error, opts, config);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
reject(error);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
useRequest,
|
||||
UseRequestProvider,
|
||||
request,
|
||||
getRequestInstance,
|
||||
};
|
||||
|
||||
export type {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
AxiosError,
|
||||
RequestError,
|
||||
IRequestInterceptorAxios as RequestInterceptorAxios,
|
||||
IRequestInterceptorUmiRequest as RequestInterceptorUmiRequest,
|
||||
IRequestInterceptor as RequestInterceptor,
|
||||
IErrorInterceptor as ErrorInterceptor,
|
||||
IResponseInterceptor as ResponseInterceptor,
|
||||
IRequestOptions as RequestOptions,
|
||||
IRequest as Request,
|
||||
};
|
||||
6
src/.umi/plugin-request/runtimeConfig.d.ts
vendored
Normal file
6
src/.umi/plugin-request/runtimeConfig.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import type { RequestConfig } from './types.d'
|
||||
export type IRuntimeConfig = {
|
||||
request?: RequestConfig
|
||||
};
|
||||
16
src/.umi/plugin-request/types.d.ts
vendored
Normal file
16
src/.umi/plugin-request/types.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
export type {
|
||||
RequestConfig,
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
AxiosError,
|
||||
RequestError,
|
||||
RequestInterceptorAxios,
|
||||
RequestInterceptorUmiRequest,
|
||||
RequestInterceptor,
|
||||
ErrorInterceptor,
|
||||
ResponseInterceptor,
|
||||
RequestOptions,
|
||||
Request } from './request';
|
||||
89
src/.umi/testBrowser.tsx
Normal file
89
src/.umi/testBrowser.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ApplyPluginsType } from 'umi';
|
||||
import { renderClient, RenderClientOpts } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/@umijs/renderer-react';
|
||||
import { createHistory } from './core/history';
|
||||
import { createPluginManager } from './core/plugin';
|
||||
import { getRoutes } from './core/route';
|
||||
import type { Location } from 'history';
|
||||
|
||||
|
||||
import 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/src/global.less';
|
||||
import 'antd/dist/reset.css';
|
||||
const publicPath = '/';
|
||||
const runtimePublicPath = false;
|
||||
|
||||
type TestBrowserProps = {
|
||||
location?: Partial<Location>;
|
||||
historyRef?: React.MutableRefObject<Location>;
|
||||
};
|
||||
|
||||
export function TestBrowser(props: TestBrowserProps) {
|
||||
const pluginManager = createPluginManager();
|
||||
const [context, setContext] = useState<RenderClientOpts | undefined>(
|
||||
undefined
|
||||
);
|
||||
useEffect(() => {
|
||||
const genContext = async () => {
|
||||
const { routes, routeComponents } = await getRoutes(pluginManager);
|
||||
// allow user to extend routes
|
||||
await pluginManager.applyPlugins({
|
||||
key: 'patchRoutes',
|
||||
type: ApplyPluginsType.event,
|
||||
args: {
|
||||
routes,
|
||||
routeComponents,
|
||||
},
|
||||
});
|
||||
const contextOpts = pluginManager.applyPlugins({
|
||||
key: 'modifyContextOpts',
|
||||
type: ApplyPluginsType.modify,
|
||||
initialValue: {},
|
||||
});
|
||||
const basename = contextOpts.basename || '/';
|
||||
const history = createHistory({
|
||||
type: 'memory',
|
||||
basename,
|
||||
});
|
||||
const context = {
|
||||
routes,
|
||||
routeComponents,
|
||||
pluginManager,
|
||||
rootElement: contextOpts.rootElement || document.getElementById('root'),
|
||||
publicPath,
|
||||
runtimePublicPath,
|
||||
history,
|
||||
basename,
|
||||
components: true,
|
||||
};
|
||||
const modifiedContext = pluginManager.applyPlugins({
|
||||
key: 'modifyClientRenderOpts',
|
||||
type: ApplyPluginsType.modify,
|
||||
initialValue: context,
|
||||
});
|
||||
return modifiedContext;
|
||||
};
|
||||
genContext().then((context) => {
|
||||
setContext(context);
|
||||
if (props.location) {
|
||||
context?.history?.push(props.location);
|
||||
}
|
||||
if (props.historyRef) {
|
||||
props.historyRef.current = context?.history;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (context === undefined) {
|
||||
return <div id="loading" />;
|
||||
}
|
||||
|
||||
const Children = renderClient(context);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Children />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
44
src/.umi/tsconfig.json
Normal file
44
src/.umi/tsconfig.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": "../../",
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
],
|
||||
"@@/*": [
|
||||
"src/.umi/*"
|
||||
],
|
||||
"@umijs/max": [
|
||||
"../../node_modules/umi"
|
||||
],
|
||||
"@umijs/max/typings": [
|
||||
"src/.umi/typings"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"../../.umirc.ts",
|
||||
"../../.umirc.*.ts",
|
||||
"../../**/*.d.ts",
|
||||
"../../**/*.ts",
|
||||
"../../**/*.tsx"
|
||||
]
|
||||
}
|
||||
136
src/.umi/typings.d.ts
vendored
Normal file
136
src/.umi/typings.d.ts
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
type CSSModuleClasses = { readonly [key: string]: string }
|
||||
declare module '*.css' {
|
||||
const classes: CSSModuleClasses
|
||||
export default classes
|
||||
}
|
||||
declare module '*.scss' {
|
||||
const classes: CSSModuleClasses
|
||||
export default classes
|
||||
}
|
||||
declare module '*.sass' {
|
||||
const classes: CSSModuleClasses
|
||||
export default classes
|
||||
}
|
||||
declare module '*.less' {
|
||||
const classes: CSSModuleClasses
|
||||
export default classes
|
||||
}
|
||||
declare module '*.styl' {
|
||||
const classes: CSSModuleClasses
|
||||
export default classes
|
||||
}
|
||||
declare module '*.stylus' {
|
||||
const classes: CSSModuleClasses
|
||||
export default classes
|
||||
}
|
||||
|
||||
// images
|
||||
declare module '*.jpg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.jpeg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.gif' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.svg' {
|
||||
import * as React from 'react';
|
||||
export const ReactComponent: React.FunctionComponent<React.SVGProps<
|
||||
SVGSVGElement
|
||||
> & { title?: string }>;
|
||||
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.ico' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.webp' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.avif' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
// media
|
||||
declare module '*.mp4' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.webm' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.ogg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.mp3' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.wav' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.flac' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.aac' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
// fonts
|
||||
declare module '*.woff' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.woff2' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.eot' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.ttf' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.otf' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
// other
|
||||
declare module '*.wasm' {
|
||||
const initWasm: (options: WebAssembly.Imports) => Promise<WebAssembly.Exports>
|
||||
export default initWasm
|
||||
}
|
||||
declare module '*.webmanifest' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.pdf' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.txt' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
82
src/.umi/umi.ts
Normal file
82
src/.umi/umi.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// @ts-nocheck
|
||||
// This file is generated by Umi automatically
|
||||
// DO NOT CHANGE IT MANUALLY!
|
||||
import './core/polyfill';
|
||||
import 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/src/global.less';
|
||||
import 'antd/dist/reset.css';
|
||||
import { renderClient } from 'C:/Users/admin/Desktop/filedata/project/泽林-frontend/node_modules/@umijs/renderer-react';
|
||||
import { getRoutes } from './core/route';
|
||||
import { createPluginManager } from './core/plugin';
|
||||
import { createHistory } from './core/history';
|
||||
import { ApplyPluginsType } from 'umi';
|
||||
|
||||
|
||||
const publicPath = "/";
|
||||
const runtimePublicPath = false;
|
||||
|
||||
async function render() {
|
||||
const pluginManager = createPluginManager();
|
||||
const { routes, routeComponents } = await getRoutes(pluginManager);
|
||||
|
||||
// allow user to extend routes
|
||||
await pluginManager.applyPlugins({
|
||||
key: 'patchRoutes',
|
||||
type: ApplyPluginsType.event,
|
||||
args: {
|
||||
routes,
|
||||
routeComponents,
|
||||
},
|
||||
});
|
||||
|
||||
const contextOpts = pluginManager.applyPlugins({
|
||||
key: 'modifyContextOpts',
|
||||
type: ApplyPluginsType.modify,
|
||||
initialValue: {},
|
||||
});
|
||||
|
||||
const basename = contextOpts.basename || '/';
|
||||
const historyType = contextOpts.historyType || 'browser';
|
||||
|
||||
const history = createHistory({
|
||||
type: historyType,
|
||||
basename,
|
||||
...contextOpts.historyOpts,
|
||||
});
|
||||
|
||||
return (pluginManager.applyPlugins({
|
||||
key: 'render',
|
||||
type: ApplyPluginsType.compose,
|
||||
initialValue() {
|
||||
const context = {
|
||||
useStream: true,
|
||||
routes,
|
||||
routeComponents,
|
||||
pluginManager,
|
||||
mountElementId: 'root',
|
||||
rootElement: contextOpts.rootElement || document.getElementById('root'),
|
||||
publicPath,
|
||||
runtimePublicPath,
|
||||
history,
|
||||
historyType,
|
||||
basename,
|
||||
__INTERNAL_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: {"pureApp":false,"pureHtml":false},
|
||||
callback: contextOpts.callback,
|
||||
};
|
||||
const modifiedContext = pluginManager.applyPlugins({
|
||||
key: 'modifyClientRenderOpts',
|
||||
type: ApplyPluginsType.modify,
|
||||
initialValue: context,
|
||||
});
|
||||
return renderClient(modifiedContext);
|
||||
},
|
||||
}))();
|
||||
}
|
||||
|
||||
|
||||
render();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.g_umi = {
|
||||
version: '4.6.26',
|
||||
};
|
||||
}
|
||||
63
src/app.ts
Normal file
63
src/app.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { message } from 'antd';
|
||||
import type { RequestConfig } from '@umijs/max';
|
||||
import { history } from '@umijs/max';
|
||||
|
||||
const codeMessage: Record<number, string> = {
|
||||
40000: '请求参数错误',
|
||||
40100: '未登录',
|
||||
40101: '无权限',
|
||||
40102: 'Token已过期',
|
||||
40103: 'Token无效',
|
||||
40300: '禁止访问',
|
||||
40400: '请求数据不存在',
|
||||
50000: '系统内部异常',
|
||||
50001: '操作失败',
|
||||
};
|
||||
|
||||
export const request: RequestConfig = {
|
||||
timeout: 10000,
|
||||
|
||||
requestInterceptors: [
|
||||
(config: any) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
],
|
||||
|
||||
responseInterceptors: [
|
||||
(response: any) => {
|
||||
const data = response.data;
|
||||
|
||||
if (data?.code === 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const code = data?.code;
|
||||
const msg = data?.message || codeMessage[code] || '请求失败';
|
||||
|
||||
// 未登录 / Token过期 / Token无效 → 跳转登录页
|
||||
if (code === 40100 || code === 40102 || code === 40103) {
|
||||
localStorage.removeItem('token');
|
||||
message.error(msg);
|
||||
history.push('/login');
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
|
||||
// 禁止访问
|
||||
if (code === 40300) {
|
||||
message.error(msg);
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
|
||||
// 其他业务错误码:不自动弹提示,交给页面自行处理
|
||||
// 页面可通过 res.code !== 0 判断并展示 res.message
|
||||
return response;
|
||||
},
|
||||
],
|
||||
};
|
||||
82
src/components/JobCard/index.less
Normal file
82
src/components/JobCard/index.less
Normal file
@@ -0,0 +1,82 @@
|
||||
.jobCard {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.jobCardBody {
|
||||
padding: 20px 20px 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.jobCardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.jobTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.jobSalary {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #E84C4C;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jobCompany {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.addressIcon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jobTags {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.jobBadgeBar {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
background: linear-gradient(91deg, #D7F0EF 0%, #FCFBFB 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badgeImg {
|
||||
height: 24px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badgeText {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
63
src/components/JobCard/index.tsx
Normal file
63
src/components/JobCard/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import styles from './index.less';
|
||||
|
||||
const SOURCE_LOGO_MAP: Record<string, string> = {
|
||||
'校方': '/assets/logo.png',
|
||||
'24365': '/assets/zw-logo.jpg',
|
||||
'国聘': '/assets/guoping.png',
|
||||
};
|
||||
|
||||
export interface JobItem {
|
||||
id: number;
|
||||
title: string;
|
||||
salary: string;
|
||||
company: string;
|
||||
experience: string;
|
||||
companySize: string;
|
||||
industry: string;
|
||||
source: string;
|
||||
sourceIcon: string;
|
||||
positionType: string;
|
||||
workLocation: string;
|
||||
educationRequirement: string;
|
||||
sourceUrl: string;
|
||||
publishDate: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface JobCardProps {
|
||||
job: JobItem;
|
||||
}
|
||||
|
||||
const JobCard: React.FC<JobCardProps> = ({ job }) => {
|
||||
const sourceLogo = SOURCE_LOGO_MAP[job.source] || job.sourceIcon || '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.jobCard}
|
||||
onClick={() => job.sourceUrl && window.open(job.sourceUrl, '_blank')}
|
||||
>
|
||||
<div className={styles.jobCardBody}>
|
||||
<div className={styles.jobCardHeader}>
|
||||
<span className={styles.jobTitle}>{job.title}</span>
|
||||
<span className={styles.jobSalary}>{job.salary}</span>
|
||||
</div>
|
||||
<div className={styles.jobCompany}>
|
||||
<img src="/icons/Address.svg" alt="地址" className={styles.addressIcon} />
|
||||
{job.company}
|
||||
</div>
|
||||
<div className={styles.jobTags}>
|
||||
{[job.experience, job.companySize, job.industry].filter(Boolean).join(' ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.jobBadgeBar}>
|
||||
{sourceLogo && (
|
||||
<img src={sourceLogo} alt={job.source} className={styles.badgeImg} />
|
||||
)}
|
||||
{job.source && <span className={styles.badgeText}>{job.source}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobCard;
|
||||
14
src/config/login.ts
Normal file
14
src/config/login.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// 登录页面配置 - 可自行修改logo和背景图
|
||||
export const loginConfig = {
|
||||
// 系统标题
|
||||
title: 'AI就业平台',
|
||||
|
||||
// Logo图片路径 - 替换为您的logo路径
|
||||
logo: '/assets/logo.png',
|
||||
|
||||
// Logo旁的文字
|
||||
logoText: '',
|
||||
|
||||
// 背景图片路径 - 替换为您的背景图路径
|
||||
backgroundImage: '/assets/loginbg.png',
|
||||
};
|
||||
72
src/global.less
Normal file
72
src/global.less
Normal file
@@ -0,0 +1,72 @@
|
||||
@font-face {
|
||||
font-family: 'HuXiaoBo-NanShen';
|
||||
src: url('/fonts/胡晓波男神体.otf') format('opentype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'HuXiaoBo-ZhenShuai';
|
||||
src: url('/fonts/胡晓波真帅体.otf') format('opentype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'HuXiaoBo-SaoBao';
|
||||
src: url('/fonts/胡晓波骚包体.otf') format('opentype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
}
|
||||
|
||||
/* ===== 全局确认弹窗样式 ===== */
|
||||
.custom-confirm-modal {
|
||||
.ant-modal-content {
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-modal-confirm-body-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ant-modal-confirm-body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.ant-modal-confirm-btns {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
14
src/layouts/BasicLayout.less
Normal file
14
src/layouts/BasicLayout.less
Normal file
@@ -0,0 +1,14 @@
|
||||
// 去掉 Sider 的默认背景色
|
||||
:global {
|
||||
.ant-layout-sider {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ant-layout-sider-children {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ant-layout-sider-trigger {
|
||||
background: rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
}
|
||||
166
src/layouts/BasicLayout.tsx
Normal file
166
src/layouts/BasicLayout.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from '@umijs/max';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
BankOutlined,
|
||||
BarChartOutlined,
|
||||
CalendarOutlined,
|
||||
TrophyOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import './BasicLayout.less';
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '/admin',
|
||||
icon: <BankOutlined />,
|
||||
label: '学校管理',
|
||||
children: [
|
||||
{
|
||||
key: '/admin/college',
|
||||
label: '学院管理',
|
||||
},
|
||||
{
|
||||
key: '/admin/staff',
|
||||
label: '教职工管理',
|
||||
},
|
||||
{
|
||||
key: '/admin/student',
|
||||
label: '学生管理',
|
||||
},
|
||||
{
|
||||
key: '/admin/position',
|
||||
label: '角色管理',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/admin/overview',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '数据总览',
|
||||
},
|
||||
{
|
||||
key: '/appointment',
|
||||
icon: <CalendarOutlined />,
|
||||
label: '预约管理',
|
||||
},
|
||||
{
|
||||
key: '/career',
|
||||
icon: <TrophyOutlined />,
|
||||
label: '就业能力提升',
|
||||
},
|
||||
{
|
||||
key: '/system',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统配置',
|
||||
},
|
||||
];
|
||||
|
||||
const BasicLayout: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
width={240}
|
||||
theme="light"
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url(/admin/background.png)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 2, height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
margin: '16px 16px 24px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#1890ff',
|
||||
fontSize: collapsed ? 16 : 20,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{collapsed ? 'AI' : 'AI就业平台'}
|
||||
</div>
|
||||
<Menu
|
||||
theme="light"
|
||||
selectedKeys={[location.pathname]}
|
||||
defaultOpenKeys={['/admin']}
|
||||
mode="inline"
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#52c41a',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<UserOutlined style={{ marginRight: 8 }} />
|
||||
{!collapsed && '管理员'}
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header
|
||||
style={{
|
||||
padding: '0 24px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18, fontWeight: 'bold' }}>泽林前端</span>
|
||||
</Header>
|
||||
<Content style={{ margin: '24px 16px', padding: 24, background: '#fff' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicLayout;
|
||||
22
src/pages/404/index.tsx
Normal file
22
src/pages/404/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Button, Result } from 'antd';
|
||||
import { useNavigate } from '@umijs/max';
|
||||
|
||||
const NotFound: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title="404"
|
||||
subTitle="抱歉,您访问的页面不存在。"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/home')}>
|
||||
返回首页
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
37
src/pages/About/index.tsx
Normal file
37
src/pages/About/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography } from 'antd';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const About: React.FC = () => {
|
||||
return (
|
||||
<Card>
|
||||
<Typography>
|
||||
<Title level={3}>关于我们</Title>
|
||||
<Paragraph>
|
||||
泽林管理系统是一个基于 Umi4 + Ant Design 构建的现代化前端项目模板。
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<strong>技术栈:</strong>
|
||||
</Paragraph>
|
||||
<ul>
|
||||
<li>Umi 4 - 企业级前端框架</li>
|
||||
<li>Ant Design 5 - 企业级 UI 组件库</li>
|
||||
<li>React 18 - JavaScript 库</li>
|
||||
<li>TypeScript - 类型安全</li>
|
||||
</ul>
|
||||
<Paragraph>
|
||||
<strong>功能特性:</strong>
|
||||
</Paragraph>
|
||||
<ul>
|
||||
<li>路由配置</li>
|
||||
<li>Mock 数据模拟</li>
|
||||
<li>全局反向代理</li>
|
||||
<li>布局组件</li>
|
||||
</ul>
|
||||
</Typography>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
240
src/pages/Admin/College/index.less
Normal file
240
src/pages/Admin/College/index.less
Normal file
@@ -0,0 +1,240 @@
|
||||
.page {
|
||||
background: #fff;
|
||||
border-radius: 0 12px 12px 12px;
|
||||
min-height: calc(100vh - 60px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loadingWrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.addCollegeBtn {
|
||||
color: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
border-radius: 20px !important;
|
||||
padding: 4px 20px !important;
|
||||
height: 36px !important;
|
||||
font-size: 14px !important;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 161, 150, 0.06) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.treeContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.treeNode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.treeChild {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
width: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #00A196;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.arrowPlaceholder {
|
||||
width: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
.label {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.labelCollege {
|
||||
color: #e84393;
|
||||
background: #ffeef8;
|
||||
}
|
||||
|
||||
.labelDept {
|
||||
color: #00A196;
|
||||
background: #e6f7f5;
|
||||
}
|
||||
|
||||
.labelMajor {
|
||||
color: #00A196;
|
||||
background: #e6f7f5;
|
||||
}
|
||||
|
||||
.labelClass {
|
||||
color: #8e7cc3;
|
||||
background: #f0ecfa;
|
||||
}
|
||||
|
||||
/* Input */
|
||||
.nameInput {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
height: 38px;
|
||||
border-radius: 20px !important;
|
||||
border: 1px solid #e0e0e0 !important;
|
||||
padding: 0 16px !important;
|
||||
font-size: 14px;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btnAdd {
|
||||
height: 34px;
|
||||
border-radius: 6px !important;
|
||||
background: #00A196 !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
font-size: 13px !important;
|
||||
padding: 0 16px !important;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: #00887e !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btnDelete {
|
||||
height: 34px;
|
||||
border-radius: 6px !important;
|
||||
background: #e74c3c !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
font-size: 13px !important;
|
||||
padding: 0 16px !important;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: #c0392b !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark toast notification */
|
||||
:global {
|
||||
.darkToast {
|
||||
.ant-message-notice-content {
|
||||
background: rgba(26, 26, 26, 0.6) !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 8px 20px !important;
|
||||
box-shadow: none !important;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.darkToastContent {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Error state for empty inputs */
|
||||
.nameInputError {
|
||||
border-color: #ff4d4f !important;
|
||||
background: #fff2f0 !important;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border-color: #ff4d4f !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bottom save/cancel bar */
|
||||
.bottomBar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 12px 32px;
|
||||
background: #fafafa;
|
||||
border-top: 1px solid #eee;
|
||||
border-radius: 0 0 12px 12px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.btnSave {
|
||||
height: 36px;
|
||||
border-radius: 6px !important;
|
||||
background: #00A196 !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
font-size: 14px !important;
|
||||
padding: 0 28px !important;
|
||||
min-width: 80px;
|
||||
|
||||
&:hover {
|
||||
background: #00887e !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btnCancel {
|
||||
height: 36px;
|
||||
border-radius: 6px !important;
|
||||
background: #f5f5f5 !important;
|
||||
color: #666 !important;
|
||||
border: 1px solid #d9d9d9 !important;
|
||||
font-size: 14px !important;
|
||||
padding: 0 28px !important;
|
||||
min-width: 80px;
|
||||
|
||||
&:hover {
|
||||
color: #333 !important;
|
||||
border-color: #bbb !important;
|
||||
}
|
||||
}
|
||||
760
src/pages/Admin/College/index.tsx
Normal file
760
src/pages/Admin/College/index.tsx
Normal file
@@ -0,0 +1,760 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from '@umijs/max';
|
||||
import { Input, Button, message, Spin } from 'antd';
|
||||
import { CaretRightOutlined, CaretDownOutlined, ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { request } from '@umijs/max';
|
||||
import styles from './index.less';
|
||||
|
||||
interface ClassItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface MajorItem {
|
||||
id: string;
|
||||
name: string;
|
||||
expanded: boolean;
|
||||
classes: ClassItem[];
|
||||
}
|
||||
|
||||
interface DeptItem {
|
||||
id: string;
|
||||
name: string;
|
||||
expanded: boolean;
|
||||
majors: MajorItem[];
|
||||
}
|
||||
|
||||
interface CollegeItem {
|
||||
id: string;
|
||||
name: string;
|
||||
expanded: boolean;
|
||||
depts: DeptItem[];
|
||||
}
|
||||
|
||||
let idCounter = 1000;
|
||||
const genId = () => `new_${++idCounter}`;
|
||||
|
||||
const CollegeManage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<CollegeItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errorIds, setErrorIds] = useState<Set<string>>(new Set());
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
const focusIdRef = useRef<string | null>(null);
|
||||
|
||||
// Fetch data from API
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request('/api/admin/college');
|
||||
if (res.code === 0 && Array.isArray(res.data)) {
|
||||
const mapTree = (items: any[]): CollegeItem[] =>
|
||||
items.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
expanded: false,
|
||||
depts: (item.depts || []).map((dept: any) => ({
|
||||
id: dept.id,
|
||||
name: dept.name,
|
||||
expanded: false,
|
||||
majors: (dept.majors || []).map((major: any) => ({
|
||||
id: major.id,
|
||||
name: major.name,
|
||||
expanded: false,
|
||||
classes: (major.classes || []).map((cls: any) => ({
|
||||
id: cls.id,
|
||||
name: cls.name,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
setData(mapTree(res.data));
|
||||
}
|
||||
} catch {
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Auto-focus newly added input
|
||||
useEffect(() => {
|
||||
if (focusIdRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
const el = document.querySelector(
|
||||
`[data-id="${focusIdRef.current}"] input`,
|
||||
) as HTMLInputElement;
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
focusIdRef.current = null;
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
const updateData = (updater: (d: CollegeItem[]) => CollegeItem[]) => {
|
||||
setData((prev) => updater([...prev]));
|
||||
setErrorIds(new Set());
|
||||
};
|
||||
|
||||
// ========== Validation ==========
|
||||
const validate = (): boolean => {
|
||||
const ids = new Set<string>();
|
||||
let firstEmptyId: string | null = null;
|
||||
|
||||
const checkItems = (
|
||||
colleges: CollegeItem[],
|
||||
) => {
|
||||
for (const c of colleges) {
|
||||
if (!c.name.trim()) {
|
||||
ids.add(c.id);
|
||||
if (!firstEmptyId) firstEmptyId = c.id;
|
||||
}
|
||||
for (const dept of c.depts) {
|
||||
if (!dept.name.trim()) {
|
||||
ids.add(dept.id);
|
||||
if (!firstEmptyId) firstEmptyId = dept.id;
|
||||
}
|
||||
for (const major of dept.majors) {
|
||||
if (!major.name.trim()) {
|
||||
ids.add(major.id);
|
||||
if (!firstEmptyId) firstEmptyId = major.id;
|
||||
}
|
||||
for (const cls of major.classes) {
|
||||
if (!cls.name.trim()) {
|
||||
ids.add(cls.id);
|
||||
if (!firstEmptyId) firstEmptyId = cls.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkItems(data);
|
||||
|
||||
if (ids.size > 0) {
|
||||
setErrorIds(ids);
|
||||
message.open({
|
||||
content: (
|
||||
<span className={styles.darkToastContent}>
|
||||
<ExclamationCircleFilled style={{ marginRight: 6 }} />
|
||||
请输入名称
|
||||
</span>
|
||||
),
|
||||
className: 'darkToast',
|
||||
duration: 2,
|
||||
});
|
||||
// Expand parents of first empty and scroll to it
|
||||
if (firstEmptyId) {
|
||||
expandParentsOf(firstEmptyId);
|
||||
setTimeout(() => {
|
||||
const el = document.querySelector(
|
||||
`[data-id="${firstEmptyId}"] input`,
|
||||
) as HTMLInputElement;
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const expandParentsOf = (targetId: string) => {
|
||||
setData((prev) =>
|
||||
prev.map((c) => {
|
||||
let collegeNeedsExpand = false;
|
||||
const newDepts = c.depts.map((dept) => {
|
||||
let deptNeedsExpand = false;
|
||||
const newMajors = dept.majors.map((major) => {
|
||||
const clsFound = major.classes.some((cls) => cls.id === targetId);
|
||||
if (clsFound || major.id === targetId) {
|
||||
deptNeedsExpand = true;
|
||||
collegeNeedsExpand = true;
|
||||
return { ...major, expanded: true };
|
||||
}
|
||||
return major;
|
||||
});
|
||||
if (dept.id === targetId) {
|
||||
collegeNeedsExpand = true;
|
||||
}
|
||||
if (deptNeedsExpand || dept.id === targetId) {
|
||||
return { ...dept, expanded: true, majors: newMajors };
|
||||
}
|
||||
return { ...dept, majors: newMajors };
|
||||
});
|
||||
if (collegeNeedsExpand || c.id === targetId) {
|
||||
return { ...c, expanded: true, depts: newDepts };
|
||||
}
|
||||
return { ...c, depts: newDepts };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// ========== Save ==========
|
||||
const handleSave = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = data.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
depts: c.depts.map((dept) => ({
|
||||
id: dept.id,
|
||||
name: dept.name,
|
||||
majors: dept.majors.map((major) => ({
|
||||
id: major.id,
|
||||
name: major.name,
|
||||
classes: major.classes.map((cls) => ({
|
||||
id: cls.id,
|
||||
name: cls.name,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
const res = await request('/api/admin/college', {
|
||||
method: 'POST',
|
||||
data: { data: payload },
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
message.success('保存成功');
|
||||
navigate('/admin/college');
|
||||
} else {
|
||||
message.error(res.message || '保存失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
fetchData();
|
||||
message.info('已取消修改');
|
||||
};
|
||||
|
||||
// ========== College ==========
|
||||
const addCollege = () => {
|
||||
const newId = genId();
|
||||
updateData((d) => [
|
||||
...d,
|
||||
{ id: newId, name: '', expanded: false, depts: [] },
|
||||
]);
|
||||
focusIdRef.current = newId;
|
||||
// Scroll to bottom
|
||||
setTimeout(() => {
|
||||
treeRef.current?.scrollTo({
|
||||
top: treeRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const updateCollegeName = (id: string, name: string) => {
|
||||
updateData((d) => d.map((c) => (c.id === id ? { ...c, name } : c)));
|
||||
};
|
||||
|
||||
const toggleCollege = (id: string) => {
|
||||
setData((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, expanded: !c.expanded } : c)),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteCollege = (id: string) => {
|
||||
updateData((d) => d.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const addDept = (collegeId: string) => {
|
||||
const newId = genId();
|
||||
updateData((d) =>
|
||||
d.map((c) =>
|
||||
c.id === collegeId
|
||||
? {
|
||||
...c,
|
||||
expanded: true,
|
||||
depts: [
|
||||
...c.depts,
|
||||
{ id: newId, name: '', expanded: false, majors: [] },
|
||||
],
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
focusIdRef.current = newId;
|
||||
};
|
||||
|
||||
// ========== Department ==========
|
||||
const updateDeptName = (collegeId: string, deptId: string, name: string) => {
|
||||
updateData((d) =>
|
||||
d.map((c) =>
|
||||
c.id === collegeId
|
||||
? {
|
||||
...c,
|
||||
depts: c.depts.map((dept) =>
|
||||
dept.id === deptId ? { ...dept, name } : dept,
|
||||
),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleDept = (collegeId: string, deptId: string) => {
|
||||
setData((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === collegeId
|
||||
? {
|
||||
...c,
|
||||
depts: c.depts.map((dept) =>
|
||||
dept.id === deptId ? { ...dept, expanded: !dept.expanded } : dept,
|
||||
),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteDept = (collegeId: string, deptId: string) => {
|
||||
updateData((d) =>
|
||||
d.map((c) =>
|
||||
c.id === collegeId
|
||||
? { ...c, depts: c.depts.filter((dept) => dept.id !== deptId) }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const addMajor = (collegeId: string, deptId: string) => {
|
||||
const newId = genId();
|
||||
updateData((d) =>
|
||||
d.map((c) =>
|
||||
c.id === collegeId
|
||||
? {
|
||||
...c,
|
||||
expanded: true,
|
||||
depts: c.depts.map((dept) =>
|
||||
dept.id === deptId
|
||||
? {
|
||||
...dept,
|
||||
expanded: true,
|
||||
majors: [
|
||||
...dept.majors,
|
||||
{ id: newId, name: '', expanded: false, classes: [] },
|
||||
],
|
||||
}
|
||||
: dept,
|
||||
),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
focusIdRef.current = newId;
|
||||
};
|
||||
|
||||
// ========== Major ==========
|
||||
const updateMajorName = (
|
||||
collegeId: string,
|
||||
deptId: string,
|
||||
majorId: string,
|
||||
name: string,
|
||||
) => {
|
||||
updateData((d) =>
|
||||
d.map((c) =>
|
||||
c.id === collegeId
|
||||
? {
|
||||
...c,
|
||||
depts: c.depts.map((dept) =>
|
||||
dept.id === deptId
|
||||
? {
|
||||
...dept,
|
||||
majors: dept.majors.map((m) =>
|
||||
m.id === majorId ? { ...m, name } : m,
|
||||
),
|
||||
}
|
||||
: dept,
|
||||
),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleMajor = (collegeId: string, deptId: string, majorId: string) => {
|
||||
setData((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === collegeId
|
||||
? {
|
||||
...c,
|
||||
depts: c.depts.map((dept) =>
|
||||
dept.id === deptId
|
||||
? {
|
||||
...dept,
|
||||
majors: dept.majors.map((m) =>
|
||||
m.id === majorId ? { ...m, expanded: !m.expanded } : m,
|
||||
),
|
||||
}
|
||||
: dept,
|
||||
),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteMajor = (
|
||||
collegeId: string,
|
||||
deptId: string,
|
||||
majorId: string,
|
||||
) => {
|
||||
updateData((d) =>
|
||||
d.map((c) =>
|
||||
c.id === collegeId
|
||||
? {
|
||||
...c,
|
||||
depts: c.depts.map((dept) =>
|
||||
dept.id === deptId
|
||||
? {
|
||||
...dept,
|
||||
majors: dept.majors.filter((m) => m.id !== majorId),
|
||||
}
|
||||
: dept,
|
||||
),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const addClass = (
|
||||
collegeId: string,
|
||||
deptId: string,
|
||||
majorId: string,
|
||||
) => {
|
||||
const newId = genId();
|
||||
updateData((d) =>
|
||||
d.map((c) =>
|
||||
c.id === collegeId
|
||||
? {
|
||||
...c,
|
||||
expanded: true,
|
||||
depts: c.depts.map((dept) =>
|
||||
dept.id === deptId
|
||||
? {
|
||||
...dept,
|
||||
expanded: true,
|
||||
majors: dept.majors.map((m) =>
|
||||
m.id === majorId
|
||||
? {
|
||||
...m,
|
||||
expanded: true,
|
||||
classes: [
|
||||
...m.classes,
|
||||
{ id: newId, name: '' },
|
||||
],
|
||||
}
|
||||
: m,
|
||||
),
|
||||
}
|
||||
: dept,
|
||||
),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
focusIdRef.current = newId;
|
||||
};
|
||||
|
||||
// ========== Class ==========
|
||||
const updateClassName = (
|
||||
collegeId: string,
|
||||
deptId: string,
|
||||
majorId: string,
|
||||
classId: string,
|
||||
name: string,
|
||||
) => {
|
||||
updateData((d) =>
|
||||
d.map((c) =>
|
||||
c.id === collegeId
|
||||
? {
|
||||
...c,
|
||||
depts: c.depts.map((dept) =>
|
||||
dept.id === deptId
|
||||
? {
|
||||
...dept,
|
||||
majors: dept.majors.map((m) =>
|
||||
m.id === majorId
|
||||
? {
|
||||
...m,
|
||||
classes: m.classes.map((cls) =>
|
||||
cls.id === classId ? { ...cls, name } : cls,
|
||||
),
|
||||
}
|
||||
: m,
|
||||
),
|
||||
}
|
||||
: dept,
|
||||
),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteClass = (
|
||||
collegeId: string,
|
||||
deptId: string,
|
||||
majorId: string,
|
||||
classId: string,
|
||||
) => {
|
||||
updateData((d) =>
|
||||
d.map((c) =>
|
||||
c.id === collegeId
|
||||
? {
|
||||
...c,
|
||||
depts: c.depts.map((dept) =>
|
||||
dept.id === deptId
|
||||
? {
|
||||
...dept,
|
||||
majors: dept.majors.map((m) =>
|
||||
m.id === majorId
|
||||
? {
|
||||
...m,
|
||||
classes: m.classes.filter(
|
||||
(cls) => cls.id !== classId,
|
||||
),
|
||||
}
|
||||
: m,
|
||||
),
|
||||
}
|
||||
: dept,
|
||||
),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.loadingWrap}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.scrollArea} ref={treeRef}>
|
||||
<h2 className={styles.title}>学校管理</h2>
|
||||
|
||||
<Button className={styles.addCollegeBtn} onClick={addCollege}>
|
||||
新增学院
|
||||
</Button>
|
||||
|
||||
<div className={styles.treeContainer}>
|
||||
{data.map((college) => (
|
||||
<div key={college.id} className={styles.treeNode}>
|
||||
<div className={styles.row} data-id={college.id}>
|
||||
<span
|
||||
className={styles.arrow}
|
||||
onClick={() => toggleCollege(college.id)}
|
||||
>
|
||||
{college.expanded ? <CaretDownOutlined /> : <CaretRightOutlined />}
|
||||
</span>
|
||||
<span className={`${styles.label} ${styles.labelCollege}`}>
|
||||
学校名称
|
||||
</span>
|
||||
<Input
|
||||
className={`${styles.nameInput} ${errorIds.has(college.id) ? styles.nameInputError : ''}`}
|
||||
value={college.name}
|
||||
placeholder="请输入"
|
||||
onChange={(e) => updateCollegeName(college.id, e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className={styles.btnAdd}
|
||||
onClick={() => addDept(college.id)}
|
||||
>
|
||||
新增系
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.btnDelete}
|
||||
onClick={() => deleteCollege(college.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{college.expanded &&
|
||||
college.depts.map((dept) => (
|
||||
<div key={dept.id} className={styles.treeChild}>
|
||||
<div className={styles.row} data-id={dept.id}>
|
||||
<span
|
||||
className={styles.arrow}
|
||||
onClick={() => toggleDept(college.id, dept.id)}
|
||||
>
|
||||
{dept.expanded ? (
|
||||
<CaretDownOutlined />
|
||||
) : (
|
||||
<CaretRightOutlined />
|
||||
)}
|
||||
</span>
|
||||
<span className={`${styles.label} ${styles.labelDept}`}>
|
||||
系名称
|
||||
</span>
|
||||
<Input
|
||||
className={`${styles.nameInput} ${errorIds.has(dept.id) ? styles.nameInputError : ''}`}
|
||||
value={dept.name}
|
||||
placeholder="请输入"
|
||||
onChange={(e) =>
|
||||
updateDeptName(college.id, dept.id, e.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
className={styles.btnAdd}
|
||||
onClick={() => addMajor(college.id, dept.id)}
|
||||
>
|
||||
新增专业
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.btnDelete}
|
||||
onClick={() => deleteDept(college.id, dept.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dept.expanded &&
|
||||
dept.majors.map((major) => (
|
||||
<div key={major.id} className={styles.treeChild}>
|
||||
<div className={styles.row} data-id={major.id}>
|
||||
<span
|
||||
className={styles.arrow}
|
||||
onClick={() =>
|
||||
toggleMajor(college.id, dept.id, major.id)
|
||||
}
|
||||
>
|
||||
{major.expanded ? (
|
||||
<CaretDownOutlined />
|
||||
) : (
|
||||
<CaretRightOutlined />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.label} ${styles.labelMajor}`}
|
||||
>
|
||||
专业名称
|
||||
</span>
|
||||
<Input
|
||||
className={`${styles.nameInput} ${errorIds.has(major.id) ? styles.nameInputError : ''}`}
|
||||
value={major.name}
|
||||
placeholder="请输入"
|
||||
onChange={(e) =>
|
||||
updateMajorName(
|
||||
college.id,
|
||||
dept.id,
|
||||
major.id,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
className={styles.btnAdd}
|
||||
onClick={() =>
|
||||
addClass(college.id, dept.id, major.id)
|
||||
}
|
||||
>
|
||||
新增班级
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.btnDelete}
|
||||
onClick={() =>
|
||||
deleteMajor(college.id, dept.id, major.id)
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{major.expanded &&
|
||||
major.classes.map((cls) => (
|
||||
<div key={cls.id} className={styles.treeChild}>
|
||||
<div className={styles.row} data-id={cls.id}>
|
||||
<span className={styles.arrowPlaceholder} />
|
||||
<span
|
||||
className={`${styles.label} ${styles.labelClass}`}
|
||||
>
|
||||
班级名称
|
||||
</span>
|
||||
<Input
|
||||
className={`${styles.nameInput} ${errorIds.has(cls.id) ? styles.nameInputError : ''}`}
|
||||
value={cls.name}
|
||||
placeholder="请输入"
|
||||
onChange={(e) =>
|
||||
updateClassName(
|
||||
college.id,
|
||||
dept.id,
|
||||
major.id,
|
||||
cls.id,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
className={styles.btnDelete}
|
||||
onClick={() =>
|
||||
deleteClass(
|
||||
college.id,
|
||||
dept.id,
|
||||
major.id,
|
||||
cls.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save / Cancel bar */}
|
||||
<div className={styles.bottomBar}>
|
||||
<Button
|
||||
className={styles.btnSave}
|
||||
loading={saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button className={styles.btnCancel} onClick={handleCancel}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollegeManage;
|
||||
37
src/pages/Admin/Placeholder.tsx
Normal file
37
src/pages/Admin/Placeholder.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { useLocation } from '@umijs/max';
|
||||
|
||||
const pageTitleMap: Record<string, string> = {
|
||||
'/admin/college': '学院管理',
|
||||
'/admin/staff': '教职工管理',
|
||||
'/admin/student': '学生管理',
|
||||
'/admin/role': '角色管理',
|
||||
'/admin/overview': '数据总览',
|
||||
'/admin/appointment-list': '预约列表',
|
||||
'/admin/appointment-users': '预约人员列表',
|
||||
'/admin/task-list': '任务管理列表',
|
||||
'/admin/banner': '首页轮播图',
|
||||
'/admin/security': '安全配置页',
|
||||
'/admin/user-manage': '用户管理',
|
||||
'/admin/menu-manage': '菜单管理',
|
||||
'/admin/operation-log': '操作日志',
|
||||
};
|
||||
|
||||
const Placeholder: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const title = pageTitleMap[location.pathname] || '管理页面';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: '0 12px 12px 12px',
|
||||
padding: 32,
|
||||
minHeight: 'calc(100vh - 48px)',
|
||||
}}>
|
||||
<h2 style={{ fontSize: 20, color: '#2A3F54', marginBottom: 16 }}>{title}</h2>
|
||||
<p style={{ color: '#999', fontSize: 14 }}>该页面正在开发中…</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Placeholder;
|
||||
131
src/pages/Admin/Role/components/AddRoleModal.tsx
Normal file
131
src/pages/Admin/Role/components/AddRoleModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Tree, message } from 'antd';
|
||||
import { request } from '@umijs/max';
|
||||
import type { PermissionItem } from '../types';
|
||||
|
||||
interface AddRoleModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const AddRoleModal: React.FC<AddRoleModalProps> = ({ visible, onClose, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||
const [checkedKeys, setCheckedKeys] = useState<string[]>([]);
|
||||
|
||||
// 获取权限树
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchPermissions();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
const res = await request('/api/admin/role/permissions');
|
||||
if (res.code === 0) {
|
||||
setPermissions(res.data);
|
||||
}
|
||||
} catch {
|
||||
message.error('获取权限列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const res = await request('/api/admin/role/create', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
...values,
|
||||
permissions: checkedKeys,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
message.success('创建成功');
|
||||
onSuccess();
|
||||
handleClose();
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
} catch {
|
||||
// 表单验证失败
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
form.resetFields();
|
||||
setCheckedKeys([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 转换权限数据为树形结构
|
||||
const convertToTreeData = (items: PermissionItem[]) => {
|
||||
return items.map(item => ({
|
||||
title: item.name,
|
||||
key: item.id,
|
||||
children: item.children ? convertToTreeData(item.children) : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="新增角色"
|
||||
open={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleClose}
|
||||
confirmLoading={loading}
|
||||
width={600}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okButtonProps={{
|
||||
style: { background: '#00A196', borderColor: '#00A196' }
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="角色名称"
|
||||
rules={[{ required: true, message: '请输入角色名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入角色名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="角色描述"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="请输入角色描述"
|
||||
rows={3}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="权限配置"
|
||||
>
|
||||
<Tree
|
||||
checkable
|
||||
checkedKeys={checkedKeys}
|
||||
onCheck={(keys) => setCheckedKeys(keys as string[])}
|
||||
treeData={convertToTreeData(permissions)}
|
||||
height={300}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddRoleModal;
|
||||
267
src/pages/Admin/Role/components/EditRoleModal.less
Normal file
267
src/pages/Admin/Role/components/EditRoleModal.less
Normal file
@@ -0,0 +1,267 @@
|
||||
.editModal {
|
||||
:global {
|
||||
.ant-modal-content {
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 6px 26.25px 5px rgba(0, 0, 0, 0.05),
|
||||
0 16px 21px 2px rgba(0, 0, 0, 0.04),
|
||||
0 8px 8.75px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
height: 800px;
|
||||
}
|
||||
|
||||
.ant-modal-mask {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modalContainer {
|
||||
height: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e1e7ef;
|
||||
background: #fff;
|
||||
border-radius: 8px 8px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #141e35;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.formSection {
|
||||
padding: 32px 100px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 14px;
|
||||
color: #141e35;
|
||||
line-height: 1.57;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.formInput {
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #9eacc0;
|
||||
|
||||
&::placeholder {
|
||||
color: #9eacc0;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border-color: #00A196;
|
||||
}
|
||||
}
|
||||
|
||||
.permissionSection {
|
||||
flex: 1;
|
||||
padding: 16px 100px 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.permissionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.permissionNode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.permissionNodeChecked {
|
||||
background: #f2fdf5;
|
||||
|
||||
&:hover {
|
||||
background: #e6faf0;
|
||||
}
|
||||
}
|
||||
|
||||
.expandIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.expandIconImg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.iconPlaceholder {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid #9eacc0;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: #00A196;
|
||||
}
|
||||
}
|
||||
|
||||
.checkboxChecked {
|
||||
background: #00A196;
|
||||
border-color: #00A196;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border: 2px solid #fff;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
.permissionText {
|
||||
font-size: 14px;
|
||||
color: #141e35;
|
||||
line-height: 1.57;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid #e1e7ef;
|
||||
background: #fff;
|
||||
border-radius: 0 0 8px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.confirmBtn {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
background: #00a196;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #00b8a9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #008a7b;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #9eacc0;
|
||||
border-radius: 4px;
|
||||
color: #141e35;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #00a196;
|
||||
color: #00a196;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border-color: #008a7b;
|
||||
color: #008a7b;
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.permissionSection::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.permissionSection::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.permissionSection::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.permissionSection::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
194
src/pages/Admin/Role/components/EditRoleModal.tsx
Normal file
194
src/pages/Admin/Role/components/EditRoleModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, message } from 'antd';
|
||||
import { request } from '@umijs/max';
|
||||
import styles from './EditRoleModal.less';
|
||||
|
||||
interface Permission {
|
||||
id: string;
|
||||
name: string;
|
||||
checked?: boolean;
|
||||
children?: Permission[];
|
||||
}
|
||||
|
||||
interface EditRoleModalProps {
|
||||
visible: boolean;
|
||||
roleKey: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const EditRoleModal: React.FC<EditRoleModalProps> = ({
|
||||
visible,
|
||||
roleKey,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
const [expandedKeys, setExpandedKeys] = useState<string[]>(['user_manage', 'system_config']);
|
||||
const [checkedKeys, setCheckedKeys] = useState<string[]>(['system_config']);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchPermissions();
|
||||
fetchRoleDetail();
|
||||
}
|
||||
}, [visible, roleKey]);
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
const res = await request('/api/admin/role/permissions');
|
||||
if (res.code === 0) {
|
||||
setPermissions(res.data);
|
||||
}
|
||||
} catch {
|
||||
// 使用模拟数据
|
||||
setPermissions([
|
||||
{ id: 'user_manage', name: '权限名称一二三', children: [
|
||||
{ id: 'student_manage', name: '权限名称一二三' },
|
||||
{ id: 'staff_manage', name: '权限名称一二三', children: [
|
||||
{ id: 'staff_add', name: '权限名称一二三' },
|
||||
{ id: 'staff_edit', name: '权限名称一二三' },
|
||||
]},
|
||||
]},
|
||||
{ id: 'role_manage', name: '权限名称一二三' },
|
||||
{ id: 'system_config', name: '权限名称一二三', children: [
|
||||
{ id: 'banner_manage', name: '权限名称一二三' },
|
||||
{ id: 'security_config', name: '权限名称一二三' },
|
||||
]},
|
||||
{ id: 'data_view', name: '权限名称一二三' },
|
||||
{ id: 'profile_view', name: '权限名称一二三' },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRoleDetail = async () => {
|
||||
form.setFieldsValue({
|
||||
name: roleKey === 'super_admin' ? '超级管理员' : roleKey,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExpand = (id: string) => {
|
||||
setExpandedKeys((prev) =>
|
||||
prev.includes(id) ? prev.filter((k) => k !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const handleCheckPermission = (id: string) => {
|
||||
setCheckedKeys((prev) =>
|
||||
prev.includes(id) ? prev.filter((k) => k !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const renderPermissionNode = (node: Permission, level: number = 0) => {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isExpanded = expandedKeys.includes(node.id);
|
||||
const isChecked = checkedKeys.includes(node.id);
|
||||
|
||||
const renderLevelIcons = () => {
|
||||
const icons = [];
|
||||
for (let i = 0; i < level; i++) {
|
||||
icons.push(<div key={`placeholder-${i}`} className={styles.iconPlaceholder} />);
|
||||
}
|
||||
return icons;
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={node.id}>
|
||||
<div
|
||||
className={`${styles.permissionNode} ${isChecked ? styles.permissionNodeChecked : ''}`}
|
||||
>
|
||||
{renderLevelIcons()}
|
||||
{hasChildren ? (
|
||||
<div className={styles.expandIcon} onClick={() => handleExpand(node.id)}>
|
||||
<img
|
||||
src={isExpanded ? '/user_manager/收起.png' : '/user_manager/展开.png'}
|
||||
alt={isExpanded ? '收起' : '展开'}
|
||||
className={styles.expandIconImg}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.iconPlaceholder} />
|
||||
)}
|
||||
<div
|
||||
className={`${styles.checkbox} ${isChecked ? styles.checkboxChecked : ''}`}
|
||||
onClick={() => handleCheckPermission(node.id)}
|
||||
/>
|
||||
<span className={styles.permissionText}>{node.name}</span>
|
||||
</div>
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{node.children!.map((child) => renderPermissionNode(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
message.success('编辑成功');
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch {
|
||||
message.error('请完善表单信息');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={600}
|
||||
height={800}
|
||||
className={styles.editModal}
|
||||
centered
|
||||
maskClosable={false}
|
||||
>
|
||||
<div className={styles.modalContainer}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h3 className={styles.modalTitle}>编辑</h3>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
<div className={styles.formSection}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label={
|
||||
<div className={styles.formLabel}>
|
||||
<span className={styles.required}>*</span>
|
||||
<span>教职工姓名</span>
|
||||
</div>
|
||||
}
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入' }]}
|
||||
>
|
||||
<Input placeholder="请输入" className={styles.formInput} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
<div className={styles.permissionSection}>
|
||||
<div className={styles.permissionList}>
|
||||
{permissions.map((node) => renderPermissionNode(node))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<button className={styles.confirmBtn} onClick={handleSubmit} disabled={loading}>
|
||||
确定
|
||||
</button>
|
||||
<button className={styles.cancelBtn} onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditRoleModal;
|
||||
282
src/pages/Admin/Role/index.less
Normal file
282
src/pages/Admin/Role/index.less
Normal file
@@ -0,0 +1,282 @@
|
||||
.page {
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
height: 56px;
|
||||
border-bottom: 1px solid #e1e7ef;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #141e35;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
background: #00A196;
|
||||
border-color: #00A196;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.67;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #00B8A9 !important;
|
||||
border-color: #00B8A9 !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #008A7B !important;
|
||||
border-color: #008A7B !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.addIcon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
border-radius: 0 8px 8px 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 左侧角色面板 */
|
||||
.leftPanel {
|
||||
width: 284px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 16px 0 0;
|
||||
border-right: 1px solid #e1e7ef;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.roleList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.roleItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
}
|
||||
|
||||
.roleItemActive {
|
||||
background: #f2fdf5;
|
||||
|
||||
&:hover {
|
||||
background: #e6faf0;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #d9f7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.roleText {
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
line-height: 1.57;
|
||||
transition: color 0.2s;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.roleActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
color: #2463eb;
|
||||
cursor: pointer;
|
||||
line-height: 1.57;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
color: #dc2828;
|
||||
cursor: pointer;
|
||||
line-height: 1.57;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: #9eacc0;
|
||||
font-size: 14px;
|
||||
line-height: 1.57;
|
||||
}
|
||||
|
||||
/* 右侧组织架构面板 */
|
||||
.rightPanel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.organizationTree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.orgNode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
}
|
||||
|
||||
.orgNodeHighlighted {
|
||||
background: #f2fdf5;
|
||||
|
||||
&:hover {
|
||||
background: #e6faf0;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #d9f7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.expandIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.expandIconImg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.expandIconPlaceholder {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.orgTag {
|
||||
margin: 0;
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
border-radius: 47px;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.orgNodeText {
|
||||
font-size: 14px;
|
||||
color: #141e35;
|
||||
line-height: 1.57;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orgChildren {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
290
src/pages/Admin/Role/index.tsx
Normal file
290
src/pages/Admin/Role/index.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button, Input, Tree, Tag, message, Modal } from 'antd';
|
||||
import { SearchOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { request } from '@umijs/max';
|
||||
import type { TreeNode, OrganizationNode } from './types';
|
||||
import EditRoleModal from './components/EditRoleModal';
|
||||
import styles from './index.less';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
const RoleManage: React.FC = () => {
|
||||
const [selectedRole, setSelectedRole] = useState<string>('super_admin');
|
||||
const [expandedKeys, setExpandedKeys] = useState<string[]>(['school_1']);
|
||||
const [organizationData, setOrganizationData] = useState<OrganizationNode[]>([]);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<string>('');
|
||||
|
||||
// 角色列表
|
||||
const roles: TreeNode[] = [
|
||||
{ key: 'super_admin', title: '超级管理员', color: '#141e35' },
|
||||
{ key: 'teacher', title: '教师', color: '#141e35' },
|
||||
{ key: 'student', title: '学生', color: '#141e35' },
|
||||
];
|
||||
|
||||
// 获取组织架构数据
|
||||
useEffect(() => {
|
||||
fetchOrganizationData();
|
||||
}, []);
|
||||
|
||||
const fetchOrganizationData = async () => {
|
||||
try {
|
||||
const res = await request('/api/admin/organization');
|
||||
if (res.code === 0) {
|
||||
setOrganizationData(res.data);
|
||||
}
|
||||
} catch {
|
||||
// 使用模拟数据
|
||||
setOrganizationData([
|
||||
{
|
||||
id: 'school_1',
|
||||
name: '学校管理',
|
||||
type: 'school',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
id: 'school_2',
|
||||
name: '学校管理',
|
||||
type: 'school',
|
||||
expanded: false,
|
||||
},
|
||||
{
|
||||
id: 'dept_1',
|
||||
name: '编辑',
|
||||
type: 'department',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
id: 'major_1',
|
||||
name: '批量导入',
|
||||
type: 'major',
|
||||
expanded: false,
|
||||
children: [
|
||||
{
|
||||
id: 'class_1',
|
||||
name: '批量导入',
|
||||
type: 'class',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'major_2',
|
||||
name: '新增',
|
||||
type: 'major',
|
||||
expanded: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'school_3',
|
||||
name: '角色管理',
|
||||
type: 'school',
|
||||
expanded: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleSelect = (roleKey: string) => {
|
||||
setSelectedRole(roleKey);
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent, roleKey: string) => {
|
||||
e.stopPropagation();
|
||||
setEditingRole(roleKey);
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent, roleKey: string) => {
|
||||
e.stopPropagation();
|
||||
|
||||
Modal.confirm({
|
||||
title: '确定删除该角色吗?',
|
||||
icon: <ExclamationCircleOutlined style={{ color: '#faad14' }} />,
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okButtonProps: {
|
||||
style: { background: '#13b580', borderColor: '#13b580' },
|
||||
},
|
||||
onOk: async () => {
|
||||
try {
|
||||
// 模拟检查是否有用户关联
|
||||
const hasUsers = Math.random() > 0.5;
|
||||
|
||||
if (hasUsers) {
|
||||
message.error({
|
||||
content: '删除失败,已有用户关联该角色',
|
||||
icon: <ExclamationCircleOutlined style={{ color: '#faad14' }} />,
|
||||
style: {
|
||||
background: 'rgba(26, 26, 26, 0.6)',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
message.success('删除成功');
|
||||
// TODO: 刷新列表
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExpand = (nodeId: string) => {
|
||||
setExpandedKeys((prev) =>
|
||||
prev.includes(nodeId) ? prev.filter((k) => k !== nodeId) : [...prev, nodeId]
|
||||
);
|
||||
};
|
||||
|
||||
const getTagColor = (type: string) => {
|
||||
const colors: Record<string, { bg: string; text: string }> = {
|
||||
school: { bg: '#fff0f1', text: '#e21d48' },
|
||||
department: { bg: '#fefce7', text: '#ca8511' },
|
||||
major: { bg: '#f0f9ff', text: '#0284c5' },
|
||||
class: { bg: '#fdf5ff', text: '#bf27d3' },
|
||||
};
|
||||
return colors[type] || { bg: '#f0f9ff', text: '#0284c5' };
|
||||
};
|
||||
|
||||
const getTagLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
school: '学校名称',
|
||||
department: '系名称',
|
||||
major: '专业名称',
|
||||
class: '班级名称',
|
||||
};
|
||||
return labels[type] || '标签';
|
||||
};
|
||||
|
||||
const renderOrganizationNode = (node: OrganizationNode, level: number = 0) => {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isExpanded = expandedKeys.includes(node.id);
|
||||
const tagColor = getTagColor(node.type);
|
||||
const isHighlighted = node.id === 'major_1';
|
||||
|
||||
// 渲染多个层级的展开图标占位
|
||||
const renderLevelIcons = () => {
|
||||
const icons = [];
|
||||
for (let i = 0; i < level; i++) {
|
||||
icons.push(
|
||||
<div key={`placeholder-${i}`} className={styles.expandIconPlaceholder} />
|
||||
);
|
||||
}
|
||||
return icons;
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={node.id}>
|
||||
<div
|
||||
className={`${styles.orgNode} ${isHighlighted ? styles.orgNodeHighlighted : ''}`}
|
||||
>
|
||||
{renderLevelIcons()}
|
||||
{hasChildren ? (
|
||||
<div className={styles.expandIcon} onClick={() => handleExpand(node.id)}>
|
||||
<img
|
||||
src={isExpanded ? '/user_manager/收起.png' : '/user_manager/展开.png'}
|
||||
alt={isExpanded ? '收起' : '展开'}
|
||||
className={styles.expandIconImg}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.expandIconPlaceholder} />
|
||||
)}
|
||||
<Tag
|
||||
className={styles.orgTag}
|
||||
style={{ background: tagColor.bg, color: tagColor.text, borderColor: tagColor.bg }}
|
||||
>
|
||||
{getTagLabel(node.type)}
|
||||
</Tag>
|
||||
<span className={styles.orgNodeText}>{node.name}</span>
|
||||
</div>
|
||||
{hasChildren && isExpanded && (
|
||||
<div className={styles.orgChildren}>
|
||||
{node.children!.map((child) => renderOrganizationNode(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{/* 标题栏 */}
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>角色管理</h2>
|
||||
<div className={styles.headerActions}>
|
||||
<Button type="primary" className={styles.addBtn}>
|
||||
<img src="/user_manager/添加.png" alt="添加" className={styles.addIcon} />
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className={styles.content}>
|
||||
{/* 左侧角色树 */}
|
||||
<div className={styles.leftPanel}>
|
||||
<div className={styles.roleList}>
|
||||
{roles.map((role) => (
|
||||
<div
|
||||
key={role.key}
|
||||
className={`${styles.roleItem} ${
|
||||
selectedRole === role.key ? styles.roleItemActive : ''
|
||||
}`}
|
||||
onClick={() => handleRoleSelect(role.key)}
|
||||
>
|
||||
<span
|
||||
className={styles.roleText}
|
||||
style={{
|
||||
color: selectedRole === role.key ? '#00a196' : role.color
|
||||
}}
|
||||
>
|
||||
{role.title}
|
||||
</span>
|
||||
{selectedRole === role.key && (
|
||||
<div className={styles.roleActions}>
|
||||
<button
|
||||
className={styles.editBtn}
|
||||
onClick={(e) => handleEdit(e, role.key)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<span className={styles.divider}>|</span>
|
||||
<button
|
||||
className={styles.deleteBtn}
|
||||
onClick={(e) => handleDelete(e, role.key)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧组织架构树 */}
|
||||
<div className={styles.rightPanel}>
|
||||
<div className={styles.organizationTree}>
|
||||
{organizationData.map((node) => renderOrganizationNode(node))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditRoleModal
|
||||
visible={editModalVisible}
|
||||
roleKey={editingRole}
|
||||
onClose={() => setEditModalVisible(false)}
|
||||
onSuccess={() => {
|
||||
message.success('编辑成功');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoleManage;
|
||||
32
src/pages/Admin/Role/types.ts
Normal file
32
src/pages/Admin/Role/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface RoleRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: string[];
|
||||
status: 'active' | 'inactive';
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
export interface PermissionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: 'menu' | 'button' | 'api';
|
||||
parentId?: string;
|
||||
children?: PermissionItem[];
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
key: string;
|
||||
title: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface OrganizationNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'school' | 'department' | 'major' | 'class';
|
||||
expanded?: boolean;
|
||||
children?: OrganizationNode[];
|
||||
}
|
||||
63
src/pages/Admin/Staff/components/AddStaffModal.tsx
Normal file
63
src/pages/Admin/Staff/components/AddStaffModal.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Input, Button, message } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { request } from '@umijs/max';
|
||||
import type { StaffFormData } from '../types';
|
||||
import { initStaffForm } from '../types';
|
||||
import styles from '../index.less';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const AddStaffModal: React.FC<Props> = ({ visible, onClose, onSuccess }) => {
|
||||
const [form, setForm] = useState<StaffFormData>(initStaffForm);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tipVisible, setTipVisible] = useState(true);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name || !form.phone) {
|
||||
message.warning('请填写完整信息');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request('/api/admin/staff/add', { method: 'POST', data: form });
|
||||
if (res.code === 0) { message.success('新增成功'); handleClose(); onSuccess(); }
|
||||
else { message.error(res.message); }
|
||||
} catch { message.error('新增失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleClose = () => { setForm(initStaffForm); setTipVisible(true); onClose(); };
|
||||
|
||||
return (
|
||||
<Modal open={visible} title="新增教职工" onCancel={handleClose} footer={null} width={600} centered className={styles.addModal} destroyOnClose>
|
||||
{tipVisible && (
|
||||
<div className={styles.tipBanner}>
|
||||
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23d4622b'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Ctext x='12' y='16' text-anchor='middle' fill='white' font-size='14' font-weight='bold'%3E!%3C/text%3E%3C/svg%3E" alt="tip" className={styles.tipIcon} />
|
||||
<span className={styles.tipText}>创建成功后账号和密码均为手机号</span>
|
||||
<CloseOutlined className={styles.tipClose} onClick={() => setTipVisible(false)} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.addModalBody}>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>教职工姓名</label>
|
||||
<Input className={styles.formInput} placeholder="请输入" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>手机号</label>
|
||||
<Input className={styles.formInput} placeholder="请输入" value={form.phone} onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.addModalFooter}>
|
||||
<Button className={styles.confirmBtn} onClick={handleSubmit} loading={loading}>确定</Button>
|
||||
<Button className={styles.cancelBtn} onClick={handleClose}>取消</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddStaffModal;
|
||||
49
src/pages/Admin/Staff/components/DetailModal.tsx
Normal file
49
src/pages/Admin/Staff/components/DetailModal.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import type { StaffRecord } from '../types';
|
||||
import styles from '../index.less';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
record: StaffRecord | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DetailModal: React.FC<Props> = ({ visible, record, onClose }) => {
|
||||
return (
|
||||
<Modal open={visible} title="详情" onCancel={onClose} footer={null} width={500} centered className={styles.detailModal} destroyOnClose>
|
||||
{record && (
|
||||
<div className={styles.detailBody}>
|
||||
<div className={styles.detailRow}>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>教职工姓名</span>
|
||||
<span className={styles.detailValue}>{record.name}</span>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>手机号</span>
|
||||
<span className={styles.detailValue}>{record.phone}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>学院</span>
|
||||
<span className={styles.detailValue}>{record.college || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>系</span>
|
||||
<span className={styles.detailValue}>{record.dept || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>状态</span>
|
||||
<span className={styles.detailValue}>{record.status === 'normal' ? '正常' : '禁用'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailModal;
|
||||
58
src/pages/Admin/Staff/components/EditStaffModal.tsx
Normal file
58
src/pages/Admin/Staff/components/EditStaffModal.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input, Button, message } from 'antd';
|
||||
import { request } from '@umijs/max';
|
||||
import type { StaffRecord, StaffFormData } from '../types';
|
||||
import { initStaffForm } from '../types';
|
||||
import styles from '../index.less';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
record: StaffRecord | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const EditStaffModal: React.FC<Props> = ({ visible, record, onClose, onSuccess }) => {
|
||||
const [form, setForm] = useState<StaffFormData>(initStaffForm);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (record) {
|
||||
setForm({ name: record.name, phone: record.phone, college: record.college, dept: record.dept, className: record.className });
|
||||
}
|
||||
}, [record]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name || !form.phone) { message.warning('请填写完整信息'); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request('/api/admin/staff/edit', { method: 'POST', data: { id: record?.id, ...form } });
|
||||
if (res.code === 0) { message.success('编辑成功'); onClose(); onSuccess(); }
|
||||
else { message.error(res.message); }
|
||||
} catch { message.error('编辑失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={visible} title="编辑" onCancel={onClose} footer={null} width={600} centered className={styles.addModal} destroyOnClose>
|
||||
{record && (
|
||||
<div className={styles.addModalBody} style={{ marginTop: 16 }}>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>教职工姓名</label>
|
||||
<Input className={styles.formInput} placeholder="请输入" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} allowClear />
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>手机号</label>
|
||||
<Input className={styles.formInput} placeholder="请输入" value={form.phone} onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))} allowClear />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.addModalFooter}>
|
||||
<Button className={styles.confirmBtn} onClick={handleSubmit} loading={loading}>确定</Button>
|
||||
<Button className={styles.cancelBtn} onClick={onClose}>取消</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditStaffModal;
|
||||
338
src/pages/Admin/Staff/index.less
Normal file
338
src/pages/Admin/Staff/index.less
Normal file
@@ -0,0 +1,338 @@
|
||||
.page {
|
||||
background: #fff;
|
||||
border-radius: 0 12px 12px 12px;
|
||||
min-height: calc(100vh - 60px);
|
||||
|
||||
:global {
|
||||
.ant-input-affix-wrapper:hover,
|
||||
.ant-input:hover { border-color: #00A196; }
|
||||
.ant-input-affix-wrapper-focused,
|
||||
.ant-input:focus,
|
||||
.ant-input-affix-wrapper:focus { border-color: #00A196 !important; box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important; }
|
||||
.ant-input { caret-color: #00A196; }
|
||||
.ant-select:hover .ant-select-selector { border-color: #00A196 !important; }
|
||||
.ant-select-focused .ant-select-selector { border-color: #00A196 !important; box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important; }
|
||||
|
||||
.ant-pagination {
|
||||
.ant-pagination-item-active { background: #00A196 !important; border-color: #00A196 !important; a { color: #fff !important; } }
|
||||
.ant-pagination-item:hover:not(.ant-pagination-item-active) { background: #F2F7FF !important; border-color: #00A196; a { color: #00A196; } }
|
||||
.ant-pagination-prev:hover .ant-pagination-item-link,
|
||||
.ant-pagination-next:hover .ant-pagination-item-link { background: #F2F7FF !important; color: #00A196; border-color: #00A196; }
|
||||
.ant-pagination-options .ant-select-selector:hover { border-color: #00A196 !important; }
|
||||
.ant-pagination-options .ant-select-focused .ant-select-selector { border-color: #00A196 !important; box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important; }
|
||||
.ant-pagination-options-quick-jumper input:hover { border-color: #00A196; }
|
||||
.ant-pagination-options-quick-jumper input:focus { border-color: #00A196; box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 布局:左侧树 + 右侧内容 ===== */
|
||||
.staffLayout {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.sideTree {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #eef2f6;
|
||||
padding: 20px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sideTreeTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.sideTreeContent {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.treeItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
transition: all 0.15s;
|
||||
padding-right: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover { color: #00A196; background: rgba(0, 161, 150, 0.03); }
|
||||
}
|
||||
|
||||
.treeItemActive {
|
||||
color: #00A196 !important;
|
||||
background: rgba(0, 161, 150, 0.06) !important;
|
||||
}
|
||||
|
||||
.treeItemParentActive {
|
||||
color: #00A196 !important;
|
||||
}
|
||||
|
||||
.treeArrow {
|
||||
width: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.treeArrowPlaceholder {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.treeLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== 右侧主区域 ===== */
|
||||
.mainArea {
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ===== 筛选区域 ===== */
|
||||
.filterRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filterItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
width: 180px;
|
||||
height: 32px;
|
||||
caret-color: #00A196;
|
||||
&:hover { border-color: #00A196 !important; }
|
||||
}
|
||||
|
||||
.filterBtns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.searchBtn {
|
||||
height: 32px;
|
||||
padding: 0 24px;
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
border-radius: 4px;
|
||||
color: #fff !important;
|
||||
&:hover, &:focus, &:active { background: #009185 !important; border-color: #009185 !important; color: #fff !important; }
|
||||
}
|
||||
|
||||
.resetBtn {
|
||||
height: 32px;
|
||||
padding: 0 24px;
|
||||
border-radius: 4px;
|
||||
background: #E1E7EF !important;
|
||||
border-color: #E1E7EF !important;
|
||||
color: #333 !important;
|
||||
&:hover, &:focus, &:active { background: #d1d9e3 !important; border-color: #d1d9e3 !important; color: #333 !important; }
|
||||
}
|
||||
|
||||
/* ===== 分割线 ===== */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
|
||||
/* ===== 操作按钮栏 ===== */
|
||||
.actionBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.actionBarLeft { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.actionBarRight {
|
||||
display: flex; align-items: center; gap: 4px; color: #666; font-size: 13px; cursor: pointer;
|
||||
&:hover { color: #00A196; }
|
||||
}
|
||||
|
||||
.settingIcon { font-size: 14px; }
|
||||
.settingText { font-size: 13px; }
|
||||
|
||||
.primaryOutlineBtn {
|
||||
height: 32px; border-radius: 4px !important; background: #00A196 !important; color: #fff !important;
|
||||
border-color: #00A196 !important; font-size: 13px !important; padding: 0 14px !important;
|
||||
&:hover { background: #009185 !important; border-color: #009185 !important; }
|
||||
}
|
||||
|
||||
.defaultBtn {
|
||||
height: 32px; border-radius: 4px !important; color: #333 !important; background: #fff !important;
|
||||
border-color: #d9d9d9 !important; font-size: 13px !important; padding: 0 14px !important;
|
||||
&:hover { color: #00A196 !important; border-color: #00A196 !important; }
|
||||
}
|
||||
|
||||
.dangerBtn {
|
||||
height: 32px; border-radius: 4px !important; background: #e74c3c !important; color: #fff !important;
|
||||
border-color: #e74c3c !important; font-size: 13px !important; padding: 0 14px !important;
|
||||
&:hover { background: #c0392b !important; border-color: #c0392b !important; color: #fff !important; }
|
||||
}
|
||||
|
||||
.selectedCount {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== 表格 ===== */
|
||||
.dataTable {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
:global {
|
||||
.ant-table-wrapper { width: 100%; }
|
||||
.ant-table { border-radius: 0 !important; table-layout: fixed !important; }
|
||||
.ant-table-container { border-radius: 0 !important; border-left: 1px solid #E1E7EF; border-top: 1px solid #E1E7EF; }
|
||||
.ant-table-thead > tr > th {
|
||||
height: 46px; background: #F1F5F9 !important; border-right: 1px solid #E1E7EF !important;
|
||||
border-bottom: 1px solid #E1E7EF !important; border-radius: 0 !important; font-weight: 500; color: #333; font-size: 14px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.ant-table-tbody > tr > td {
|
||||
font-size: 14px; color: #333; border-right: 1px solid #E1E7EF; border-bottom: 1px solid #E1E7EF;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.ant-table-selection-column {
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
}
|
||||
.ant-table-tbody > tr:hover > td { background: rgba(0, 161, 150, 0.05) !important; }
|
||||
.ant-table-cell { border-radius: 0 !important; }
|
||||
.ant-pagination { margin-top: 16px; }
|
||||
|
||||
/* checkbox 主题色 */
|
||||
.ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
.ant-checkbox-indeterminate .ant-checkbox-inner::after {
|
||||
background-color: #00A196 !important;
|
||||
}
|
||||
.ant-checkbox-wrapper:hover .ant-checkbox-inner,
|
||||
.ant-checkbox:hover .ant-checkbox-inner {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
.ant-checkbox-checked::after {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 状态 ===== */
|
||||
.statusCell { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; }
|
||||
.statusDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }
|
||||
|
||||
/* ===== 操作链接 ===== */
|
||||
.actionCell { white-space: nowrap; }
|
||||
.actionLink { color: #00A196; font-size: 13px; cursor: pointer; &:hover { color: #009185; } }
|
||||
.actionLinkWarn { color: #fa8c16; font-size: 13px; cursor: pointer; &:hover { color: #d87a10; } }
|
||||
.actionLinkDanger { color: #e74c3c; font-size: 13px; cursor: pointer; &:hover { color: #c0392b; } }
|
||||
.actionDivider { margin: 0 4px; color: #ddd; font-size: 12px; }
|
||||
|
||||
/* ===== 弹窗 ===== */
|
||||
.addModal {
|
||||
:global {
|
||||
.ant-modal-content { border-radius: 12px; padding: 0; overflow: hidden; }
|
||||
.ant-modal-header { text-align: center; border-bottom: 1px solid #f0f0f0; padding: 14px 24px; margin: 0; }
|
||||
.ant-modal-title { font-size: 16px; font-weight: 600; color: #333; }
|
||||
.ant-modal-close { color: #999; }
|
||||
.ant-modal-body { padding: 0; }
|
||||
.ant-input:hover, .ant-input-affix-wrapper:hover { border-color: #00A196; }
|
||||
.ant-input:focus, .ant-input-affix-wrapper-focused { border-color: #00A196 !important; box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important; }
|
||||
.ant-input { caret-color: #00A196; }
|
||||
}
|
||||
}
|
||||
|
||||
.addModalBody { width: 400px; margin: 0 auto; padding: 32px 0 20px; }
|
||||
|
||||
.tipBanner {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: linear-gradient(135deg, #FFF3E0, #FDE8D0); padding: 10px 24px; margin: 0;
|
||||
}
|
||||
.tipIcon { width: 22px; height: 22px; flex-shrink: 0; }
|
||||
.tipText { flex: 1; font-size: 13px; color: #333; }
|
||||
.tipClose { font-size: 12px; color: #999; cursor: pointer; &:hover { color: #666; } }
|
||||
|
||||
.formField { margin-bottom: 10px; }
|
||||
.formLabel { display: block; font-size: 14px; font-weight: 500; color: #333; margin-bottom: 6px; }
|
||||
.formRequired { color: #e74c3c; font-size: 14px; margin-right: 2px; }
|
||||
.formInput { width: 100%; height: 40px; border-radius: 8px !important; border: 1px solid #E1E7EF !important; }
|
||||
|
||||
.addModalFooter {
|
||||
display: flex; justify-content: center; gap: 16px; padding: 16px 24px; border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.confirmBtn {
|
||||
min-width: 80px; height: 36px; background: #00A196 !important; border-color: #00A196 !important;
|
||||
border-radius: 4px; color: #fff !important; font-size: 14px;
|
||||
&:hover, &:focus, &:active { background: #009185 !important; border-color: #009185 !important; color: #fff !important; }
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
min-width: 80px; height: 36px; border-radius: 4px; border-color: #d9d9d9 !important; color: #333 !important; font-size: 14px;
|
||||
&:hover, &:focus, &:active { border-color: #bbb !important; color: #333 !important; background: #fafafa !important; }
|
||||
}
|
||||
|
||||
/* ===== 详情弹窗 ===== */
|
||||
.detailModal {
|
||||
:global {
|
||||
.ant-modal-content { border-radius: 12px; padding: 0; }
|
||||
.ant-modal-header { text-align: center; border-bottom: 1px solid #f0f0f0; padding: 14px 24px; margin: 0; }
|
||||
.ant-modal-title { font-size: 16px; font-weight: 600; color: #333; }
|
||||
.ant-modal-close { color: #999; }
|
||||
.ant-modal-body { padding: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
.detailBody { padding: 24px 32px; }
|
||||
.detailRow { display: flex; margin-bottom: 20px; }
|
||||
.detailItem { flex: 1; display: flex; gap: 8px; }
|
||||
.detailLabel { font-size: 14px; color: #999; white-space: nowrap; flex-shrink: 0; }
|
||||
.detailValue { font-size: 14px; color: #333; font-weight: 500; }
|
||||
|
||||
/* ===== 列设置 ===== */
|
||||
.columnSettingPanel {
|
||||
min-width: 140px;
|
||||
:global {
|
||||
.ant-checkbox-wrapper { font-size: 14px; color: #333; }
|
||||
.ant-checkbox-checked .ant-checkbox-inner { background-color: #00A196; border-color: #00A196; }
|
||||
.ant-checkbox-indeterminate .ant-checkbox-inner::after { background-color: #00A196; }
|
||||
.ant-checkbox-wrapper:hover .ant-checkbox-inner { border-color: #00A196; }
|
||||
}
|
||||
}
|
||||
.columnSettingDivider { height: 1px; background: #f0f0f0; margin: 8px 0; }
|
||||
.columnSettingItem { padding: 4px 0; }
|
||||
366
src/pages/Admin/Staff/index.tsx
Normal file
366
src/pages/Admin/Staff/index.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Input, Button, Table, message, Modal, ConfigProvider, Upload, Popover, Checkbox } from 'antd';
|
||||
import { ExclamationCircleOutlined, SettingOutlined, PlusOutlined, UploadOutlined, CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons';
|
||||
import { request } from '@umijs/max';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import type { StaffRecord, CollegeTreeItem } from './types';
|
||||
import { statusMap } from './types';
|
||||
import AddStaffModal from './components/AddStaffModal';
|
||||
import EditStaffModal from './components/EditStaffModal';
|
||||
import DetailModal from './components/DetailModal';
|
||||
import styles from './index.less';
|
||||
|
||||
const StaffManage: React.FC = () => {
|
||||
const [filterName, setFilterName] = useState('');
|
||||
const [filterPhone, setFilterPhone] = useState('');
|
||||
// 实际用于请求的搜索参数(点查询后才更新)
|
||||
const [searchName, setSearchName] = useState('');
|
||||
const [searchPhone, setSearchPhone] = useState('');
|
||||
|
||||
// 学院树
|
||||
const [collegeTree, setCollegeTree] = useState<CollegeTreeItem[]>([]);
|
||||
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
|
||||
const [selectedTreeKey, setSelectedTreeKey] = useState<string>('');
|
||||
const [selectedTreeType, setSelectedTreeType] = useState<'college' | 'dept' | 'class' | ''>('');
|
||||
|
||||
// 表格状态
|
||||
const [list, setList] = useState<StaffRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 弹窗
|
||||
const [addModalVisible, setAddModalVisible] = useState(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [editRecord, setEditRecord] = useState<StaffRecord | null>(null);
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [detailRecord, setDetailRecord] = useState<StaffRecord | null>(null);
|
||||
|
||||
// 批量操作
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [batchMode, setBatchMode] = useState(false);
|
||||
|
||||
// 列设置
|
||||
const allColumnKeys = ['name', 'maskedPhone', 'status'];
|
||||
const [visibleColumnKeys, setVisibleColumnKeys] = useState<string[]>(allColumnKeys);
|
||||
const [columnSettingOpen, setColumnSettingOpen] = useState(false);
|
||||
|
||||
// 防抖 timer
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 获取学院树
|
||||
useEffect(() => {
|
||||
request('/api/admin/staff/college-tree').then((res) => {
|
||||
if (res.code === 0) setCollegeTree(res.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 获取教职工列表(只依赖实际搜索参数)
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, any> = { page, pageSize };
|
||||
if (searchName) params.name = searchName;
|
||||
if (searchPhone) params.phone = searchPhone;
|
||||
if (selectedTreeKey && selectedTreeType) {
|
||||
if (selectedTreeType === 'college') params.college = selectedTreeKey;
|
||||
else if (selectedTreeType === 'dept') params.dept = selectedTreeKey;
|
||||
else if (selectedTreeType === 'class') params.className = selectedTreeKey;
|
||||
}
|
||||
const res = await request('/api/admin/staff', { params });
|
||||
if (res.code === 0) { setList(res.data.list); setTotal(res.data.total); }
|
||||
} catch { message.error('获取数据失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [page, pageSize, searchName, searchPhone, selectedTreeKey, selectedTreeType]);
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setSearchName(filterName);
|
||||
setSearchPhone(filterPhone);
|
||||
setPage(1);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilterName('');
|
||||
setFilterPhone('');
|
||||
setSearchName('');
|
||||
setSearchPhone('');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 树操作
|
||||
const toggleExpand = (key: string) => {
|
||||
setExpandedKeys((prev) => prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]);
|
||||
};
|
||||
|
||||
const handleTreeSelect = (key: string, type: 'college' | 'dept' | 'class') => {
|
||||
if (selectedTreeKey === key) { setSelectedTreeKey(''); setSelectedTreeType(''); }
|
||||
else { setSelectedTreeKey(key); setSelectedTreeType(type); }
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const confirmProps = {
|
||||
okButtonProps: { style: { background: '#00A196', borderColor: '#00A196' } },
|
||||
icon: <ExclamationCircleOutlined style={{ color: '#e74c3c' }} />,
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
width: 400,
|
||||
className: 'custom-confirm-modal',
|
||||
};
|
||||
|
||||
// 操作
|
||||
const handleToggleStatus = (record: StaffRecord) => {
|
||||
const action = record.status === 'normal' ? '禁用' : '启用';
|
||||
Modal.confirm({
|
||||
...confirmProps,
|
||||
title: '提示',
|
||||
content: `确定要${action}教职工 ${record.name} 吗?`,
|
||||
onOk: async () => {
|
||||
const res = await request('/api/admin/staff/toggle-status', { method: 'POST', data: { id: record.id } });
|
||||
if (res.code === 0) { message.success(`${action}成功`); fetchList(); } else { message.error(res.message); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (record: StaffRecord) => {
|
||||
Modal.confirm({
|
||||
...confirmProps,
|
||||
title: '提示',
|
||||
content: `确定要删除教职工 ${record.name} 吗?`,
|
||||
onOk: async () => {
|
||||
const res = await request('/api/admin/staff/delete', { method: 'POST', data: { id: record.id } });
|
||||
if (res.code === 0) { message.success('删除成功'); fetchList(); } else { message.error(res.message); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetPassword = (record: StaffRecord) => {
|
||||
Modal.confirm({
|
||||
...confirmProps,
|
||||
title: '提示',
|
||||
content: `确定要重置教职工 ${record.name} 的密码吗?`,
|
||||
onOk: async () => {
|
||||
const res = await request('/api/admin/staff/reset-password', { method: 'POST', data: { id: record.id } });
|
||||
if (res.code === 0) { message.success(res.message); } else { message.error(res.message); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (!batchMode) { setBatchMode(true); return; }
|
||||
if (selectedRowKeys.length === 0) { message.warning('请先选择要删除的教职工'); return; }
|
||||
Modal.confirm({
|
||||
...confirmProps,
|
||||
title: '提示',
|
||||
content: '是否删除已勾选数据?',
|
||||
onOk: async () => {
|
||||
const res = await request('/api/admin/staff/batch-delete', { method: 'POST', data: { ids: selectedRowKeys } });
|
||||
if (res.code === 0) { message.success(res.message); setSelectedRowKeys([]); fetchList(); } else { message.error(res.message); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchImport = async () => {
|
||||
const res = await request('/api/admin/staff/batch-import', { method: 'POST', data: {} });
|
||||
if (res.code === 0) { message.success(res.message); fetchList(); } else { message.error(res.message); }
|
||||
};
|
||||
|
||||
const baseColumns: ColumnsType<StaffRecord> = [
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: '25%', ellipsis: true },
|
||||
{ title: '账号', dataIndex: 'maskedPhone', key: 'maskedPhone', width: '25%', ellipsis: true },
|
||||
{
|
||||
title: '状态', key: 'status', width: '25%', ellipsis: true,
|
||||
render: (_, record) => {
|
||||
const s = statusMap[record.status];
|
||||
return (<span className={styles.statusCell}><span className={styles.statusDot} style={{ background: s.color }} />{s.label}</span>);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const actionCol: ColumnsType<StaffRecord>[0] = {
|
||||
title: '操作', key: 'action', width: '25%',
|
||||
render: (_, record) => (
|
||||
<span className={styles.actionCell}>
|
||||
<a className={styles.actionLink} onClick={() => { setEditRecord(record); setEditModalVisible(true); }}>编辑</a>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
<a className={styles.actionLink} onClick={() => { setDetailRecord(record); setDetailVisible(true); }}>详情</a>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
<a className={styles.actionLink} onClick={() => handleResetPassword(record)}>重置密码</a>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
<a className={record.status === 'normal' ? styles.actionLinkWarn : styles.actionLink} onClick={() => handleToggleStatus(record)}>
|
||||
{record.status === 'normal' ? '禁用' : '启用'}
|
||||
</a>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
<a className={styles.actionLinkDanger} onClick={() => handleDelete(record)}>删除</a>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
|
||||
const filteredBaseColumns = baseColumns.filter((col) => {
|
||||
const key = (col as any).key || (col as any).dataIndex;
|
||||
return visibleColumnKeys.includes(key);
|
||||
});
|
||||
const columns = [...filteredBaseColumns, actionCol];
|
||||
|
||||
// 判断某个节点是否是选中节点的祖先
|
||||
const isAncestorOfSelected = useCallback((item: CollegeTreeItem): boolean => {
|
||||
if (!selectedTreeKey || !item.children) return false;
|
||||
for (const child of item.children) {
|
||||
if (child.id === selectedTreeKey) return true;
|
||||
if (child.children && isAncestorOfSelected(child)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [selectedTreeKey]);
|
||||
|
||||
// 渲染学院树
|
||||
const renderTree = (items: CollegeTreeItem[], level: 'college' | 'dept' | 'class') => {
|
||||
return items.map((item) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isExpanded = expandedKeys.includes(item.id);
|
||||
const isSelected = selectedTreeKey === item.id;
|
||||
const isParentActive = isAncestorOfSelected(item);
|
||||
const nextLevel = level === 'college' ? 'dept' : 'class';
|
||||
|
||||
let itemClass = styles.treeItem;
|
||||
if (isSelected) itemClass += ` ${styles.treeItemActive}`;
|
||||
else if (isParentActive) itemClass += ` ${styles.treeItemParentActive}`;
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div
|
||||
className={itemClass}
|
||||
style={{ paddingLeft: level === 'college' ? 12 : level === 'dept' ? 28 : 44 }}
|
||||
onClick={() => handleTreeSelect(item.id, level)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<span className={styles.treeArrow} onClick={(e) => { e.stopPropagation(); toggleExpand(item.id); }}>
|
||||
{isExpanded ? <CaretDownOutlined /> : <CaretRightOutlined />}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.treeArrowPlaceholder} />
|
||||
)}
|
||||
<span className={styles.treeLabel}>{item.name}</span>
|
||||
</div>
|
||||
{hasChildren && isExpanded && renderTree(item.children!, nextLevel as any)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<div className={styles.page}>
|
||||
<div className={styles.staffLayout}>
|
||||
{/* 左侧学院树 */}
|
||||
<div className={styles.sideTree}>
|
||||
<div className={styles.sideTreeTitle}>教职工管理</div>
|
||||
<div className={styles.sideTreeContent}>
|
||||
{renderTree(collegeTree, 'college')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧内容 */}
|
||||
<div className={styles.mainArea}>
|
||||
{/* 筛选区域 */}
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>教职工姓名</span>
|
||||
<Input className={styles.filterInput} placeholder="请输入" value={filterName} onChange={(e) => setFilterName(e.target.value)} onPressEnter={handleSearch} allowClear autoComplete="off" />
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>教职工账号</span>
|
||||
<Input className={styles.filterInput} placeholder="请输入" value={filterPhone} onChange={(e) => setFilterPhone(e.target.value)} onPressEnter={handleSearch} allowClear autoComplete="off" />
|
||||
</div>
|
||||
<div className={styles.filterBtns}>
|
||||
<Button className={styles.searchBtn} onClick={handleSearch}>查询</Button>
|
||||
<Button className={styles.resetBtn} onClick={handleReset}>重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className={styles.divider} />
|
||||
|
||||
{/* 操作按钮栏 */}
|
||||
<div className={styles.actionBar}>
|
||||
<div className={styles.actionBarLeft}>
|
||||
{!batchMode && <Button className={styles.primaryOutlineBtn} icon={<PlusOutlined />} onClick={() => setAddModalVisible(true)}>新增教职工</Button>}
|
||||
{!batchMode && (
|
||||
<Upload showUploadList={false} beforeUpload={() => { handleBatchImport(); return false; }}>
|
||||
<Button className={styles.defaultBtn} icon={<UploadOutlined />}>批量导入</Button>
|
||||
</Upload>
|
||||
)}
|
||||
{batchMode && <span className={styles.selectedCount}>已选 {selectedRowKeys.length} 个</span>}
|
||||
<Button className={batchMode ? styles.dangerBtn : styles.defaultBtn} onClick={handleBatchDelete}>批量删除</Button>
|
||||
{batchMode && <Button className={styles.defaultBtn} onClick={() => { setBatchMode(false); setSelectedRowKeys([]); }}>取消</Button>}
|
||||
</div>
|
||||
<div className={styles.actionBarRight}>
|
||||
<Popover
|
||||
open={columnSettingOpen} onOpenChange={setColumnSettingOpen} trigger="click" placement="bottomRight"
|
||||
content={
|
||||
<div className={styles.columnSettingPanel}>
|
||||
<Checkbox
|
||||
indeterminate={visibleColumnKeys.length > 0 && visibleColumnKeys.length < allColumnKeys.length}
|
||||
checked={visibleColumnKeys.length === allColumnKeys.length}
|
||||
onChange={(e) => setVisibleColumnKeys(e.target.checked ? [...allColumnKeys] : [])}
|
||||
>全选</Checkbox>
|
||||
<div className={styles.columnSettingDivider} />
|
||||
{baseColumns.map((col) => {
|
||||
const key = (col as any).key || (col as any).dataIndex;
|
||||
const title = col.title as string;
|
||||
return (
|
||||
<div key={key} className={styles.columnSettingItem}>
|
||||
<Checkbox checked={visibleColumnKeys.includes(key)} onChange={(e) => {
|
||||
setVisibleColumnKeys((prev) => e.target.checked ? [...prev, key] : prev.filter((k) => k !== key));
|
||||
}}>{title}</Checkbox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className={styles.actionBarRight} style={{ cursor: 'pointer' }}>
|
||||
<SettingOutlined className={styles.settingIcon} />
|
||||
<span className={styles.settingText}>列表设置</span>
|
||||
</span>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<div className={styles.dataTable}>
|
||||
<Table<StaffRecord>
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
rowSelection={batchMode ? { selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys) } : undefined}
|
||||
pagination={{
|
||||
current: page, pageSize, total,
|
||||
showTotal: (t) => `共${t}条`,
|
||||
showSizeChanger: true, showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||
}}
|
||||
scroll={{ x: '100%' }}
|
||||
size="middle"
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddStaffModal visible={addModalVisible} onClose={() => setAddModalVisible(false)} onSuccess={fetchList} />
|
||||
<EditStaffModal visible={editModalVisible} record={editRecord} onClose={() => setEditModalVisible(false)} onSuccess={fetchList} />
|
||||
<DetailModal visible={detailVisible} record={detailRecord} onClose={() => setDetailVisible(false)} />
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffManage;
|
||||
37
src/pages/Admin/Staff/types.ts
Normal file
37
src/pages/Admin/Staff/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface StaffRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
maskedPhone: string;
|
||||
status: 'normal' | 'disabled';
|
||||
college?: string;
|
||||
dept?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface StaffFormData {
|
||||
name: string;
|
||||
phone: string;
|
||||
college: string | undefined;
|
||||
dept: string | undefined;
|
||||
className: string | undefined;
|
||||
}
|
||||
|
||||
export const initStaffForm: StaffFormData = {
|
||||
name: '',
|
||||
phone: '',
|
||||
college: undefined,
|
||||
dept: undefined,
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export const statusMap: Record<string, { label: string; color: string }> = {
|
||||
normal: { label: '正常', color: '#00A196' },
|
||||
disabled: { label: '禁用', color: '#E1E7EF' },
|
||||
};
|
||||
|
||||
export interface CollegeTreeItem {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: CollegeTreeItem[];
|
||||
}
|
||||
142
src/pages/Admin/Student/components/AddStudentModal.tsx
Normal file
142
src/pages/Admin/Student/components/AddStudentModal.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Input, Select, Button, message } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { request } from '@umijs/max';
|
||||
import type { CollegeData, StudentFormData } from '../types';
|
||||
import { initStudentForm } from '../types';
|
||||
import styles from '../index.less';
|
||||
|
||||
interface AddStudentModalProps {
|
||||
visible: boolean;
|
||||
collegeData: CollegeData;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const AddStudentModal: React.FC<AddStudentModalProps> = ({ visible, collegeData, onClose, onSuccess }) => {
|
||||
const [form, setForm] = useState<StudentFormData>(initStudentForm);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tipVisible, setTipVisible] = useState(true);
|
||||
|
||||
const updateForm = (key: string, val: any) => {
|
||||
setForm((prev) => ({ ...prev, [key]: val }));
|
||||
};
|
||||
|
||||
const handleCollegeChange = (val: string | undefined) => {
|
||||
setForm((prev) => ({ ...prev, college: val, dept: undefined, major: undefined }));
|
||||
};
|
||||
|
||||
const handleDeptChange = (val: string | undefined) => {
|
||||
setForm((prev) => ({ ...prev, dept: val, major: undefined }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { name, studentNo, role, college, dept, major, className, grade, phone } = form;
|
||||
if (!name || !studentNo || !role || !college || !dept || !major || !className || !grade || !phone) {
|
||||
message.warning('请填写完整信息');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request('/api/admin/student/add', {
|
||||
method: 'POST',
|
||||
data: form,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success('新增成功');
|
||||
handleClose();
|
||||
onSuccess();
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
} catch {
|
||||
message.error('新增失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setForm(initStudentForm);
|
||||
setTipVisible(true);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
title="新增学生"
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={600}
|
||||
centered
|
||||
className={styles.addModal}
|
||||
destroyOnClose
|
||||
>
|
||||
{/* 提示横幅 - 全宽 */}
|
||||
{tipVisible && (
|
||||
<div className={styles.tipBanner}>
|
||||
<img
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23d4622b'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Ctext x='12' y='16' text-anchor='middle' fill='white' font-size='14' font-weight='bold'%3E!%3C/text%3E%3C/svg%3E"
|
||||
alt="tip"
|
||||
className={styles.tipIcon}
|
||||
/>
|
||||
<span className={styles.tipText}>创建成功后账号和密码均为学号</span>
|
||||
<CloseOutlined className={styles.tipClose} onClick={() => setTipVisible(false)} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.addModalBody}>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>学生姓名</label>
|
||||
<Input className={styles.formInput} placeholder="请输入" value={form.name} onChange={(e) => updateForm('name', e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>学号</label>
|
||||
<Input className={styles.formInput} placeholder="请输入" value={form.studentNo} onChange={(e) => updateForm('studentNo', e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>角色</label>
|
||||
<Select className={styles.formSelect} placeholder="请选择" value={form.role} onChange={(val) => updateForm('role', val)} options={collegeData.roles.map((r) => ({ label: r, value: r }))} popupClassName={styles.filterDropdown} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>学院</label>
|
||||
<Select className={styles.formSelect} placeholder="请选择" value={form.college} onChange={handleCollegeChange} options={collegeData.colleges.map((c) => ({ label: c, value: c }))} popupClassName={styles.filterDropdown} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>系</label>
|
||||
<Select className={styles.formSelect} placeholder="请选择" value={form.dept} onChange={handleDeptChange} options={form.college ? (collegeData.deptMap[form.college] || []).map((d) => ({ label: d, value: d })) : []} disabled={!form.college} popupClassName={styles.filterDropdown} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>专业</label>
|
||||
<Select className={styles.formSelect} placeholder="请选择" value={form.major} onChange={(val) => updateForm('major', val)} options={form.dept ? (collegeData.majorMap[form.dept] || []).map((m) => ({ label: m, value: m })) : []} disabled={!form.dept} popupClassName={styles.filterDropdown} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>班级</label>
|
||||
<Select className={styles.formSelect} placeholder="请选择" value={form.className} onChange={(val) => updateForm('className', val)} options={collegeData.classes.map((c) => ({ label: c, value: c }))} popupClassName={styles.filterDropdown} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>年级</label>
|
||||
<Select className={styles.formSelect} placeholder="请选择" value={form.grade} onChange={(val) => updateForm('grade', val)} options={collegeData.grades.map((g) => ({ label: g, value: g }))} popupClassName={styles.filterDropdown} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}>手机号</label>
|
||||
<Input className={styles.formInput} placeholder="请输入" value={form.phone} onChange={(e) => updateForm('phone', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.addModalFooter}>
|
||||
<Button className={styles.confirmBtn} onClick={handleSubmit} loading={loading}>确定</Button>
|
||||
<Button className={styles.cancelBtn} onClick={handleClose}>取消</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddStudentModal;
|
||||
78
src/pages/Admin/Student/components/DetailModal.tsx
Normal file
78
src/pages/Admin/Student/components/DetailModal.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import type { StudentRecord } from '../types';
|
||||
import styles from '../index.less';
|
||||
|
||||
interface DetailModalProps {
|
||||
visible: boolean;
|
||||
record: StudentRecord | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DetailModal: React.FC<DetailModalProps> = ({ visible, record, onClose }) => {
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
title="详情"
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={500}
|
||||
centered
|
||||
className={styles.detailModal}
|
||||
destroyOnClose
|
||||
>
|
||||
{record && (
|
||||
<div className={styles.detailBody}>
|
||||
<div className={styles.detailRow}>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>学生姓名</span>
|
||||
<span className={styles.detailValue}>{record.name}</span>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>学号</span>
|
||||
<span className={styles.detailValue}>{record.studentNo}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>角色</span>
|
||||
<span className={styles.detailValue}>{record.role}</span>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>学院</span>
|
||||
<span className={styles.detailValue}>{record.college}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>系</span>
|
||||
<span className={styles.detailValue}>{record.dept}</span>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>专业</span>
|
||||
<span className={styles.detailValue}>{record.major}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>班级</span>
|
||||
<span className={styles.detailValue}>{record.className}</span>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>年级</span>
|
||||
<span className={styles.detailValue}>{record.grade}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>手机号</span>
|
||||
<span className={styles.detailValue}>{record.phone}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailModal;
|
||||
141
src/pages/Admin/Student/components/EditStudentModal.tsx
Normal file
141
src/pages/Admin/Student/components/EditStudentModal.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input, Select, Button, message } from 'antd';
|
||||
import { request } from '@umijs/max';
|
||||
import type { CollegeData, StudentRecord, StudentFormData } from '../types';
|
||||
import { initStudentForm } from '../types';
|
||||
import styles from '../index.less';
|
||||
|
||||
interface EditStudentModalProps {
|
||||
visible: boolean;
|
||||
record: StudentRecord | null;
|
||||
collegeData: CollegeData;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const EditStudentModal: React.FC<EditStudentModalProps> = ({ visible, record, collegeData, onClose, onSuccess }) => {
|
||||
const [form, setForm] = useState<StudentFormData>(initStudentForm);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (record) {
|
||||
setForm({
|
||||
name: record.name,
|
||||
studentNo: record.studentNo,
|
||||
role: record.role,
|
||||
college: record.college,
|
||||
dept: record.dept,
|
||||
major: record.major,
|
||||
className: record.className,
|
||||
grade: record.grade,
|
||||
phone: record.phone,
|
||||
});
|
||||
}
|
||||
}, [record]);
|
||||
|
||||
const updateForm = (key: string, val: any) => {
|
||||
setForm((prev) => ({ ...prev, [key]: val }));
|
||||
};
|
||||
|
||||
const handleCollegeChange = (val: string | undefined) => {
|
||||
setForm((prev) => ({ ...prev, college: val, dept: undefined, major: undefined }));
|
||||
};
|
||||
|
||||
const handleDeptChange = (val: string | undefined) => {
|
||||
setForm((prev) => ({ ...prev, dept: val, major: undefined }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { name, studentNo, role, college, dept, major, className, grade } = form;
|
||||
if (!name || !studentNo || !role || !college || !dept || !major || !className || !grade) {
|
||||
message.warning('请填写完整信息');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request('/api/admin/student/edit', {
|
||||
method: 'POST',
|
||||
data: { id: record?.id, ...form },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success('编辑成功');
|
||||
onClose();
|
||||
onSuccess();
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
} catch {
|
||||
message.error('编辑失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
title="编辑"
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={600}
|
||||
centered
|
||||
className={styles.addModal}
|
||||
destroyOnClose
|
||||
>
|
||||
{record && (
|
||||
<div className={styles.addModalBody}>
|
||||
<div className={styles.formField} style={{ marginTop: 16 }}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>学生姓名</label>
|
||||
<Input className={styles.formInput} placeholder="请输入" value={form.name} onChange={(e) => updateForm('name', e.target.value)} allowClear />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>学号</label>
|
||||
<Input className={styles.formInput} placeholder="请输入" value={form.studentNo} onChange={(e) => updateForm('studentNo', e.target.value)} allowClear />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>角色</label>
|
||||
<Select className={styles.formSelect} placeholder="请选择" value={form.role} onChange={(val) => updateForm('role', val)} options={collegeData.roles.map((r) => ({ label: r, value: r }))} popupClassName={styles.filterDropdown} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>学院</label>
|
||||
<Select className={styles.formSelect} placeholder="请选择" value={form.college} onChange={handleCollegeChange} options={collegeData.colleges.map((c) => ({ label: c, value: c }))} popupClassName={styles.filterDropdown} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>系</label>
|
||||
<Select className={styles.formSelect} placeholder="请选择" value={form.dept} onChange={handleDeptChange} options={form.college ? (collegeData.deptMap[form.college] || []).map((d) => ({ label: d, value: d })) : []} disabled={!form.college} popupClassName={styles.filterDropdown} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>专业</label>
|
||||
<Select className={styles.formSelect} placeholder="请选择" value={form.major} onChange={(val) => updateForm('major', val)} options={form.dept ? (collegeData.majorMap[form.dept] || []).map((m) => ({ label: m, value: m })) : []} disabled={!form.dept} popupClassName={styles.filterDropdown} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>班级</label>
|
||||
<Select className={styles.formSelect} placeholder="请选择" value={form.className} onChange={(val) => updateForm('className', val)} options={collegeData.classes.map((c) => ({ label: c, value: c }))} popupClassName={styles.filterDropdown} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>年级</label>
|
||||
<Select className={styles.formSelect} placeholder="请选择" value={form.grade} onChange={(val) => updateForm('grade', val)} options={collegeData.grades.map((g) => ({ label: g, value: g }))} popupClassName={styles.filterDropdown} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}>手机号</label>
|
||||
<Input className={styles.formInput} placeholder="请输入" value={form.phone} onChange={(e) => updateForm('phone', e.target.value)} allowClear />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.addModalFooter}>
|
||||
<Button className={styles.confirmBtn} onClick={handleSubmit} loading={loading}>确定</Button>
|
||||
<Button className={styles.cancelBtn} onClick={onClose}>取消</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditStudentModal;
|
||||
260
src/pages/Admin/Student/components/GradeManage.tsx
Normal file
260
src/pages/Admin/Student/components/GradeManage.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Input, Button, Table, message, Modal, Popconfirm } from 'antd';
|
||||
import { ExclamationCircleOutlined, SettingOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { request } from '@umijs/max';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import styles from '../index.less';
|
||||
|
||||
interface GradeRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
createTime: string;
|
||||
graduated?: boolean;
|
||||
}
|
||||
|
||||
interface GradeManageProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const GradeManage: React.FC<GradeManageProps> = ({ onBack }) => {
|
||||
const [filterName, setFilterName] = useState('');
|
||||
const [list, setList] = useState<GradeRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 新增/编辑弹窗
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<GradeRecord | null>(null);
|
||||
const [gradeName, setGradeName] = useState('');
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, any> = { page, pageSize };
|
||||
if (filterName) params.name = filterName;
|
||||
const res = await request('/api/admin/grade', { params });
|
||||
if (res.code === 0) {
|
||||
setList(res.data.list);
|
||||
setTotal(res.data.total);
|
||||
}
|
||||
} catch {
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, filterName]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
fetchList();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilterName('');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleOpenAdd = () => {
|
||||
setEditingRecord(null);
|
||||
setGradeName('');
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (record: GradeRecord) => {
|
||||
setEditingRecord(record);
|
||||
setGradeName(record.name);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleModalSubmit = async () => {
|
||||
if (!gradeName.trim()) {
|
||||
message.warning('请输入年级名称');
|
||||
return;
|
||||
}
|
||||
setModalLoading(true);
|
||||
try {
|
||||
const url = editingRecord ? '/api/admin/grade/edit' : '/api/admin/grade/add';
|
||||
const data = editingRecord ? { id: editingRecord.id, name: gradeName } : { name: gradeName };
|
||||
const res = await request(url, { method: 'POST', data });
|
||||
if (res.code === 0) {
|
||||
message.success(editingRecord ? '编辑成功' : '新增成功');
|
||||
setModalVisible(false);
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGraduateConfirm = async (record: GradeRecord) => {
|
||||
const res = await request('/api/admin/grade/graduate', {
|
||||
method: 'POST',
|
||||
data: { id: record.id },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success('转毕业成功');
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreConfirm = async (record: GradeRecord) => {
|
||||
const res = await request('/api/admin/grade/restore', {
|
||||
method: 'POST',
|
||||
data: { id: record.id },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success('还原成功');
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<GradeRecord> = [
|
||||
{ title: '年级名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
render: (_, record) => (
|
||||
<span className={styles.actionCell}>
|
||||
<a className={styles.actionLink} onClick={() => handleOpenEdit(record)}>编辑</a>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
{record.graduated ? (
|
||||
<Popconfirm
|
||||
title="还原"
|
||||
description="确定要还原该年级吗?"
|
||||
onConfirm={() => handleRestoreConfirm(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
overlayStyle={{ maxWidth: 200 }}
|
||||
>
|
||||
<a className={styles.actionLink}>还原</a>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Popconfirm
|
||||
title="转毕业"
|
||||
description="转毕业后此年级下的所有学生将不再计入数据统计。"
|
||||
onConfirm={() => handleGraduateConfirm(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
icon={<ExclamationCircleOutlined style={{ color: '#faad14' }} />}
|
||||
overlayStyle={{ maxWidth: 200 }}
|
||||
>
|
||||
<a className={styles.actionLink}>转毕业</a>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h2 className={styles.title}>年级管理</h2>
|
||||
|
||||
{/* 筛选区域 */}
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>年级</span>
|
||||
<Input
|
||||
className={styles.filterInput}
|
||||
placeholder="请输入"
|
||||
value={filterName}
|
||||
onChange={(e) => setFilterName(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterBtns}>
|
||||
<Button className={styles.searchBtn} onClick={handleSearch}>查询</Button>
|
||||
<Button className={styles.resetBtn} onClick={handleReset}>重置</Button>
|
||||
</div>
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className={styles.divider} />
|
||||
|
||||
{/* 操作按钮栏 */}
|
||||
<div className={styles.actionBar}>
|
||||
<div className={styles.actionBarLeft}>
|
||||
<Button className={styles.primaryOutlineBtn} icon={<PlusOutlined />} onClick={handleOpenAdd}>新增年级</Button>
|
||||
</div>
|
||||
<div className={styles.actionBarRight}>
|
||||
<Button className={styles.defaultBtn} onClick={onBack} style={{ marginRight: 12 }}>返回</Button>
|
||||
<SettingOutlined className={styles.settingIcon} />
|
||||
<span className={styles.settingText}>列表设置</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<div className={styles.dataTable}>
|
||||
<Table<GradeRecord>
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showTotal: (t) => `共${t}条`,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 新增/编辑年级弹窗 */}
|
||||
<Modal
|
||||
open={modalVisible}
|
||||
title={editingRecord ? '编辑年级' : '新增年级'}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={null}
|
||||
width={400}
|
||||
centered
|
||||
className={styles.addModal}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className={styles.addModalBody} style={{ padding: '24px 0' }}>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.formLabel}><span className={styles.formRequired}>*</span>年级名称</label>
|
||||
<Input
|
||||
className={styles.formInput}
|
||||
placeholder="请输入年级名称"
|
||||
value={gradeName}
|
||||
onChange={(e) => setGradeName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.addModalFooter}>
|
||||
<Button className={styles.confirmBtn} onClick={handleModalSubmit} loading={modalLoading}>确定</Button>
|
||||
<Button className={styles.cancelBtn} onClick={() => setModalVisible(false)}>取消</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GradeManage;
|
||||
770
src/pages/Admin/Student/index.less
Normal file
770
src/pages/Admin/Student/index.less
Normal file
@@ -0,0 +1,770 @@
|
||||
.page {
|
||||
background: #fff;
|
||||
border-radius: 0 12px 12px 12px;
|
||||
padding: 24px 32px;
|
||||
min-height: calc(100vh - 60px);
|
||||
overflow: hidden;
|
||||
|
||||
:global {
|
||||
.ant-input-affix-wrapper:hover,
|
||||
.ant-input:hover {
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-input-affix-wrapper-focused,
|
||||
.ant-input:focus,
|
||||
.ant-input-affix-wrapper:focus {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
caret-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-select:hover .ant-select-selector {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
|
||||
/* 分页样式 */
|
||||
.ant-pagination {
|
||||
.ant-pagination-item-active {
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
|
||||
a {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-item:hover:not(.ant-pagination-item-active) {
|
||||
background: #F2F7FF !important;
|
||||
border-color: #00A196;
|
||||
|
||||
a {
|
||||
color: #00A196;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-prev:hover .ant-pagination-item-link,
|
||||
.ant-pagination-next:hover .ant-pagination-item-link {
|
||||
background: #F2F7FF !important;
|
||||
color: #00A196;
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-pagination-options {
|
||||
.ant-select-selector {
|
||||
&:hover {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-options-quick-jumper input:hover {
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-pagination-options-quick-jumper input:focus {
|
||||
border-color: #00A196;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ===== 筛选区域 ===== */
|
||||
.filterRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filterItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
caret-color: #00A196;
|
||||
|
||||
&:hover {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.filterBtns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.searchBtn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
border-radius: 4px;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: #009185 !important;
|
||||
border-color: #009185 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.resetBtn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
background: #E1E7EF !important;
|
||||
border-color: #E1E7EF !important;
|
||||
color: #333 !important;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: #d1d9e3 !important;
|
||||
border-color: #d1d9e3 !important;
|
||||
color: #333 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 分割线 ===== */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* ===== 操作按钮栏 ===== */
|
||||
.actionBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.actionBarLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actionBarRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #00A196;
|
||||
}
|
||||
}
|
||||
|
||||
.settingIcon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settingText {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.primaryOutlineBtn {
|
||||
height: 32px;
|
||||
border-radius: 4px !important;
|
||||
background: #00A196 !important;
|
||||
color: #fff !important;
|
||||
border-color: #00A196 !important;
|
||||
font-size: 13px !important;
|
||||
padding: 0 14px !important;
|
||||
|
||||
&:hover {
|
||||
background: #009185 !important;
|
||||
border-color: #009185 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.defaultBtn {
|
||||
height: 32px;
|
||||
border-radius: 4px !important;
|
||||
color: #333 !important;
|
||||
background: #fff !important;
|
||||
border-color: #d9d9d9 !important;
|
||||
font-size: 13px !important;
|
||||
padding: 0 14px !important;
|
||||
|
||||
&:hover {
|
||||
color: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dangerBtn {
|
||||
height: 32px;
|
||||
border-radius: 4px !important;
|
||||
background: #e74c3c !important;
|
||||
color: #fff !important;
|
||||
border-color: #e74c3c !important;
|
||||
font-size: 13px !important;
|
||||
padding: 0 14px !important;
|
||||
|
||||
&:hover {
|
||||
background: #c0392b !important;
|
||||
border-color: #c0392b !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.selectedCount {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== 表格 ===== */
|
||||
.dataTable {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
:global {
|
||||
.ant-table-wrapper { width: 100%; }
|
||||
.ant-table {
|
||||
border-radius: 0 !important;
|
||||
table-layout: fixed !important;
|
||||
}
|
||||
|
||||
.ant-table-container {
|
||||
border-radius: 0 !important;
|
||||
border-left: 1px solid #E1E7EF;
|
||||
border-top: 1px solid #E1E7EF;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
height: 46px;
|
||||
background: #F1F5F9 !important;
|
||||
border-right: 1px solid #E1E7EF !important;
|
||||
border-bottom: 1px solid #E1E7EF !important;
|
||||
border-radius: 0 !important;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
border-right: 1px solid #E1E7EF;
|
||||
border-bottom: 1px solid #E1E7EF;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ant-table-selection-column {
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: rgba(0, 161, 150, 0.05) !important;
|
||||
}
|
||||
|
||||
.ant-table-cell {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.ant-pagination {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* checkbox 主题色 */
|
||||
.ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
.ant-checkbox-indeterminate .ant-checkbox-inner::after {
|
||||
background-color: #00A196 !important;
|
||||
}
|
||||
.ant-checkbox-wrapper:hover .ant-checkbox-inner,
|
||||
.ant-checkbox:hover .ant-checkbox-inner {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
.ant-checkbox-checked::after {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 状态单元格 ===== */
|
||||
.statusCell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ===== 操作链接 ===== */
|
||||
.actionCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionLink {
|
||||
color: #00A196;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #009185;
|
||||
}
|
||||
}
|
||||
|
||||
.actionLinkWarn {
|
||||
color: #fa8c16;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #d87a10;
|
||||
}
|
||||
}
|
||||
|
||||
.actionLinkDanger {
|
||||
color: #e74c3c;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #c0392b;
|
||||
}
|
||||
}
|
||||
|
||||
.actionDivider {
|
||||
margin: 0 4px;
|
||||
color: #ddd;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ===== 新增学生弹窗 ===== */
|
||||
.addModal {
|
||||
:global {
|
||||
.ant-modal-content {
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 14px 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-input:hover,
|
||||
.ant-input-affix-wrapper:hover {
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-input:focus,
|
||||
.ant-input-affix-wrapper-focused {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
caret-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-select:hover .ant-select-selector {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.addModalBody {
|
||||
width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 0 20px;
|
||||
}
|
||||
|
||||
.tipBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, #FFF3E0, #FDE8D0);
|
||||
border-radius: 0;
|
||||
padding: 10px 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tipIcon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tipText {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tipClose {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.formField {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.formRequired {
|
||||
color: #e74c3c;
|
||||
font-size: 14px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.formInput {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid #E1E7EF !important;
|
||||
}
|
||||
|
||||
.formSelect {
|
||||
width: 100% !important;
|
||||
height: 40px;
|
||||
|
||||
:global {
|
||||
.ant-select-selector {
|
||||
border-radius: 8px !important;
|
||||
height: 40px !important;
|
||||
align-items: center;
|
||||
border: 1px solid #E1E7EF !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.addModalFooter {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.confirmBtn {
|
||||
min-width: 80px;
|
||||
height: 36px;
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
border-radius: 4px;
|
||||
color: #fff !important;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: #009185 !important;
|
||||
border-color: #009185 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
min-width: 80px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
border-color: #d9d9d9 !important;
|
||||
color: #333 !important;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
border-color: #bbb !important;
|
||||
color: #333 !important;
|
||||
background: #fafafa !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 详情弹窗 ===== */
|
||||
.detailModal {
|
||||
:global {
|
||||
.ant-modal-content {
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 14px 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detailBody {
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.detailRow {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detailItem {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== 下拉菜单样式(portal) ===== */
|
||||
.filterDropdown {
|
||||
:global {
|
||||
.ant-select-item-option {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
|
||||
color: #00A196 !important;
|
||||
font-weight: 500;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.ant-select-item-option-active:not(.ant-select-item-option-disabled):not(.ant-select-item-option-selected) {
|
||||
background: #f5f5f5 !important;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ant-select-item-option-active.ant-select-item-option-selected {
|
||||
background: #f5f5f5 !important;
|
||||
color: #00A196 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 列表设置面板 ===== */
|
||||
.columnSettingPanel {
|
||||
min-width: 140px;
|
||||
|
||||
:global {
|
||||
.ant-checkbox-wrapper {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: #00A196;
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-checkbox-indeterminate .ant-checkbox-inner::after {
|
||||
background-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-checkbox-wrapper:hover .ant-checkbox-inner {
|
||||
border-color: #00A196;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.columnSettingDivider {
|
||||
height: 1px;
|
||||
background: #f0f0f0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.columnSettingItem {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* ===== 删除确认弹窗全局样式 ===== */
|
||||
:global {
|
||||
.custom-confirm-modal {
|
||||
.ant-modal-content {
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-modal-confirm-body-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ant-modal-confirm-body {
|
||||
padding: 24px 24px 20px;
|
||||
|
||||
.anticon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ant-modal-confirm-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ant-modal-confirm-content {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-confirm-btns {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 12px 24px;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
.ant-btn-primary {
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
border-radius: 4px;
|
||||
height: 32px;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: #008d83 !important;
|
||||
border-color: #008d83 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn-default {
|
||||
border-radius: 4px;
|
||||
height: 32px;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
border-color: #d9d9d9;
|
||||
color: #333;
|
||||
|
||||
&:hover {
|
||||
border-color: #00A196;
|
||||
color: #00A196;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
602
src/pages/Admin/Student/index.tsx
Normal file
602
src/pages/Admin/Student/index.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Input, Button, Select, Table, message, Modal, ConfigProvider, Upload, Popover, Checkbox } from 'antd';
|
||||
import { ExclamationCircleOutlined, SettingOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { request } from '@umijs/max';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import type { StudentRecord, CollegeData } from './types';
|
||||
import { statusMap } from './types';
|
||||
import AddStudentModal from './components/AddStudentModal';
|
||||
import EditStudentModal from './components/EditStudentModal';
|
||||
import DetailModal from './components/DetailModal';
|
||||
import GradeManage from './components/GradeManage';
|
||||
import styles from './index.less';
|
||||
|
||||
const StudentManage: React.FC = () => {
|
||||
// 视图模式:normal(正常)| graduated(已毕业)| grade(年级管理)
|
||||
const [viewMode, setViewMode] = useState<'normal' | 'graduated' | 'grade'>('normal');
|
||||
|
||||
// 筛选条件状态
|
||||
const [filterStudentNo, setFilterStudentNo] = useState('');
|
||||
const [filterName, setFilterName] = useState<string | undefined>();
|
||||
const [filterCollege, setFilterCollege] = useState<string | undefined>();
|
||||
const [filterDept, setFilterDept] = useState<string | undefined>();
|
||||
const [filterMajor, setFilterMajor] = useState<string | undefined>();
|
||||
const [filterClass, setFilterClass] = useState<string | undefined>();
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>();
|
||||
|
||||
// 学院级联数据
|
||||
const [collegeData, setCollegeData] = useState<CollegeData>({
|
||||
colleges: [],
|
||||
deptMap: {},
|
||||
majorMap: {},
|
||||
classes: [],
|
||||
grades: [],
|
||||
roles: [],
|
||||
});
|
||||
|
||||
// 表格状态
|
||||
const [list, setList] = useState<StudentRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 弹窗状态
|
||||
const [addModalVisible, setAddModalVisible] = useState(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [editRecord, setEditRecord] = useState<StudentRecord | null>(null);
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [detailRecord, setDetailRecord] = useState<StudentRecord | null>(null);
|
||||
|
||||
// 批量操作行选择
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [batchMode, setBatchMode] = useState(false);
|
||||
|
||||
// 列表设置 - 控制列显示/隐藏
|
||||
const allColumnKeys = ['name', 'studentNo', 'college', 'dept', 'major', 'className', 'grade', 'phone', 'role', 'updateDate', 'status'];
|
||||
const [visibleColumnKeys, setVisibleColumnKeys] = useState<string[]>(allColumnKeys);
|
||||
const [columnSettingOpen, setColumnSettingOpen] = useState(false);
|
||||
|
||||
// 批量删除
|
||||
const confirmProps = {
|
||||
okButtonProps: { style: { background: '#00A196', borderColor: '#00A196' } },
|
||||
icon: <ExclamationCircleOutlined style={{ color: '#e74c3c' }} />,
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
width: 400,
|
||||
className: 'custom-confirm-modal',
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (!batchMode) {
|
||||
setBatchMode(true);
|
||||
return;
|
||||
}
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择要删除的学生');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
...confirmProps,
|
||||
title: '提示',
|
||||
content: '是否删除已勾选数据?',
|
||||
onOk: async () => {
|
||||
const res = await request('/api/admin/student/batch-delete', {
|
||||
method: 'POST',
|
||||
data: { ids: selectedRowKeys, graduated: viewMode === 'graduated' },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success(res.message);
|
||||
setSelectedRowKeys([]);
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 批量导入
|
||||
const handleBatchImport = async () => {
|
||||
const res = await request('/api/admin/student/batch-import', {
|
||||
method: 'POST',
|
||||
data: {},
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success(res.message);
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取学院选项数据
|
||||
useEffect(() => {
|
||||
request('/api/admin/student/colleges').then((res) => {
|
||||
if (res.code === 0) {
|
||||
setCollegeData(res.data);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 实际用于请求的搜索参数(点查询后才更新)
|
||||
const [searchParams, setSearchParams] = useState<Record<string, any>>({});
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 获取学生列表
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, any> = { page, pageSize, ...searchParams };
|
||||
if (viewMode === 'graduated') params.graduated = true;
|
||||
|
||||
const res = await request('/api/admin/student', { params });
|
||||
if (res.code === 0) {
|
||||
setList(res.data.list);
|
||||
setTotal(res.data.total);
|
||||
}
|
||||
} catch {
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, viewMode, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'grade') fetchList();
|
||||
}, [fetchList, viewMode]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const params: Record<string, any> = {};
|
||||
if (filterStudentNo) params.studentNo = filterStudentNo;
|
||||
if (filterName) params.name = filterName;
|
||||
if (filterCollege) params.college = filterCollege;
|
||||
if (filterDept) params.dept = filterDept;
|
||||
if (filterMajor) params.major = filterMajor;
|
||||
if (filterClass) params.className = filterClass;
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
setSearchParams(params);
|
||||
setPage(1);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilterStudentNo('');
|
||||
setFilterName(undefined);
|
||||
setFilterCollege(undefined);
|
||||
setFilterDept(undefined);
|
||||
setFilterMajor(undefined);
|
||||
setFilterClass(undefined);
|
||||
setFilterStatus(undefined);
|
||||
setSearchParams({});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const switchView = (mode: 'normal' | 'graduated' | 'grade') => {
|
||||
handleReset();
|
||||
setViewMode(mode);
|
||||
};
|
||||
|
||||
// 学院级联:学院变更时重置系和专业
|
||||
const handleCollegeChange = (val: string | undefined) => {
|
||||
setFilterCollege(val);
|
||||
setFilterDept(undefined);
|
||||
setFilterMajor(undefined);
|
||||
};
|
||||
|
||||
const handleDeptChange = (val: string | undefined) => {
|
||||
setFilterDept(val);
|
||||
setFilterMajor(undefined);
|
||||
};
|
||||
|
||||
// 操作处理
|
||||
const handleToggleStatus = async (record: StudentRecord) => {
|
||||
const action = record.status === 'normal' ? '禁用' : '启用';
|
||||
Modal.confirm({
|
||||
...confirmProps,
|
||||
title: '提示',
|
||||
content: `确定要${action}学生 ${record.name} 吗?`,
|
||||
onOk: async () => {
|
||||
const res = await request('/api/admin/student/toggle-status', {
|
||||
method: 'POST',
|
||||
data: { id: record.id },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success(`${action}成功`);
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (record: StudentRecord) => {
|
||||
Modal.confirm({
|
||||
...confirmProps,
|
||||
title: '提示',
|
||||
content: `确定要删除学生 ${record.name} 吗?`,
|
||||
onOk: async () => {
|
||||
const res = await request('/api/admin/student/delete', {
|
||||
method: 'POST',
|
||||
data: { id: record.id },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success('删除成功');
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetPassword = (record: StudentRecord) => {
|
||||
Modal.confirm({
|
||||
...confirmProps,
|
||||
title: '提示',
|
||||
content: `确定要重置学生 ${record.name} 的密码吗?`,
|
||||
onOk: async () => {
|
||||
const res = await request('/api/admin/student/reset-password', {
|
||||
method: 'POST',
|
||||
data: { id: record.id },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success(res.message);
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 根据已选学院/系动态生成系/专业下拉选项
|
||||
const deptOptions = filterCollege
|
||||
? (collegeData.deptMap[filterCollege] || []).map((d) => ({ label: d, value: d }))
|
||||
: [];
|
||||
const majorOptions = filterDept
|
||||
? (collegeData.majorMap[filterDept] || []).map((m) => ({ label: m, value: m }))
|
||||
: [];
|
||||
|
||||
const baseColumns: ColumnsType<StudentRecord> = [
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 80 },
|
||||
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 90 },
|
||||
{ title: '学院', dataIndex: 'college', key: 'college', width: 110 },
|
||||
{ title: '系', dataIndex: 'dept', key: 'dept', width: 110 },
|
||||
{ title: '专业', dataIndex: 'major', key: 'major', width: 90 },
|
||||
{ title: '班级', dataIndex: 'className', key: 'className', width: 60 },
|
||||
{ title: '年级', dataIndex: 'grade', key: 'grade', width: 70 },
|
||||
{ title: '手机号', dataIndex: 'maskedPhone', key: 'phone', width: 120 },
|
||||
{ title: '角色', dataIndex: 'role', key: 'role', width: 60 },
|
||||
{ title: '更新日期', dataIndex: 'updateDate', key: 'updateDate', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (_, record) => {
|
||||
const s = statusMap[record.status];
|
||||
return (
|
||||
<span className={styles.statusCell}>
|
||||
<span className={styles.statusDot} style={{ background: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const normalActionCol: ColumnsType<StudentRecord>[0] = {
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 240,
|
||||
render: (_, record) => (
|
||||
<span className={styles.actionCell}>
|
||||
<a className={styles.actionLink} onClick={() => { setEditRecord(record); setEditModalVisible(true); }}>编辑</a>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
<a className={styles.actionLink} onClick={() => { setDetailRecord(record); setDetailVisible(true); }}>详情</a>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
<a className={styles.actionLink} onClick={() => handleResetPassword(record)}>重置密码</a>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
<a
|
||||
className={record.status === 'normal' ? styles.actionLinkWarn : styles.actionLink}
|
||||
onClick={() => handleToggleStatus(record)}
|
||||
>
|
||||
{record.status === 'normal' ? '禁用' : '启用'}
|
||||
</a>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
<a className={styles.actionLinkDanger} onClick={() => handleDelete(record)}>删除</a>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
|
||||
const graduatedActionCol: ColumnsType<StudentRecord>[0] = {
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<span className={styles.actionCell}>
|
||||
<a className={styles.actionLink} onClick={() => { setDetailRecord(record); setDetailVisible(true); }}>详情</a>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
<a className={styles.actionLink} onClick={() => { setEditRecord(record); setEditModalVisible(true); }}>编辑</a>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
<a
|
||||
className={record.status === 'normal' ? styles.actionLinkWarn : styles.actionLink}
|
||||
onClick={() => handleToggleStatus(record)}
|
||||
>
|
||||
{record.status === 'normal' ? '禁用' : '启用'}
|
||||
</a>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
<a className={styles.actionLinkDanger} onClick={() => handleDelete(record)}>删除</a>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
|
||||
// 根据列表设置过滤可见列
|
||||
const filteredBaseColumns = baseColumns.filter((col) => {
|
||||
const key = (col as any).key || (col as any).dataIndex;
|
||||
return visibleColumnKeys.includes(key);
|
||||
});
|
||||
const columns = [...filteredBaseColumns, viewMode === 'graduated' ? graduatedActionCol : normalActionCol];
|
||||
|
||||
// 年级管理视图
|
||||
if (viewMode === 'grade') {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<GradeManage onBack={() => switchView('normal')} />
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<div className={styles.page}>
|
||||
<h2 className={styles.title}>{viewMode === 'graduated' ? '已毕业学生列表' : '学生管理'}</h2>
|
||||
|
||||
{/* 筛选区域 */}
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>学号</span>
|
||||
<Input
|
||||
className={styles.filterInput}
|
||||
placeholder="请输入"
|
||||
value={filterStudentNo}
|
||||
onChange={(e) => setFilterStudentNo(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>姓名</span>
|
||||
{viewMode === 'graduated' ? (
|
||||
<Select
|
||||
className={styles.filterSelect}
|
||||
placeholder="请选择"
|
||||
value={filterName}
|
||||
onChange={(val) => setFilterName(val)}
|
||||
allowClear
|
||||
options={list.map((s) => s.name).filter((v, i, a) => a.indexOf(v) === i).map((n) => ({ label: n, value: n }))}
|
||||
popupClassName={styles.filterDropdown}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className={styles.filterInput}
|
||||
placeholder="请输入"
|
||||
value={filterName}
|
||||
onChange={(e) => setFilterName(e.target.value || undefined)}
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>学院</span>
|
||||
<Select
|
||||
className={styles.filterSelect}
|
||||
placeholder="请选择"
|
||||
value={filterCollege}
|
||||
onChange={handleCollegeChange}
|
||||
allowClear
|
||||
options={collegeData.colleges.map((c) => ({ label: c, value: c }))}
|
||||
popupClassName={styles.filterDropdown}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>系</span>
|
||||
<Select
|
||||
className={styles.filterSelect}
|
||||
placeholder="请选择"
|
||||
value={filterDept}
|
||||
onChange={handleDeptChange}
|
||||
allowClear
|
||||
options={deptOptions}
|
||||
disabled={!filterCollege}
|
||||
popupClassName={styles.filterDropdown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>专业</span>
|
||||
<Select
|
||||
className={styles.filterSelect}
|
||||
placeholder="请选择"
|
||||
value={filterMajor}
|
||||
onChange={(val) => setFilterMajor(val)}
|
||||
allowClear
|
||||
options={majorOptions}
|
||||
disabled={!filterDept}
|
||||
popupClassName={styles.filterDropdown}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>班级</span>
|
||||
<Select
|
||||
className={styles.filterSelect}
|
||||
placeholder="请选择"
|
||||
value={filterClass}
|
||||
onChange={(val) => setFilterClass(val)}
|
||||
allowClear
|
||||
options={collegeData.classes.map((c) => ({ label: c, value: c }))}
|
||||
popupClassName={styles.filterDropdown}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>状态</span>
|
||||
<Select
|
||||
className={styles.filterSelect}
|
||||
placeholder="请选择"
|
||||
value={filterStatus}
|
||||
onChange={(val) => setFilterStatus(val)}
|
||||
allowClear
|
||||
options={[
|
||||
{ label: '正常', value: 'normal' },
|
||||
{ label: '禁用', value: 'disabled' },
|
||||
]}
|
||||
popupClassName={styles.filterDropdown}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterBtns}>
|
||||
<Button className={styles.searchBtn} onClick={handleSearch}>查询</Button>
|
||||
<Button className={styles.resetBtn} onClick={handleReset}>重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className={styles.divider} />
|
||||
|
||||
{/* 操作按钮栏 */}
|
||||
<div className={styles.actionBar}>
|
||||
<div className={styles.actionBarLeft}>
|
||||
{viewMode === 'normal' ? (
|
||||
<>
|
||||
{!batchMode && <Button className={styles.primaryOutlineBtn} icon={<PlusOutlined />} onClick={() => setAddModalVisible(true)}>新增学生</Button>}
|
||||
{!batchMode && (
|
||||
<Upload showUploadList={false} beforeUpload={() => { handleBatchImport(); return false; }}>
|
||||
<Button className={styles.defaultBtn} icon={<UploadOutlined />}>批量导入</Button>
|
||||
</Upload>
|
||||
)}
|
||||
{batchMode && <span className={styles.selectedCount}>已选 {selectedRowKeys.length} 个</span>}
|
||||
<Button className={batchMode ? styles.dangerBtn : styles.defaultBtn} onClick={handleBatchDelete}>批量删除</Button>
|
||||
{batchMode && <Button className={styles.defaultBtn} onClick={() => { setBatchMode(false); setSelectedRowKeys([]); }}>取消</Button>}
|
||||
{!batchMode && <Button className={styles.defaultBtn} onClick={() => switchView('grade')}>年级管理</Button>}
|
||||
{!batchMode && <Button className={styles.defaultBtn} onClick={() => switchView('graduated')}>已毕业学生</Button>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{batchMode && <span className={styles.selectedCount}>已选 {selectedRowKeys.length} 个</span>}
|
||||
<Button className={batchMode ? styles.dangerBtn : styles.defaultBtn} onClick={handleBatchDelete}>批量删除</Button>
|
||||
{batchMode && <Button className={styles.defaultBtn} onClick={() => { setBatchMode(false); setSelectedRowKeys([]); }}>取消</Button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actionBarRight}>
|
||||
{viewMode === 'graduated' && (
|
||||
<Button className={styles.defaultBtn} onClick={() => switchView('normal')} style={{ marginRight: 12 }}>返回</Button>
|
||||
)}
|
||||
<Popover
|
||||
open={columnSettingOpen}
|
||||
onOpenChange={setColumnSettingOpen}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={
|
||||
<div className={styles.columnSettingPanel}>
|
||||
<Checkbox
|
||||
indeterminate={visibleColumnKeys.length > 0 && visibleColumnKeys.length < allColumnKeys.length}
|
||||
checked={visibleColumnKeys.length === allColumnKeys.length}
|
||||
onChange={(e) => setVisibleColumnKeys(e.target.checked ? [...allColumnKeys] : [])}
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
<div className={styles.columnSettingDivider} />
|
||||
{baseColumns.map((col) => {
|
||||
const key = (col as any).key || (col as any).dataIndex;
|
||||
const title = col.title as string;
|
||||
return (
|
||||
<div key={key} className={styles.columnSettingItem}>
|
||||
<Checkbox
|
||||
checked={visibleColumnKeys.includes(key)}
|
||||
onChange={(e) => {
|
||||
setVisibleColumnKeys((prev) =>
|
||||
e.target.checked ? [...prev, key] : prev.filter((k) => k !== key),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className={styles.actionBarRight} style={{ cursor: 'pointer' }}>
|
||||
<SettingOutlined className={styles.settingIcon} />
|
||||
<span className={styles.settingText}>列表设置</span>
|
||||
</span>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<div className={styles.dataTable}>
|
||||
<Table<StudentRecord>
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
rowSelection={batchMode ? {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
} : undefined}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showTotal: (t) => `共${t}条`,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: '100%' }}
|
||||
size="middle"
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 弹窗组件 */}
|
||||
<AddStudentModal
|
||||
visible={addModalVisible}
|
||||
collegeData={collegeData}
|
||||
onClose={() => setAddModalVisible(false)}
|
||||
onSuccess={fetchList}
|
||||
/>
|
||||
|
||||
<EditStudentModal
|
||||
visible={editModalVisible}
|
||||
record={editRecord}
|
||||
collegeData={collegeData}
|
||||
onClose={() => setEditModalVisible(false)}
|
||||
onSuccess={fetchList}
|
||||
/>
|
||||
|
||||
<DetailModal
|
||||
visible={detailVisible}
|
||||
record={detailRecord}
|
||||
onClose={() => setDetailVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudentManage;
|
||||
53
src/pages/Admin/Student/types.ts
Normal file
53
src/pages/Admin/Student/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface StudentRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
studentNo: string;
|
||||
college: string;
|
||||
dept: string;
|
||||
major: string;
|
||||
className: string;
|
||||
grade: string;
|
||||
phone: string;
|
||||
maskedPhone: string;
|
||||
role: string;
|
||||
updateDate: string;
|
||||
status: 'normal' | 'disabled';
|
||||
}
|
||||
|
||||
export interface CollegeData {
|
||||
colleges: string[];
|
||||
deptMap: Record<string, string[]>;
|
||||
majorMap: Record<string, string[]>;
|
||||
classes: string[];
|
||||
grades: string[];
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface StudentFormData {
|
||||
name: string;
|
||||
studentNo: string;
|
||||
role: string | undefined;
|
||||
college: string | undefined;
|
||||
dept: string | undefined;
|
||||
major: string | undefined;
|
||||
className: string | undefined;
|
||||
grade: string | undefined;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export const initStudentForm: StudentFormData = {
|
||||
name: '',
|
||||
studentNo: '',
|
||||
role: undefined,
|
||||
college: undefined,
|
||||
dept: undefined,
|
||||
major: undefined,
|
||||
className: undefined,
|
||||
grade: undefined,
|
||||
phone: '',
|
||||
};
|
||||
|
||||
export const statusMap: Record<string, { label: string; color: string }> = {
|
||||
normal: { label: '正常', color: '#00A196' },
|
||||
disabled: { label: '禁用', color: '#E1E7EF' },
|
||||
};
|
||||
379
src/pages/Admin/index.less
Normal file
379
src/pages/Admin/index.less
Normal file
@@ -0,0 +1,379 @@
|
||||
.adminLayout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* ===== Sidebar ===== */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
min-height: 100vh;
|
||||
background-image: url('/admin/background.png');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #eef2f6;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ===== Header ===== */
|
||||
.sidebarHeader {
|
||||
padding: 28px 14px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebarTitle {
|
||||
font-family: PingFang SC, PingFang SC;
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
color: #00A196;
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
font-style: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* ===== Menu ===== */
|
||||
.sidebarMenu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 14px;
|
||||
|
||||
/* Hide scrollbar */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
&::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
}
|
||||
|
||||
.menuGroup {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Group header */
|
||||
.menuGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 46px;
|
||||
padding: 0 10px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 161, 150, 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
/* Active state: when a child menu item is selected */
|
||||
.menuGroupHeaderActive {
|
||||
.menuGroupLabel {
|
||||
font-family: PingFang SC, PingFang SC;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #00A196;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.menuGroupIcon {
|
||||
color: #00A196;
|
||||
}
|
||||
|
||||
.menuGroupArrowBtn {
|
||||
color: #00A196;
|
||||
}
|
||||
}
|
||||
|
||||
.menuGroupLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menuGroupIcon {
|
||||
font-size: 17px;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.menuGroupLabel {
|
||||
font-family: PingFang SC, PingFang SC;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #2A3F54;
|
||||
line-height: 22px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
/* Square arrow button on the right */
|
||||
.menuGroupArrowBtn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
transition: all 0.3s;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.menuGroupArrowBtnOpen {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
/* ===== Sub-menu items ===== */
|
||||
.menuChildren {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px 0 8px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-left: 46px;
|
||||
margin: 2px 0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #00A196;
|
||||
background: rgba(0, 161, 150, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.menuItemActive {
|
||||
color: #00A196;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 161, 150, 0.08);
|
||||
}
|
||||
|
||||
/* ===== Sidebar Footer ===== */
|
||||
.sidebarFooter {
|
||||
padding: 20px 14px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 161, 150, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.userAvatar {
|
||||
background: #e0f0ee !important;
|
||||
color: #00A196 !important;
|
||||
}
|
||||
|
||||
.userName {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.userArrows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
line-height: 1;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ===== User Popover ===== */
|
||||
:global {
|
||||
.user-popover-overlay {
|
||||
.ant-popover-inner {
|
||||
background: rgba(88, 107, 96, 0.78);
|
||||
backdrop-filter: blur(24px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(160%);
|
||||
border-radius: 16px;
|
||||
padding: 6px 0;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.22);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
right: 24px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-top: 8px solid rgba(88, 107, 96, 0.78);
|
||||
}
|
||||
}
|
||||
.ant-popover-content {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.userPopoverItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px 24px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
& + .userPopoverItem {
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userPopoverIcon {
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ===== Main Content ===== */
|
||||
.mainContent {
|
||||
flex: 1;
|
||||
margin-left: 220px;
|
||||
min-height: 100vh;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: #F4F4F4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ===== Tabs Bar ===== */
|
||||
.tabsBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 16px 16px 0;
|
||||
background: #F4F4F4;
|
||||
}
|
||||
|
||||
.tabItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
background: #F2FDF5;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
margin-bottom: -1px;
|
||||
|
||||
&:hover {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.tabItemActive {
|
||||
color: #00A196;
|
||||
font-weight: 500;
|
||||
background: #fff;
|
||||
z-index: 1;
|
||||
|
||||
.tabLabel {
|
||||
color: #00A196;
|
||||
}
|
||||
|
||||
.tabRefreshIcon {
|
||||
color: #00A196;
|
||||
}
|
||||
|
||||
.tabCloseIcon {
|
||||
color: #00A196;
|
||||
}
|
||||
}
|
||||
|
||||
.tabLabel {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tabRefreshIcon {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #00A196;
|
||||
}
|
||||
}
|
||||
|
||||
.tabCloseIcon {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #f5222d;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Page Content ===== */
|
||||
.pageContent {
|
||||
flex: 1;
|
||||
padding: 0 16px 16px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
284
src/pages/Admin/index.tsx
Normal file
284
src/pages/Admin/index.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from '@umijs/max';
|
||||
import { Avatar, Popover } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
BankOutlined,
|
||||
BarChartOutlined,
|
||||
CalendarOutlined,
|
||||
TrophyOutlined,
|
||||
AppstoreOutlined,
|
||||
DownOutlined,
|
||||
ReloadOutlined,
|
||||
CloseOutlined,
|
||||
MobileOutlined,
|
||||
LockOutlined,
|
||||
UndoOutlined,
|
||||
CaretUpOutlined,
|
||||
CaretDownOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import styles from './index.less';
|
||||
|
||||
interface MenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
path?: string;
|
||||
children?: { key: string; label: string; path: string }[];
|
||||
}
|
||||
|
||||
const menuData: MenuItem[] = [
|
||||
{
|
||||
key: 'school',
|
||||
label: '学校管理',
|
||||
icon: <BankOutlined />,
|
||||
children: [
|
||||
{ key: 'college', label: '学院管理', path: '/admin/college' },
|
||||
{ key: 'staff', label: '教职工管理', path: '/admin/staff' },
|
||||
{ key: 'student', label: '学生管理', path: '/admin/student' },
|
||||
{ key: 'role', label: '角色管理', path: '/admin/role' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'statistics',
|
||||
label: '数据统计',
|
||||
icon: <BarChartOutlined />,
|
||||
children: [
|
||||
{ key: 'overview', label: '数据总览', path: '/admin/overview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'appointment',
|
||||
label: '预约管理',
|
||||
icon: <CalendarOutlined />,
|
||||
children: [
|
||||
{ key: 'appointmentList', label: '预约列表', path: '/admin/appointment-list' },
|
||||
{ key: 'appointmentUsers', label: '预约人员列表', path: '/admin/appointment-users' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'employment',
|
||||
label: '就业能力提升',
|
||||
icon: <TrophyOutlined />,
|
||||
children: [
|
||||
{ key: 'taskList', label: '任务管理列表', path: '/admin/task-list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
label: '系统配置',
|
||||
icon: <AppstoreOutlined />,
|
||||
children: [
|
||||
{ key: 'banner', label: '首页轮播图', path: '/admin/banner' },
|
||||
{ key: 'security', label: '安全配置页', path: '/admin/security' },
|
||||
{ key: 'userManage', label: '用户管理', path: '/admin/user-manage' },
|
||||
{ key: 'menuManage', label: '菜单管理', path: '/admin/menu-manage' },
|
||||
{ key: 'operationLog', label: '操作日志', path: '/admin/operation-log' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface TabItem {
|
||||
key: string;
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Build a flat map: path -> { key, label, groupLabel }
|
||||
const pathMap: Record<string, { key: string; label: string; groupLabel: string }> = {};
|
||||
menuData.forEach((group) => {
|
||||
group.children?.forEach((child) => {
|
||||
pathMap[child.path] = { key: child.key, label: child.label, groupLabel: group.label };
|
||||
});
|
||||
});
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(['school']);
|
||||
const [selectedKey, setSelectedKey] = useState('college');
|
||||
const [tabs, setTabs] = useState<TabItem[]>([]);
|
||||
const [activeTabKey, setActiveTabKey] = useState('');
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
const displayName = userInfo.realName || userInfo.username || '未登录';
|
||||
|
||||
const addTab = useCallback((path: string) => {
|
||||
const info = pathMap[path];
|
||||
if (!info) return;
|
||||
setTabs((prev) => {
|
||||
if (prev.some((t) => t.key === info.key)) return prev;
|
||||
return [...prev, { key: info.key, label: info.label, path }];
|
||||
});
|
||||
setActiveTabKey(info.key);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const info = pathMap[location.pathname];
|
||||
if (info) {
|
||||
setSelectedKey(info.key);
|
||||
addTab(location.pathname);
|
||||
for (const group of menuData) {
|
||||
if (group.children?.some((c) => c.key === info.key)) {
|
||||
if (!openKeys.includes(group.key)) {
|
||||
setOpenKeys((prev) => [...prev, group.key]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setOpenKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const handleMenuClick = (item: { key: string; path: string }) => {
|
||||
setSelectedKey(item.key);
|
||||
addTab(item.path);
|
||||
navigate(item.path);
|
||||
};
|
||||
|
||||
const handleTabClick = (tab: TabItem) => {
|
||||
setActiveTabKey(tab.key);
|
||||
setSelectedKey(tab.key);
|
||||
navigate(tab.path);
|
||||
};
|
||||
|
||||
const handleTabClose = (e: React.MouseEvent, tab: TabItem) => {
|
||||
e.stopPropagation();
|
||||
setTabs((prev) => {
|
||||
const newTabs = prev.filter((t) => t.key !== tab.key);
|
||||
if (tab.key === activeTabKey && newTabs.length > 0) {
|
||||
const last = newTabs[newTabs.length - 1];
|
||||
setActiveTabKey(last.key);
|
||||
setSelectedKey(last.key);
|
||||
navigate(last.path);
|
||||
}
|
||||
return newTabs;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTabRefresh = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setRefreshKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
setPopoverOpen(false);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userInfo');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const userPopoverContent = (
|
||||
<div className={styles.userPopover}>
|
||||
<div className={styles.userPopoverItem} onClick={() => setPopoverOpen(false)}>
|
||||
<MobileOutlined className={styles.userPopoverIcon} />
|
||||
<span>换绑手机号</span>
|
||||
</div>
|
||||
<div className={styles.userPopoverItem} onClick={() => setPopoverOpen(false)}>
|
||||
<LockOutlined className={styles.userPopoverIcon} />
|
||||
<span>修改密码</span>
|
||||
</div>
|
||||
<div className={styles.userPopoverItem} onClick={handleLogout}>
|
||||
<UndoOutlined className={styles.userPopoverIcon} />
|
||||
<span>退出</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.adminLayout}>
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<span className={styles.sidebarTitle}>AI就业平台</span>
|
||||
</div>
|
||||
|
||||
<nav className={styles.sidebarMenu}>
|
||||
{menuData.map((group) => {
|
||||
const isGroupActive = group.children?.some((c) => c.key === selectedKey) || false;
|
||||
return (
|
||||
<div key={group.key} className={styles.menuGroup}>
|
||||
<div
|
||||
className={`${styles.menuGroupHeader} ${isGroupActive ? styles.menuGroupHeaderActive : ''}`}
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
>
|
||||
<div className={styles.menuGroupLeft}>
|
||||
<span className={styles.menuGroupIcon}>{group.icon}</span>
|
||||
<span className={styles.menuGroupLabel}>{group.label}</span>
|
||||
</div>
|
||||
<span className={`${styles.menuGroupArrowBtn} ${openKeys.includes(group.key) ? styles.menuGroupArrowBtnOpen : ''}`}>
|
||||
<DownOutlined />
|
||||
</span>
|
||||
</div>
|
||||
{openKeys.includes(group.key) && group.children && (
|
||||
<ul className={styles.menuChildren}>
|
||||
{group.children.map((child) => (
|
||||
<li
|
||||
key={child.key}
|
||||
className={`${styles.menuItem} ${selectedKey === child.key ? styles.menuItemActive : ''}`}
|
||||
onClick={() => handleMenuClick(child)}
|
||||
>
|
||||
{child.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className={styles.sidebarFooter}>
|
||||
<Popover
|
||||
content={userPopoverContent}
|
||||
placement="top"
|
||||
trigger="click"
|
||||
open={popoverOpen}
|
||||
onOpenChange={setPopoverOpen}
|
||||
overlayClassName="user-popover-overlay"
|
||||
arrow={false}
|
||||
>
|
||||
<div className={styles.userInfo}>
|
||||
<Avatar size={36} icon={<UserOutlined />} className={styles.userAvatar} />
|
||||
<span className={styles.userName}>{displayName}</span>
|
||||
<span className={styles.userArrows}>
|
||||
<CaretUpOutlined />
|
||||
<CaretDownOutlined />
|
||||
</span>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className={styles.mainContent}>
|
||||
{tabs.length > 0 && (
|
||||
<div className={styles.tabsBar}>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.key}
|
||||
className={`${styles.tabItem} ${activeTabKey === tab.key ? styles.tabItemActive : ''}`}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
>
|
||||
<span className={styles.tabLabel}>{tab.label}</span>
|
||||
<ReloadOutlined className={styles.tabRefreshIcon} onClick={handleTabRefresh} />
|
||||
<CloseOutlined className={styles.tabCloseIcon} onClick={(e) => handleTabClose(e, tab)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.pageContent}>
|
||||
<Outlet key={refreshKey} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
716
src/pages/Appointment/index.less
Normal file
716
src/pages/Appointment/index.less
Normal file
@@ -0,0 +1,716 @@
|
||||
.appointmentPage {
|
||||
min-height: 100vh;
|
||||
background: #F4F4F4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbarWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.contentArea {
|
||||
flex: 1;
|
||||
width: 1400px;
|
||||
max-width: calc(100% - 80px);
|
||||
margin: 0 auto;
|
||||
padding: 16px 0 32px;
|
||||
}
|
||||
|
||||
/* ===== Breadcrumb ===== */
|
||||
.breadcrumb {
|
||||
padding: 12px 0;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.breadcrumbItem {
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumbActive {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.breadcrumbSep {
|
||||
margin: 0 8px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* ===== Card ===== */
|
||||
.card {
|
||||
width: 100%;
|
||||
min-height: 662px;
|
||||
background: #FFFFFF;
|
||||
border-radius: 16px;
|
||||
padding: 24px 32px;
|
||||
|
||||
:global {
|
||||
.ant-input-affix-wrapper:hover,
|
||||
.ant-input:hover {
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-input-affix-wrapper-focused,
|
||||
.ant-input:focus,
|
||||
.ant-input-affix-wrapper:focus {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
caret-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-select:hover .ant-select-selector {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
|
||||
.ant-picker:hover {
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-picker-focused {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Pagination styles */
|
||||
.ant-pagination {
|
||||
.ant-pagination-item-active {
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
|
||||
a {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-item:hover:not(.ant-pagination-item-active) {
|
||||
background: #F2F7FF !important;
|
||||
border-color: #00A196;
|
||||
|
||||
a {
|
||||
color: #00A196;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-prev:hover .ant-pagination-item-link,
|
||||
.ant-pagination-next:hover .ant-pagination-item-link {
|
||||
background: #F2F7FF !important;
|
||||
color: #00A196;
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-pagination-options {
|
||||
.ant-select-selector {
|
||||
&:hover {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-options-quick-jumper input:hover {
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-pagination-options-quick-jumper input:focus {
|
||||
border-color: #00A196;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Empty State ===== */
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 64px;
|
||||
color: #c0c0c0;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== Card Header ===== */
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.titleBar {
|
||||
width: 4px;
|
||||
height: 26px;
|
||||
background: #00A196;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-family: HuXiaoBo-NanShen, HuXiaoBo-NanShen;
|
||||
font-weight: 400;
|
||||
font-size: 26px;
|
||||
color: #00A196;
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
font-style: normal;
|
||||
text-transform: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== Filter Row ===== */
|
||||
.filterRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
caret-color: #00A196;
|
||||
|
||||
&:hover {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.filterDatePicker {
|
||||
width: 200px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.filterBtns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.searchBtn {
|
||||
min-width: 80px;
|
||||
height: 32px;
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
border-radius: 4px;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: #009185 !important;
|
||||
border-color: #009185 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.resetBtn {
|
||||
min-width: 80px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
background: #E1E7EF !important;
|
||||
border-color: #E1E7EF !important;
|
||||
color: #333 !important;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: #d1d9e3 !important;
|
||||
border-color: #d1d9e3 !important;
|
||||
color: #333 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Tabs ===== */
|
||||
.tableTabs {
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
margin-bottom: 4px;
|
||||
|
||||
:global {
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
padding: 8px 0;
|
||||
|
||||
&:hover {
|
||||
color: #00A196;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: #00A196 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
background: #00A196;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Table ===== */
|
||||
.dataTable {
|
||||
:global {
|
||||
.ant-table {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.ant-table-container {
|
||||
border-radius: 0 !important;
|
||||
border-left: 1px solid #E1E7EF;
|
||||
border-top: 1px solid #E1E7EF;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
height: 46px;
|
||||
background: #F1F5F9 !important;
|
||||
border-right: 1px solid #E1E7EF !important;
|
||||
border-bottom: 1px solid #E1E7EF !important;
|
||||
border-radius: 0 !important;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
border-right: 1px solid #E1E7EF;
|
||||
border-bottom: 1px solid #E1E7EF;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: rgba(0, 161, 150, 0.05) !important;
|
||||
}
|
||||
|
||||
.ant-table-cell {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.ant-pagination {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Action Links ===== */
|
||||
.actionLink {
|
||||
color: #00A196;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #009185;
|
||||
}
|
||||
}
|
||||
|
||||
.actionDivider {
|
||||
margin: 0 8px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.actionDisabled {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.actionBookLink {
|
||||
color: #00A196;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #009185;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Status Tag (arrow shape) ===== */
|
||||
.statusTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
padding: 2px 10px 2px 8px;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
clip-path: polygon(8px 0, 100% 0, 100% 100%, 8px 100%, 0 50%);
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.statusTagOngoing {
|
||||
background: #F0F9FF;
|
||||
color: #00A196;
|
||||
}
|
||||
|
||||
.statusTagEnded {
|
||||
background: #C8D6E5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.statusTagNotStarted {
|
||||
background: #FEFCE7;
|
||||
color: #FA8C16;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ===== Booking Modal ===== */
|
||||
.bookModal {
|
||||
:global {
|
||||
.ant-modal-header {
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.ant-input:hover,
|
||||
.ant-input-affix-wrapper:hover {
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-input:focus,
|
||||
.ant-input-affix-wrapper-focused {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
caret-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-select:hover .ant-select-selector {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
|
||||
.ant-picker:hover {
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-picker-focused {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookModalBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bookDesc {
|
||||
background: #F1F5F9;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.bookDescTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #2A3F54;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bookDescContent {
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bookField {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.bookFieldLabel {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bookFieldSelect {
|
||||
width: 100% !important;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.bookFieldDate {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.bookFieldInput {
|
||||
height: 40px;
|
||||
caret-color: #00A196;
|
||||
}
|
||||
|
||||
.bookFooter {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.bookConfirmBtn {
|
||||
min-width: 100px;
|
||||
height: 36px;
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
border-radius: 4px;
|
||||
color: #fff !important;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: #009185 !important;
|
||||
border-color: #009185 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.bookCancelBtn {
|
||||
min-width: 100px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
border-color: #d9d9d9 !important;
|
||||
color: #333 !important;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
border-color: #bbb !important;
|
||||
color: #333 !important;
|
||||
background: #fafafa !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Select Dropdown (portal, must be standalone class) ===== */
|
||||
.filterDropdown {
|
||||
:global {
|
||||
.ant-select-item-option {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
|
||||
color: #00A196 !important;
|
||||
font-weight: 500;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.ant-select-item-option-active:not(.ant-select-item-option-disabled):not(.ant-select-item-option-selected) {
|
||||
background: #f5f5f5 !important;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ant-select-item-option-active.ant-select-item-option-selected {
|
||||
background: #f5f5f5 !important;
|
||||
color: #00A196 !important;
|
||||
}
|
||||
|
||||
.ant-select-item-option-disabled {
|
||||
color: #c0c8d4 !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Tablet ===== */
|
||||
@media screen and (max-width: 1440px) {
|
||||
.contentArea {
|
||||
width: auto;
|
||||
max-width: calc(100% - 60px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Mobile ===== */
|
||||
@media screen and (max-width: 768px) {
|
||||
.navbarWrap {
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.contentArea {
|
||||
width: auto;
|
||||
max-width: calc(100% - 24px);
|
||||
padding: 8px 0 20px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 12px;
|
||||
min-height: auto;
|
||||
border-radius: 12px;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.titleBar {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.filterRow {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filterItem {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
min-width: 60px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
flex: 1;
|
||||
width: auto !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterDatePicker {
|
||||
flex: 1;
|
||||
width: auto !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterBtns {
|
||||
justify-content: flex-end;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.dataTable {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
:global {
|
||||
.ant-table-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-table-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionLink {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actionDivider {
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
691
src/pages/Appointment/index.tsx
Normal file
691
src/pages/Appointment/index.tsx
Normal file
@@ -0,0 +1,691 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Input, Button, Tabs, Table, DatePicker, Modal, Select, Empty, ConfigProvider, message } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { InboxOutlined } from '@ant-design/icons';
|
||||
import { request } from '@umijs/max';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import Navbar from '../Home/components/Navbar';
|
||||
import Footer from '../Home/components/Footer';
|
||||
import styles from './index.less';
|
||||
|
||||
interface AppointmentRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
time: string;
|
||||
session: string;
|
||||
teacher: string;
|
||||
location: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ManageRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
remaining: number;
|
||||
total: number;
|
||||
sessions: number;
|
||||
teacher: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'manage' | 'list';
|
||||
|
||||
const statusMap: Record<string, { label: string; color: string }> = {
|
||||
ongoing: { label: '进行中', color: '#00A196' },
|
||||
ended: { label: '已结束', color: '#999' },
|
||||
notStarted: { label: '未开始', color: '#FA8C16' },
|
||||
};
|
||||
|
||||
const Appointment: React.FC = () => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
// 预约列表 state
|
||||
const [activeTab, setActiveTab] = useState('booked');
|
||||
const [searchName, setSearchName] = useState('');
|
||||
const [searchStudent, setSearchStudent] = useState('');
|
||||
const [searchDate, setSearchDate] = useState<Dayjs | null>(null);
|
||||
const [dataSource, setDataSource] = useState<AppointmentRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// 预约管理 state
|
||||
const [mSearchName, setMSearchName] = useState('');
|
||||
const [mSearchCategory, setMSearchCategory] = useState<string | undefined>(undefined);
|
||||
const [mSearchTeacher, setMSearchTeacher] = useState('');
|
||||
const [mSearchStatus, setMSearchStatus] = useState<string | undefined>(undefined);
|
||||
const [mDataSource, setMDataSource] = useState<ManageRecord[]>([]);
|
||||
const [mTotal, setMTotal] = useState(0);
|
||||
const [mCurrentPage, setMCurrentPage] = useState(1);
|
||||
const [mPageSize, setMPageSize] = useState(10);
|
||||
const [mLoading, setMLoading] = useState(false);
|
||||
// 预约弹窗 state
|
||||
const [bookModalVisible, setBookModalVisible] = useState(false);
|
||||
const [bookingRecord, setBookingRecord] = useState<ManageRecord | null>(null);
|
||||
const [bookLocation, setBookLocation] = useState<string | undefined>(undefined);
|
||||
const [bookDate, setBookDate] = useState<Dayjs | null>(null);
|
||||
const [bookSession, setBookSession] = useState<string | undefined>(undefined);
|
||||
const [bookPosition, setBookPosition] = useState('');
|
||||
const [bookSubmitting, setBookSubmitting] = useState(false);
|
||||
|
||||
const fetchList = useCallback(async (params?: {
|
||||
status?: string;
|
||||
name?: string;
|
||||
date?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query: Record<string, any> = {
|
||||
status: params?.status ?? activeTab,
|
||||
page: params?.page ?? currentPage,
|
||||
pageSize: params?.pageSize ?? pageSize,
|
||||
};
|
||||
if (params?.name ?? searchName) query.name = params?.name ?? searchName;
|
||||
if (params?.date ?? (searchDate ? searchDate.format('YYYY.MM.DD') : '')) {
|
||||
query.date = params?.date ?? searchDate?.format('YYYY.MM.DD');
|
||||
}
|
||||
if (searchStudent) query.student = searchStudent;
|
||||
|
||||
const res = await request('/api/appointment/list', { params: query });
|
||||
if (res.code === 0) {
|
||||
setDataSource(res.data.list || []);
|
||||
setTotal(res.data.total || 0);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeTab, currentPage, pageSize, searchName, searchStudent, searchDate]);
|
||||
|
||||
const fetchManageList = useCallback(async (params?: {
|
||||
name?: string;
|
||||
category?: string;
|
||||
teacher?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
setMLoading(true);
|
||||
try {
|
||||
const query: Record<string, any> = {
|
||||
page: params?.page ?? mCurrentPage,
|
||||
pageSize: params?.pageSize ?? mPageSize,
|
||||
};
|
||||
const n = params?.name ?? mSearchName;
|
||||
const c = params?.category ?? mSearchCategory;
|
||||
const t = params?.teacher ?? mSearchTeacher;
|
||||
const s = params?.status ?? mSearchStatus;
|
||||
if (n) query.name = n;
|
||||
if (c) query.category = c;
|
||||
if (t) query.teacher = t;
|
||||
if (s) query.status = s;
|
||||
|
||||
const res = await request('/api/appointment/manage', { params: query });
|
||||
if (res.code === 0) {
|
||||
setMDataSource(res.data.list || []);
|
||||
setMTotal(res.data.total || 0);
|
||||
}
|
||||
} finally {
|
||||
setMLoading(false);
|
||||
}
|
||||
}, [mCurrentPage, mPageSize, mSearchName, mSearchCategory, mSearchTeacher, mSearchStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'list') {
|
||||
fetchList();
|
||||
}
|
||||
}, [viewMode, activeTab, currentPage, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'manage') {
|
||||
fetchManageList();
|
||||
}
|
||||
}, [viewMode, mCurrentPage, mPageSize]);
|
||||
|
||||
const canBook = (record: ManageRecord) => {
|
||||
if (record.status === 'ended') return false;
|
||||
if (record.remaining <= 0) return false;
|
||||
if (record.status === 'notStarted') {
|
||||
// 未开始到了预约时段才可以预约(mock中用 bookable 字段判断,默认允许)
|
||||
return record.remaining > 0;
|
||||
}
|
||||
// 进行中还有名额的可以预约(可重复预约)
|
||||
return true;
|
||||
};
|
||||
|
||||
const resetBookModal = () => {
|
||||
setBookModalVisible(false);
|
||||
setBookingRecord(null);
|
||||
setBookLocation(undefined);
|
||||
setBookDate(null);
|
||||
setBookSession(undefined);
|
||||
setBookPosition('');
|
||||
};
|
||||
|
||||
const handleBook = (record: ManageRecord) => {
|
||||
if (!canBook(record)) {
|
||||
message.warning('该项目不可预约');
|
||||
return;
|
||||
}
|
||||
setBookingRecord(record);
|
||||
setBookModalVisible(true);
|
||||
};
|
||||
|
||||
const handleBookSubmit = async () => {
|
||||
if (!bookingRecord) return;
|
||||
if (!bookLocation) { message.warning('请选择预约地点'); return; }
|
||||
if (!bookDate) { message.warning('请选择日期'); return; }
|
||||
if (!bookSession) { message.warning('请选择场次'); return; }
|
||||
setBookSubmitting(true);
|
||||
try {
|
||||
const res = await request('/api/appointment/book', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
id: bookingRecord.id,
|
||||
location: bookLocation,
|
||||
date: bookDate.format('YYYY-MM-DD'),
|
||||
session: bookSession,
|
||||
position: bookPosition,
|
||||
},
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success('预约成功');
|
||||
resetBookModal();
|
||||
fetchManageList();
|
||||
} else {
|
||||
message.error(res.message || '预约失败');
|
||||
}
|
||||
} finally {
|
||||
setBookSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const manageColumns: ColumnsType<ManageRecord> = [
|
||||
{ title: '预约名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '分类', dataIndex: 'category', key: 'category' },
|
||||
{
|
||||
title: '起止时间',
|
||||
key: 'timeRange',
|
||||
render: (_, record) => `${record.startTime}-${record.endTime}`,
|
||||
},
|
||||
{
|
||||
title: '剩余',
|
||||
key: 'remaining',
|
||||
render: (_, record) => `${record.remaining}/${record.total}`,
|
||||
},
|
||||
{ title: '场次', dataIndex: 'sessions', key: 'sessions' },
|
||||
{ title: '教师', dataIndex: 'teacher', key: 'teacher' },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (_, record) => {
|
||||
const s = statusMap[record.status] || { label: record.status, color: '#999' };
|
||||
const tagClass =
|
||||
record.status === 'ongoing' ? styles.statusTagOngoing :
|
||||
record.status === 'ended' ? styles.statusTagEnded :
|
||||
styles.statusTagNotStarted;
|
||||
return (
|
||||
<span className={`${styles.statusTag} ${tagClass}`}>
|
||||
<span className={styles.statusDot} style={{ background: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_, record) =>
|
||||
canBook(record) ? (
|
||||
<a className={styles.actionBookLink} onClick={() => handleBook(record)}>预约</a>
|
||||
) : (
|
||||
<span className={styles.actionDisabled}>不可预约</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const canCancel = (status: string) => status === 'booked' || status === 'pending';
|
||||
|
||||
const handleCancel = (record: AppointmentRecord) => {
|
||||
if (!canCancel(record.status)) {
|
||||
message.warning('已结束的预约不能取消');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '取消预约',
|
||||
content: `确定要取消预约「${record.name}」吗?`,
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { style: { background: '#00A196', borderColor: '#00A196' } },
|
||||
onOk: async () => {
|
||||
const res = await request('/api/appointment/cancel', {
|
||||
method: 'POST',
|
||||
data: { id: record.id },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success('取消成功');
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message || '取消失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<AppointmentRecord> = [
|
||||
{
|
||||
title: '序列',
|
||||
key: 'index',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (_, __, idx) => (currentPage - 1) * pageSize + idx + 1,
|
||||
},
|
||||
{ title: '预约名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '分类', dataIndex: 'category', key: 'category' },
|
||||
{ title: '时间', dataIndex: 'time', key: 'time' },
|
||||
{ title: '场次', dataIndex: 'session', key: 'session' },
|
||||
{ title: '教师', dataIndex: 'teacher', key: 'teacher' },
|
||||
{ title: '地点', dataIndex: 'location', key: 'location' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<span>
|
||||
<a className={styles.actionLink}>详情</a>
|
||||
{canCancel(record.status) && (
|
||||
<>
|
||||
<span className={styles.actionDivider}>|</span>
|
||||
<a className={styles.actionLink} onClick={() => handleCancel(record)}>取消预约</a>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'booked', label: '已预约' },
|
||||
{ key: 'pending', label: '预约待确认' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
{ key: 'cancelled', label: '已取消' },
|
||||
];
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveTab(key);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchList({
|
||||
status: activeTab,
|
||||
name: searchName,
|
||||
date: searchDate ? searchDate.format('YYYY.MM.DD') : undefined,
|
||||
page: 1,
|
||||
pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchName('');
|
||||
setSearchStudent('');
|
||||
setSearchDate(null);
|
||||
setCurrentPage(1);
|
||||
fetchList({ status: activeTab, name: '', date: '', page: 1, pageSize });
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<div className={styles.appointmentPage}>
|
||||
<div className={styles.navbarWrap}>
|
||||
<Navbar solidBg />
|
||||
</div>
|
||||
|
||||
<div className={styles.contentArea}>
|
||||
<div className={styles.breadcrumb}>
|
||||
<span
|
||||
className={viewMode === 'manage' ? styles.breadcrumbActive : styles.breadcrumbItem}
|
||||
onClick={() => setViewMode('manage')}
|
||||
>
|
||||
预约管理
|
||||
</span>
|
||||
<span className={styles.breadcrumbSep}>/</span>
|
||||
<span
|
||||
className={viewMode === 'list' ? styles.breadcrumbActive : styles.breadcrumbItem}
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
预约列表
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
{viewMode === 'manage' && (
|
||||
<>
|
||||
<div className={styles.cardHeader}>
|
||||
<div className={styles.titleBar} />
|
||||
<h2 className={styles.cardTitle}>预约管理</h2>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>预约名称</span>
|
||||
<Input
|
||||
placeholder="请输入"
|
||||
value={mSearchName}
|
||||
onChange={(e) => setMSearchName(e.target.value)}
|
||||
className={styles.filterInput}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>分类</span>
|
||||
<Select
|
||||
placeholder="请选择"
|
||||
value={mSearchCategory}
|
||||
onChange={setMSearchCategory}
|
||||
className={styles.filterSelect}
|
||||
popupClassName={styles.filterDropdown}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: '模拟面试', label: '模拟面试1' },
|
||||
{ value: '职业咨询', label: '模拟面试2' },
|
||||
{ value: '简历指导', label: '模拟面试3' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>教师名称</span>
|
||||
<Input
|
||||
placeholder="请输入"
|
||||
value={mSearchTeacher}
|
||||
onChange={(e) => setMSearchTeacher(e.target.value)}
|
||||
className={styles.filterInput}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>状态</span>
|
||||
<Select
|
||||
placeholder="请选择"
|
||||
value={mSearchStatus}
|
||||
onChange={setMSearchStatus}
|
||||
className={styles.filterSelect}
|
||||
popupClassName={styles.filterDropdown}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'notStarted', label: '未开始' },
|
||||
{ value: 'ongoing', label: '进行中' },
|
||||
{ value: 'ended', label: '已结束' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.filterRow} style={{ marginTop: 0 }}>
|
||||
<div className={styles.filterBtns}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setMCurrentPage(1);
|
||||
fetchManageList({
|
||||
name: mSearchName,
|
||||
category: mSearchCategory,
|
||||
teacher: mSearchTeacher,
|
||||
status: mSearchStatus,
|
||||
page: 1,
|
||||
pageSize: mPageSize,
|
||||
});
|
||||
}}
|
||||
className={styles.searchBtn}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMSearchName('');
|
||||
setMSearchCategory(undefined);
|
||||
setMSearchTeacher('');
|
||||
setMSearchStatus(undefined);
|
||||
setMCurrentPage(1);
|
||||
fetchManageList({ name: '', category: '', teacher: '', status: '', page: 1, pageSize: mPageSize });
|
||||
}}
|
||||
className={styles.resetBtn}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!mLoading && mDataSource.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<span className={styles.emptyText}>暂无内容</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={manageColumns}
|
||||
dataSource={mDataSource}
|
||||
rowKey="id"
|
||||
loading={mLoading}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={mTotal > mPageSize ? {
|
||||
total: mTotal,
|
||||
current: mCurrentPage,
|
||||
pageSize: mPageSize,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (t) => `共${t}条`,
|
||||
onChange: (p, ps) => {
|
||||
setMCurrentPage(p);
|
||||
setMPageSize(ps);
|
||||
},
|
||||
} : false}
|
||||
size="middle"
|
||||
className={styles.dataTable}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
<div className={styles.cardHeader}>
|
||||
<div className={styles.titleBar} />
|
||||
<h2 className={styles.cardTitle}>预约列表</h2>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>预约名称</span>
|
||||
<Input
|
||||
placeholder="请输入"
|
||||
value={searchName}
|
||||
onChange={(e) => setSearchName(e.target.value)}
|
||||
className={styles.filterInput}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>学生姓名</span>
|
||||
<Input
|
||||
placeholder="请输入"
|
||||
value={searchStudent}
|
||||
onChange={(e) => setSearchStudent(e.target.value)}
|
||||
className={styles.filterInput}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<span className={styles.filterLabel}>时间</span>
|
||||
<DatePicker
|
||||
placeholder="请选择日期"
|
||||
value={searchDate}
|
||||
onChange={setSearchDate}
|
||||
className={styles.filterDatePicker}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterBtns}>
|
||||
<Button type="primary" onClick={handleSearch} className={styles.searchBtn}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={handleReset} className={styles.resetBtn}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={tabItems}
|
||||
className={styles.tableTabs}
|
||||
/>
|
||||
|
||||
{!loading && dataSource.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<span className={styles.emptyText}>暂无内容</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={total > pageSize ? {
|
||||
total,
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (t) => `共${t}条`,
|
||||
onChange: (p, ps) => {
|
||||
setCurrentPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
} : false}
|
||||
size="middle"
|
||||
className={styles.dataTable}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
|
||||
{/* 预约弹窗 */}
|
||||
<Modal
|
||||
title="预约"
|
||||
open={bookModalVisible}
|
||||
onCancel={resetBookModal}
|
||||
footer={null}
|
||||
width={560}
|
||||
centered
|
||||
className={styles.bookModal}
|
||||
destroyOnClose
|
||||
>
|
||||
{bookingRecord && (
|
||||
<div className={styles.bookModalBody}>
|
||||
<div className={styles.bookDesc}>
|
||||
<div className={styles.bookDescTitle}>面试说明</div>
|
||||
<div className={styles.bookDescContent}>
|
||||
本次实战预约是针对AI面试达到90分以上切面试次数达到5次的同学,未完成的同学可继续通过AI进行训练
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.bookField}>
|
||||
<div className={styles.bookFieldLabel}>预约地点</div>
|
||||
<Select
|
||||
placeholder="请选择"
|
||||
value={bookLocation}
|
||||
onChange={setBookLocation}
|
||||
className={styles.bookFieldSelect}
|
||||
popupClassName={styles.filterDropdown}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: '教室1', label: '教室1' },
|
||||
{ value: '教室2', label: '教室2' },
|
||||
{ value: '会议室A', label: '会议室A' },
|
||||
{ value: '报告厅', label: '报告厅' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.bookField}>
|
||||
<div className={styles.bookFieldLabel}>日期选择</div>
|
||||
<DatePicker
|
||||
placeholder="请选择日期"
|
||||
value={bookDate}
|
||||
onChange={setBookDate}
|
||||
className={styles.bookFieldDate}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.bookField}>
|
||||
<div className={styles.bookFieldLabel}>场次选择</div>
|
||||
<Select
|
||||
placeholder="请选择"
|
||||
value={bookSession}
|
||||
onChange={setBookSession}
|
||||
className={styles.bookFieldSelect}
|
||||
popupClassName={styles.filterDropdown}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: '09:00-11:00', label: '09:00-11:00' },
|
||||
{ value: '14:00-16:00', label: '14:00-16:00' },
|
||||
{ value: '19:00-21:00', label: '19:00-21:00' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.bookField}>
|
||||
<div className={styles.bookFieldLabel}>意向岗位</div>
|
||||
<Input
|
||||
placeholder="请输入"
|
||||
value={bookPosition}
|
||||
onChange={(e) => setBookPosition(e.target.value)}
|
||||
className={styles.bookFieldInput}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.bookFooter}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleBookSubmit}
|
||||
loading={bookSubmitting}
|
||||
className={styles.bookConfirmBtn}
|
||||
>
|
||||
确定预约
|
||||
</Button>
|
||||
<Button onClick={resetBookModal} className={styles.bookCancelBtn}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Appointment;
|
||||
27
src/pages/Home/components/BannerCarousel/index.less
Normal file
27
src/pages/Home/components/BannerCarousel/index.less
Normal file
@@ -0,0 +1,27 @@
|
||||
.bannerSection {
|
||||
width: 100%;
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
|
||||
:global {
|
||||
.ant-carousel .slick-dots li button {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.ant-carousel .slick-dots li.slick-active button {
|
||||
background: #008d83;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bannerSlide {
|
||||
width: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bannerImg {
|
||||
width: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
67
src/pages/Home/components/BannerCarousel/index.tsx
Normal file
67
src/pages/Home/components/BannerCarousel/index.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Carousel, Spin } from 'antd';
|
||||
import { useNavigate, request } from '@umijs/max';
|
||||
import styles from './index.less';
|
||||
|
||||
interface BannerItem {
|
||||
id: number;
|
||||
title: string;
|
||||
highlightText: string;
|
||||
subtitle: string;
|
||||
date: string;
|
||||
imageUrl: string;
|
||||
linkUrl: string;
|
||||
sort: number;
|
||||
status: number;
|
||||
}
|
||||
|
||||
const BannerCarousel: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [banners, setBanners] = useState<BannerItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request('/api/home/banners')
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
setBanners(res.data || []);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.bannerSection}>
|
||||
<div style={{ height: 320, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (banners.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.bannerSection}>
|
||||
<Carousel autoplay>
|
||||
{banners.map((banner) => (
|
||||
<div key={banner.id}>
|
||||
<div
|
||||
className={styles.bannerSlide}
|
||||
onClick={() => banner.linkUrl && navigate(banner.linkUrl)}
|
||||
>
|
||||
<img
|
||||
src={banner.imageUrl}
|
||||
alt={banner.title}
|
||||
className={styles.bannerImg}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannerCarousel;
|
||||
39
src/pages/Home/components/Footer/index.less
Normal file
39
src/pages/Home/components/Footer/index.less
Normal file
@@ -0,0 +1,39 @@
|
||||
.footer {
|
||||
background: #00A196;
|
||||
padding: 20px 40px;
|
||||
}
|
||||
|
||||
.footerInner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.footerLinks {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footerLink {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.footerDivider {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.footerCopy {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
text-align: left;
|
||||
}
|
||||
24
src/pages/Home/components/Footer/index.tsx
Normal file
24
src/pages/Home/components/Footer/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import styles from './index.less';
|
||||
|
||||
const footerLinks = ['关于我们', '版权所有', '免责声明', '联系我们', '帮助文档'];
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.footerInner}>
|
||||
<div className={styles.footerLinks}>
|
||||
{footerLinks.map((text, idx) => (
|
||||
<React.Fragment key={text}>
|
||||
{idx > 0 && <span className={styles.footerDivider}>|</span>}
|
||||
<span className={styles.footerLink}>{text}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.footerCopy}>技术支持:武汉XXXXX公司</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
72
src/pages/Home/components/JobList/index.less
Normal file
72
src/pages/Home/components/JobList/index.less
Normal file
@@ -0,0 +1,72 @@
|
||||
/* ========== Job Section ========== */
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-family: 'HuXiaoBo-NanShen', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 26px;
|
||||
color: #00A196;
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
font-style: normal;
|
||||
text-transform: none;
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid #00A196;
|
||||
}
|
||||
|
||||
.sectionMore {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #008d83;
|
||||
}
|
||||
}
|
||||
|
||||
.jobGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
/* ========== Load More Button ========== */
|
||||
.loadMoreWrap {
|
||||
text-align: center;
|
||||
padding: 16px 0 40px;
|
||||
}
|
||||
|
||||
.loadMoreBtn {
|
||||
display: inline-block;
|
||||
padding: 10px 48px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #008d83;
|
||||
border-color: #008d83;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.jobGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
.jobGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
82
src/pages/Home/components/JobList/index.tsx
Normal file
82
src/pages/Home/components/JobList/index.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { RightOutlined } from '@ant-design/icons';
|
||||
import { request, useNavigate } from '@umijs/max';
|
||||
import { Spin, message } from 'antd';
|
||||
import JobCard from '@/components/JobCard';
|
||||
import type { JobItem } from '@/components/JobCard';
|
||||
import styles from './index.less';
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
const JobList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [jobs, setJobs] = useState<JobItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [current, setCurrent] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const fetchJobs = async (page: number, append = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request('/api/job/position/page', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
current: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
sortField: 'publishDate',
|
||||
sortOrder: 'descend',
|
||||
},
|
||||
});
|
||||
if (res.code === 0 && res.data) {
|
||||
const records = res.data.records || [];
|
||||
setJobs((prev) => (append ? [...prev, ...records] : records));
|
||||
setTotal(res.data.total || 0);
|
||||
setCurrent(page);
|
||||
} else {
|
||||
message.error(res.message || '获取岗位列表失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('获取岗位列表失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs(1);
|
||||
}, []);
|
||||
|
||||
const hasMore = jobs.length < total;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Section Header */}
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>岗位最新动态</div>
|
||||
<span className={styles.sectionMore} onClick={() => navigate('/jobs')}>
|
||||
更多 <RightOutlined style={{ fontSize: 10 }} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Job Grid */}
|
||||
<Spin spinning={loading && jobs.length === 0}>
|
||||
<div className={styles.jobGrid}>
|
||||
{jobs.map((job) => (
|
||||
<JobCard key={job.id} job={job} />
|
||||
))}
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
{/* Load More → navigate to /jobs */}
|
||||
{hasMore && (
|
||||
<div className={styles.loadMoreWrap}>
|
||||
<span className={styles.loadMoreBtn} onClick={() => navigate('/jobs')}>
|
||||
查看更多
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobList;
|
||||
232
src/pages/Home/components/Navbar/index.less
Normal file
232
src/pages/Home/components/Navbar/index.less
Normal file
@@ -0,0 +1,232 @@
|
||||
.navbar {
|
||||
width: 100vw;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 13.54%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
background: transparent;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.navLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.logoWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logoImg {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.logoTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #008d83;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navMenu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
padding: 6px 16px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: #008d83;
|
||||
}
|
||||
}
|
||||
|
||||
.navItemActive {
|
||||
color: #008d83;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
height: 2px;
|
||||
background: #008d83;
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.navRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: none;
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.mobileOverlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileMenu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 0 16px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.navLeft {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.navMenu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navRight {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mobileOverlay {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 52px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 99;
|
||||
animation: fadeIn 0.25s ease;
|
||||
}
|
||||
|
||||
.mobileMenu {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 52px;
|
||||
right: -280px;
|
||||
width: 280px;
|
||||
height: calc(100vh - 52px);
|
||||
background: #fff;
|
||||
z-index: 100;
|
||||
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.1);
|
||||
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow-y: auto;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.mobileMenuOpen {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.mobileNavList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.mobileNavItem {
|
||||
padding: 14px 12px;
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #008d83;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileNavItemActive {
|
||||
color: #008d83;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobileUserInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 28px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.mobileUserName {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.mobileActions {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.mobileActionItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
color: #008d83;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
178
src/pages/Home/components/Navbar/index.tsx
Normal file
178
src/pages/Home/components/Navbar/index.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Avatar, Dropdown } from 'antd';
|
||||
import { DownOutlined, UserOutlined, LogoutOutlined, MenuOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from '@umijs/max';
|
||||
import styles from './index.less';
|
||||
|
||||
const navItems = [
|
||||
{ key: 'home', label: '首页', path: '/home' },
|
||||
{ key: 'jobs', label: '就业机会', path: '/jobs' },
|
||||
{ key: 'interview', label: 'AI面试', path: '' },
|
||||
{ key: 'resume', label: 'AI简历', path: '/resume' },
|
||||
{ key: 'booking', label: '预约管理', path: '/appointment' },
|
||||
{ key: 'improve', label: '就业能力提升', path: '' },
|
||||
];
|
||||
|
||||
const pathToKey: Record<string, string> = {};
|
||||
navItems.forEach((item) => {
|
||||
if (item.path) pathToKey[item.path] = item.key;
|
||||
});
|
||||
|
||||
const NAVBAR_HEIGHT = 60;
|
||||
const TRANSITION_ZONE = 100;
|
||||
|
||||
interface NavbarProps {
|
||||
solidBg?: boolean;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ solidBg = false }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const currentKey = pathToKey[location.pathname] || 'home';
|
||||
const [activeNav, setActiveNav] = useState(currentKey);
|
||||
const [bgOpacity, setBgOpacity] = useState(solidBg ? 1 : 0);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const bannerHeightRef = useRef(600);
|
||||
|
||||
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
const displayName = userInfo.realName || userInfo.username || '用户';
|
||||
|
||||
const measureBanner = useCallback(() => {
|
||||
const bannerWrap = document.querySelector('[class*="bannerWrap"]');
|
||||
if (bannerWrap) {
|
||||
bannerHeightRef.current = bannerWrap.clientHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const scrollY = window.scrollY || window.pageYOffset;
|
||||
const scrollEnd = bannerHeightRef.current - NAVBAR_HEIGHT;
|
||||
const scrollStart = scrollEnd - TRANSITION_ZONE;
|
||||
if (scrollY <= scrollStart) {
|
||||
setBgOpacity(0);
|
||||
} else if (scrollY >= scrollEnd) {
|
||||
setBgOpacity(1);
|
||||
} else {
|
||||
setBgOpacity((scrollY - scrollStart) / (scrollEnd - scrollStart));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (solidBg) return;
|
||||
measureBanner();
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', measureBanner);
|
||||
handleScroll();
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', measureBanner);
|
||||
};
|
||||
}, [solidBg, handleScroll, measureBanner]);
|
||||
|
||||
const isAdminOrTeacher = userInfo.userType === 1 || userInfo.userType === 2;
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人中心',
|
||||
},
|
||||
...(isAdminOrTeacher
|
||||
? [
|
||||
{
|
||||
key: 'admin',
|
||||
icon: <SettingOutlined />,
|
||||
label: '管理端',
|
||||
onClick: () => {
|
||||
window.open('/admin', '_blank');
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userInfo');
|
||||
navigate('/login');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={styles.navbar}
|
||||
style={{
|
||||
background: `rgba(255, 255, 255, ${bgOpacity})`,
|
||||
boxShadow: bgOpacity > 0 ? `0 2px 8px rgba(0, 0, 0, ${bgOpacity * 0.08})` : 'none',
|
||||
}}
|
||||
>
|
||||
<div className={styles.navLeft}>
|
||||
<div className={styles.logoWrap}>
|
||||
<span className={styles.logoTitle}>AI就业平台</span>
|
||||
</div>
|
||||
<ul className={styles.navMenu}>
|
||||
{navItems.map((item) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className={`${styles.navItem} ${activeNav === item.key ? styles.navItemActive : ''}`}
|
||||
onClick={() => {
|
||||
setActiveNav(item.key);
|
||||
if (item.path) navigate(item.path);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<div className={styles.navRight}>
|
||||
<Avatar size={32} icon={<UserOutlined />} />
|
||||
<span className={styles.userName}>{displayName}</span>
|
||||
<DownOutlined style={{ fontSize: 10, color: '#999' }} />
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
<div className={styles.hamburger} onClick={() => setMenuOpen(!menuOpen)}>
|
||||
{menuOpen ? <CloseOutlined /> : <MenuOutlined />}
|
||||
</div>
|
||||
|
||||
{menuOpen && <div className={styles.mobileOverlay} onClick={() => setMenuOpen(false)} />}
|
||||
<div className={`${styles.mobileMenu} ${menuOpen ? styles.mobileMenuOpen : ''}`}>
|
||||
<ul className={styles.mobileNavList}>
|
||||
{navItems.map((item) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className={`${styles.mobileNavItem} ${activeNav === item.key ? styles.mobileNavItemActive : ''}`}
|
||||
onClick={() => { setActiveNav(item.key); setMenuOpen(false); if (item.path) navigate(item.path); }}
|
||||
>
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.mobileUserInfo}>
|
||||
<Avatar size={32} icon={<UserOutlined />} />
|
||||
<span className={styles.mobileUserName}>{displayName}</span>
|
||||
</div>
|
||||
<div className={styles.mobileActions}>
|
||||
<div className={styles.mobileActionItem} onClick={() => { navigate('/profile'); setMenuOpen(false); }}>
|
||||
<UserOutlined /> 个人中心
|
||||
</div>
|
||||
{isAdminOrTeacher && (
|
||||
<div className={styles.mobileActionItem} onClick={() => { window.open('/admin', '_blank'); setMenuOpen(false); }}>
|
||||
<SettingOutlined /> 管理端
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.mobileActionItem} onClick={() => { localStorage.removeItem('token'); localStorage.removeItem('userInfo'); navigate('/login'); }}>
|
||||
<LogoutOutlined /> 退出登录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
63
src/pages/Home/components/StatsCards/index.less
Normal file
63
src/pages/Home/components/StatsCards/index.less
Normal file
@@ -0,0 +1,63 @@
|
||||
.statsRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.statCard {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
height: 120px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.statCardImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: fill;
|
||||
}
|
||||
|
||||
.statOverlay {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
bottom: 38px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-family: 'DIN Alternate', 'DIN', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-weight: bold;
|
||||
font-size: 28px;
|
||||
line-height: 34px;
|
||||
color: inherit;
|
||||
font-style: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.statUnit {
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
color: inherit;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.statsRow {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
.statsRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
27
src/pages/Home/components/StatsCards/index.tsx
Normal file
27
src/pages/Home/components/StatsCards/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import styles from './index.less';
|
||||
|
||||
const statsCards = [
|
||||
{ key: 'task', image: '/assets/任务.png', alt: '待完成学校任务', value: '899', unit: '项', color: '#00A196' },
|
||||
{ key: 'job', image: '/assets/岗位.png', alt: '岗位搜索', value: '85', unit: '个', color: '#D4951A' },
|
||||
{ key: 'resume', image: '/assets/简历.png', alt: '我的简历', value: '5', unit: '场', color: '#5B7FC3' },
|
||||
{ key: 'booking', image: '/assets/预约.png', alt: '最近预约', value: '12/15 10:30', unit: '', color: '#E08C6A' },
|
||||
];
|
||||
|
||||
const StatsCards: React.FC = () => {
|
||||
return (
|
||||
<div className={styles.statsRow}>
|
||||
{statsCards.map((card) => (
|
||||
<div className={styles.statCard} key={card.key}>
|
||||
<img src={card.image} alt={card.alt} className={styles.statCardImg} />
|
||||
<div className={styles.statOverlay} style={{ color: card.color }}>
|
||||
<span className={styles.statValue}>{card.value}</span>
|
||||
{card.unit && <span className={styles.statUnit}>{card.unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsCards;
|
||||
28
src/pages/Home/index.less
Normal file
28
src/pages/Home/index.less
Normal file
@@ -0,0 +1,28 @@
|
||||
/* ========== Page Layout ========== */
|
||||
.homePage {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ========== Banner + Navbar Wrapper ========== */
|
||||
.bannerWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ========== Content Wrapper ========== */
|
||||
.contentArea {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 32px 40px 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.contentArea {
|
||||
padding: 20px 16px 0;
|
||||
}
|
||||
}
|
||||
30
src/pages/Home/index.tsx
Normal file
30
src/pages/Home/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import Navbar from './components/Navbar';
|
||||
import BannerCarousel from './components/BannerCarousel';
|
||||
import StatsCards from './components/StatsCards';
|
||||
import JobList from './components/JobList';
|
||||
import Footer from './components/Footer';
|
||||
import styles from './index.less';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
return (
|
||||
<div className={styles.homePage}>
|
||||
{/* ===== Banner + Navbar ===== */}
|
||||
<div className={styles.bannerWrap}>
|
||||
<Navbar />
|
||||
<BannerCarousel />
|
||||
</div>
|
||||
|
||||
{/* ===== Content Area ===== */}
|
||||
<div className={styles.contentArea}>
|
||||
<StatsCards />
|
||||
<JobList />
|
||||
</div>
|
||||
|
||||
{/* ===== Footer ===== */}
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
174
src/pages/Jobs/index.less
Normal file
174
src/pages/Jobs/index.less
Normal file
@@ -0,0 +1,174 @@
|
||||
@import (reference) '../../global.less';
|
||||
|
||||
.jobsPage {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navPlaceholder {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.contentArea {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 24px 40px 40px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ========== Filter Bar ========== */
|
||||
.filterBar {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.searchRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filterRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.resetBtn {
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
&:hover {
|
||||
color: #008d83;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== Result Info ========== */
|
||||
.resultInfo {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.resultCount {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.resultCount em {
|
||||
font-style: normal;
|
||||
color: #008d83;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ========== Job Grid (same as Home) ========== */
|
||||
.jobGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
/* ========== Pagination ========== */
|
||||
.paginationWrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0 0;
|
||||
|
||||
:global {
|
||||
.ant-pagination {
|
||||
.ant-pagination-item-active {
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
|
||||
a {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-item:hover:not(.ant-pagination-item-active) {
|
||||
background: #F2F7FF !important;
|
||||
border-color: #00A196;
|
||||
|
||||
a {
|
||||
color: #00A196;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-prev:hover .ant-pagination-item-link,
|
||||
.ant-pagination-next:hover .ant-pagination-item-link {
|
||||
background: #F2F7FF !important;
|
||||
color: #00A196;
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-pagination-options {
|
||||
.ant-select-selector {
|
||||
&:hover {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: #00A196 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-options-quick-jumper input:hover {
|
||||
border-color: #00A196;
|
||||
}
|
||||
|
||||
.ant-pagination-options-quick-jumper input:focus {
|
||||
border-color: #00A196;
|
||||
box-shadow: 0 0 0 2px rgba(0, 161, 150, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== Empty ========== */
|
||||
.emptyWrap {
|
||||
padding: 80px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.jobGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.contentArea {
|
||||
padding: 16px;
|
||||
}
|
||||
.jobGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.searchRow {
|
||||
flex-direction: column;
|
||||
}
|
||||
.filterRow {
|
||||
flex-direction: column;
|
||||
}
|
||||
.filterSelect {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
188
src/pages/Jobs/index.tsx
Normal file
188
src/pages/Jobs/index.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Input, Select, Button, Spin, Pagination, Empty, message, ConfigProvider } from 'antd';
|
||||
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { request } from '@umijs/max';
|
||||
import { formatGB2260Standard, dataOf202011 } from 'administrative-division-cn';
|
||||
import Navbar from '../Home/components/Navbar';
|
||||
import Footer from '../Home/components/Footer';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import JobCard from '@/components/JobCard';
|
||||
import type { JobItem } from '@/components/JobCard';
|
||||
import styles from './index.less';
|
||||
|
||||
interface Filters {
|
||||
keyword: string;
|
||||
workLocation: string;
|
||||
sourceChannel: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 12;
|
||||
|
||||
const SOURCE_OPTIONS = ['校方', '24365', '国聘'];
|
||||
|
||||
const Jobs: React.FC = () => {
|
||||
// 使用 useMemo 缓存省份数据
|
||||
const LOCATION_OPTIONS = useMemo(() => {
|
||||
const formattedData = formatGB2260Standard(dataOf202011);
|
||||
const provinces = formattedData.filter(item => item.type === 'province');
|
||||
return provinces.map(province => province.name);
|
||||
}, []);
|
||||
const [jobs, setJobs] = useState<JobItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [current, setCurrent] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
keyword: '',
|
||||
workLocation: '',
|
||||
sourceChannel: '',
|
||||
});
|
||||
|
||||
const fetchJobs = useCallback(async (page: number, f: Filters, size?: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request('/api/job/position/page', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
current: page,
|
||||
pageSize: size ?? pageSize,
|
||||
sortField: 'publishDate',
|
||||
sortOrder: 'descend',
|
||||
keyword: f.keyword || undefined,
|
||||
workLocation: f.workLocation || undefined,
|
||||
sourceChannel: f.sourceChannel || undefined,
|
||||
},
|
||||
});
|
||||
if (res.code === 0 && res.data) {
|
||||
setJobs(res.data.records || []);
|
||||
setTotal(res.data.total || 0);
|
||||
setCurrent(page);
|
||||
} else {
|
||||
message.error(res.message || '获取岗位列表失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('获取岗位列表失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs(1, filters);
|
||||
}, []);
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchJobs(1, filters);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const empty: Filters = { keyword: '', workLocation: '', sourceChannel: '' };
|
||||
setFilters(empty);
|
||||
fetchJobs(1, empty);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number, size: number) => {
|
||||
if (size !== pageSize) {
|
||||
setPageSize(size);
|
||||
setCurrent(1);
|
||||
fetchJobs(1, filters, size);
|
||||
} else {
|
||||
fetchJobs(page, filters);
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<div className={styles.jobsPage}>
|
||||
<Navbar solidBg />
|
||||
<div className={styles.navPlaceholder} />
|
||||
|
||||
<div className={styles.contentArea}>
|
||||
{/* Filter Bar */}
|
||||
<div className={styles.filterBar}>
|
||||
<div className={styles.searchRow}>
|
||||
<Input
|
||||
className={styles.searchInput}
|
||||
placeholder="搜索岗位名称或公司名称"
|
||||
prefix={<SearchOutlined style={{ color: '#bbb' }} />}
|
||||
value={filters.keyword}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, keyword: e.target.value }))}
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" style={{ background: '#00A196' }} onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.filterRow}>
|
||||
<Select
|
||||
className={styles.filterSelect}
|
||||
placeholder="工作地点"
|
||||
allowClear
|
||||
value={filters.workLocation || undefined}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, workLocation: v || '' }))}
|
||||
options={LOCATION_OPTIONS.map((l) => ({ label: l, value: l }))}
|
||||
/>
|
||||
<Select
|
||||
className={styles.filterSelect}
|
||||
placeholder="来源渠道"
|
||||
allowClear
|
||||
value={filters.sourceChannel || undefined}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, sourceChannel: v || '' }))}
|
||||
options={SOURCE_OPTIONS.map((l) => ({ label: l, value: l }))}
|
||||
/>
|
||||
<span className={styles.resetBtn} onClick={handleReset}>
|
||||
<ReloadOutlined /> 重置
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result Info */}
|
||||
<div className={styles.resultInfo}>
|
||||
<span className={styles.resultCount}>
|
||||
共找到 <em>{total}</em> 个岗位
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Job Grid */}
|
||||
<Spin spinning={loading}>
|
||||
{jobs.length > 0 ? (
|
||||
<div className={styles.jobGrid}>
|
||||
{jobs.map((job) => (
|
||||
<JobCard key={job.id} job={job} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!loading && (
|
||||
<div className={styles.emptyWrap}>
|
||||
<Empty description="暂无匹配岗位" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > pageSize && (
|
||||
<div className={styles.paginationWrap}>
|
||||
<Pagination
|
||||
current={current}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={['12', '24', '48', '96']}
|
||||
showSizeChanger
|
||||
showQuickJumper
|
||||
showTotal={(t) => `共${t}条`}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Jobs;
|
||||
99
src/pages/Login/components/SliderVerify/index.less
Normal file
99
src/pages/Login/components/SliderVerify/index.less
Normal file
@@ -0,0 +1,99 @@
|
||||
.sliderVerify {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: 44px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fillBar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: #99D9D5;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #141E35;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.successText {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #141E35;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 48px;
|
||||
height: 100%;
|
||||
background: #00A196;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
z-index: 4;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #009185;
|
||||
}
|
||||
}
|
||||
|
||||
.dragging {
|
||||
cursor: grabbing;
|
||||
background: #009185;
|
||||
}
|
||||
|
||||
.sliderSuccess {
|
||||
cursor: default;
|
||||
background: #00A196;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: auto !important;
|
||||
}
|
||||
|
||||
.arrowIcon {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.successIcon {
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.verified {
|
||||
.track {
|
||||
border-color: #5bb4a7;
|
||||
}
|
||||
|
||||
.fillBar {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
97
src/pages/Login/components/SliderVerify/index.tsx
Normal file
97
src/pages/Login/components/SliderVerify/index.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { CheckCircleOutlined, DoubleRightOutlined } from '@ant-design/icons';
|
||||
import styles from './index.less';
|
||||
|
||||
interface SliderVerifyProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const SliderVerify: React.FC<SliderVerifyProps> = ({ onSuccess }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
const [verified, setVerified] = useState(false);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const startXRef = useRef(0);
|
||||
const sliderWidth = 48;
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (verified) return;
|
||||
setIsDragging(true);
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
startXRef.current = clientX - offsetX;
|
||||
},
|
||||
[verified, offsetX],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging || verified) return;
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
const maxOffset = track.offsetWidth - sliderWidth;
|
||||
let newOffset = clientX - startXRef.current;
|
||||
newOffset = Math.max(0, Math.min(newOffset, maxOffset));
|
||||
setOffsetX(newOffset);
|
||||
},
|
||||
[isDragging, verified],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isDragging || verified) return;
|
||||
setIsDragging(false);
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
const maxOffset = track.offsetWidth - sliderWidth;
|
||||
if (offsetX >= maxOffset - 4) {
|
||||
setVerified(true);
|
||||
setOffsetX(maxOffset);
|
||||
onSuccess?.();
|
||||
} else {
|
||||
setOffsetX(0);
|
||||
}
|
||||
}, [isDragging, verified, offsetX, onSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
window.addEventListener('touchmove', handleMouseMove);
|
||||
window.addEventListener('touchend', handleMouseUp);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
window.removeEventListener('touchmove', handleMouseMove);
|
||||
window.removeEventListener('touchend', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<div className={`${styles.sliderVerify} ${verified ? styles.verified : ''}`}>
|
||||
<div className={styles.track} ref={trackRef}>
|
||||
<div
|
||||
className={styles.fillBar}
|
||||
style={{ width: offsetX + sliderWidth }}
|
||||
/>
|
||||
{!verified && <span className={styles.hint}>向右滑动滑块验证</span>}
|
||||
{verified && <span className={styles.successText}>验证成功</span>}
|
||||
<div
|
||||
className={`${styles.slider} ${isDragging ? styles.dragging : ''} ${verified ? styles.sliderSuccess : ''}`}
|
||||
style={{ left: offsetX }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleMouseDown}
|
||||
>
|
||||
{verified ? (
|
||||
<CheckCircleOutlined className={styles.successIcon} />
|
||||
) : (
|
||||
<DoubleRightOutlined className={styles.arrowIcon} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SliderVerify;
|
||||
296
src/pages/Login/index.less
Normal file
296
src/pages/Login/index.less
Normal file
@@ -0,0 +1,296 @@
|
||||
.loginContainer {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
height: 80px;
|
||||
background: linear-gradient(180deg, #008D83 0%, rgba(128, 168, 165, 0) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
box-sizing: border-box;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logoArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.loginCard {
|
||||
width: 420px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 40px 48px;
|
||||
margin-right: 10%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-bottom: 24px;
|
||||
|
||||
:global {
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
font-family: PingFang SC, PingFang SC;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
line-height: 24px;
|
||||
padding: 8px 0;
|
||||
|
||||
&:hover {
|
||||
color: #00A196;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active {
|
||||
.ant-tabs-tab-btn {
|
||||
color: #00A196 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
background: #00A196;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d9d9d9;
|
||||
caret-color: #00A196;
|
||||
|
||||
&::placeholder {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:global(.ant-input-affix-wrapper-focused) {
|
||||
border-color: #5bb4a7;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global {
|
||||
.ant-form-item-label > label {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-input-password {
|
||||
height: 40px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
.ant-input {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rememberRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.remember {
|
||||
:global {
|
||||
.ant-checkbox-inner {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ant-checkbox:hover .ant-checkbox-inner {
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
|
||||
.ant-checkbox-checked:hover .ant-checkbox-inner {
|
||||
background-color: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
|
||||
.ant-checkbox-wrapper:hover .ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
|
||||
.ant-checkbox-checked::after {
|
||||
border-color: #00A196 !important;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forgotTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #00A196;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid #00A196;
|
||||
display: inline-block;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: PingFang SC, PingFang SC;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
color: #4E5969;
|
||||
line-height: 22px;
|
||||
text-align: left;
|
||||
font-style: normal;
|
||||
text-transform: none;
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #00A196;
|
||||
}
|
||||
}
|
||||
|
||||
.forgotPassword {
|
||||
color: #00A196;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #009185;
|
||||
}
|
||||
}
|
||||
|
||||
.loginBtn {
|
||||
height: 44px;
|
||||
font-family: PingFang SC, PingFang SC;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
color: #F0F2F5 !important;
|
||||
line-height: 24px;
|
||||
background: #00A196 !important;
|
||||
border: none !important;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&[disabled] {
|
||||
background: linear-gradient(0deg, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.6)), #00A196 !important;
|
||||
border: none !important;
|
||||
color: #F0F2F5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.codeInputWrapper {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.codeInput {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
caret-color: #00A196;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:global(.ant-input-affix-wrapper-focused) {
|
||||
border-color: #5bb4a7;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.codeBtn {
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background: #00A196 !important;
|
||||
border: none !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover {
|
||||
background: #009185 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #F2F7FF !important;
|
||||
border: 1px solid #9EACC0 !important;
|
||||
border-radius: 4px;
|
||||
color: #9EACC0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.loginCard {
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.loginContainer {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
482
src/pages/Login/index.tsx
Normal file
482
src/pages/Login/index.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Form, Input, Button, Checkbox, message, Tabs } from 'antd';
|
||||
import { UserOutlined, LockOutlined, MobileOutlined, SafetyOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from '@umijs/max';
|
||||
import { request } from '@umijs/max';
|
||||
import { loginConfig } from '@/config/login';
|
||||
import styles from './index.less';
|
||||
import SliderVerify from './components/SliderVerify';
|
||||
|
||||
type LoginType = 'account' | 'code';
|
||||
type PageMode = 'login' | 'forgot';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loginType, setLoginType] = useState<LoginType>('account');
|
||||
const [codeLoading, setCodeLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [sliderVerified, setSliderVerified] = useState(false);
|
||||
const [accountForm] = Form.useForm();
|
||||
const [codeForm] = Form.useForm();
|
||||
const [pageMode, setPageMode] = useState<PageMode>('login');
|
||||
const [forgotStep, setForgotStep] = useState<1 | 2>(1);
|
||||
const [forgotCodeLoading, setForgotCodeLoading] = useState(false);
|
||||
const [forgotCountdown, setForgotCountdown] = useState(0);
|
||||
const [forgotSliderVerified, setForgotSliderVerified] = useState(false);
|
||||
const [forgotForm] = Form.useForm();
|
||||
const [resetForm] = Form.useForm();
|
||||
|
||||
const accountValue = Form.useWatch('account', accountForm);
|
||||
const passwordValue = Form.useWatch('password', accountForm);
|
||||
const phoneValue = Form.useWatch('phone', codeForm);
|
||||
const codeValue = Form.useWatch('code', codeForm);
|
||||
const forgotPhoneValue = Form.useWatch('phone', forgotForm);
|
||||
const forgotCodeValue = Form.useWatch('code', forgotForm);
|
||||
const newPasswordValue = Form.useWatch('newPassword', resetForm);
|
||||
const confirmPasswordValue = Form.useWatch('confirmPassword', resetForm);
|
||||
|
||||
const accountLoginDisabled = !accountValue || !passwordValue;
|
||||
const codeLoginDisabled = !phoneValue || !codeValue || !sliderVerified;
|
||||
const forgotNextDisabled = !forgotPhoneValue || !forgotCodeValue || !forgotSliderVerified;
|
||||
const resetDisabled = !newPasswordValue || !confirmPasswordValue;
|
||||
|
||||
const handleSliderSuccess = useCallback(() => {
|
||||
setSliderVerified(true);
|
||||
}, []);
|
||||
|
||||
const handleForgotSliderSuccess = useCallback(() => {
|
||||
setForgotSliderVerified(true);
|
||||
}, []);
|
||||
|
||||
const handleAccountLogin = async (values: { account: string; password: string; remember: boolean }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
data: { account: values.account, password: values.password, remember: values.remember },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success('登录成功');
|
||||
localStorage.setItem('token', res.data.token);
|
||||
localStorage.setItem('userInfo', JSON.stringify(res.data));
|
||||
if (values.remember) {
|
||||
localStorage.setItem('rememberAccount', values.account);
|
||||
} else {
|
||||
localStorage.removeItem('rememberAccount');
|
||||
}
|
||||
navigate('/home');
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodeLogin = async (values: { phone: string; code: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request('/api/auth/loginByCode', {
|
||||
method: 'POST',
|
||||
data: values,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success('登录成功');
|
||||
localStorage.setItem('token', res.data.token);
|
||||
localStorage.setItem('userInfo', JSON.stringify(res.data));
|
||||
navigate('/home');
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goToForgot = () => {
|
||||
setPageMode('forgot');
|
||||
setForgotStep(1);
|
||||
setForgotSliderVerified(false);
|
||||
forgotForm.resetFields();
|
||||
resetForm.resetFields();
|
||||
};
|
||||
|
||||
const backToLogin = () => {
|
||||
setPageMode('login');
|
||||
setForgotStep(1);
|
||||
setForgotSliderVerified(false);
|
||||
};
|
||||
|
||||
const handleForgotNext = async () => {
|
||||
try {
|
||||
const values = await forgotForm.validateFields();
|
||||
setLoading(true);
|
||||
const res = await request('/api/auth/verifyResetCode', {
|
||||
method: 'POST',
|
||||
data: { phone: values.phone, code: values.code },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
setForgotStep(2);
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.message) message.error(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async (values: { newPassword: string; confirmPassword: string }) => {
|
||||
if (values.newPassword !== values.confirmPassword) {
|
||||
message.error('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const phone = forgotForm.getFieldValue('phone');
|
||||
const code = forgotForm.getFieldValue('code');
|
||||
const res = await request('/api/auth/resetPassword', {
|
||||
method: 'POST',
|
||||
data: { phone, code, newPassword: values.newPassword },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success('密码重置成功,请重新登录');
|
||||
backToLogin();
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '重置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendForgotCode = async () => {
|
||||
try {
|
||||
const phone = forgotForm.getFieldValue('phone');
|
||||
if (!phone) {
|
||||
message.warning('请输入手机号');
|
||||
return;
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
message.warning('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
setForgotCodeLoading(true);
|
||||
const res = await request('/api/auth/sendCode', {
|
||||
method: 'POST',
|
||||
data: { phone, type: 'reset' },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success('验证码已发送');
|
||||
setForgotCountdown(60);
|
||||
const timer = setInterval(() => {
|
||||
setForgotCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '发送失败');
|
||||
} finally {
|
||||
setForgotCodeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendCode = async () => {
|
||||
try {
|
||||
const phone = codeForm.getFieldValue('phone');
|
||||
if (!phone) {
|
||||
message.warning('请输入手机号');
|
||||
return;
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
message.warning('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
setCodeLoading(true);
|
||||
const res = await request('/api/auth/sendCode', {
|
||||
method: 'POST',
|
||||
data: { phone },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
message.success('验证码已发送');
|
||||
setCountdown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '发送失败');
|
||||
} finally {
|
||||
setCodeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'account', label: '账号密码登录' },
|
||||
{ key: 'code', label: '验证码登录' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.loginContainer}
|
||||
style={{ backgroundImage: `url(${loginConfig.backgroundImage})` }}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.logoArea}>
|
||||
<img src={loginConfig.logo} alt="logo" className={styles.logo} />
|
||||
<span className={styles.logoText}>{loginConfig.logoText}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pageMode === 'login' && <div className={styles.loginCard}>
|
||||
<h1 className={styles.title}>{loginConfig.title}</h1>
|
||||
|
||||
<Tabs
|
||||
activeKey={loginType}
|
||||
onChange={(key) => setLoginType(key as LoginType)}
|
||||
items={tabItems}
|
||||
className={styles.tabs}
|
||||
/>
|
||||
|
||||
<div style={{ display: loginType === 'account' ? 'block' : 'none' }}>
|
||||
<Form
|
||||
form={accountForm}
|
||||
name="accountLogin"
|
||||
onFinish={handleAccountLogin}
|
||||
initialValues={{
|
||||
account: localStorage.getItem('rememberAccount') || '',
|
||||
remember: !!localStorage.getItem('rememberAccount'),
|
||||
}}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
label="账号"
|
||||
name="account"
|
||||
required={false}
|
||||
rules={[{ required: true, message: '请输入账号' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined className={styles.inputIcon} />} placeholder="请输入" className={styles.input} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="密码"
|
||||
name="password"
|
||||
required={false}
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined className={styles.inputIcon} />} placeholder="请输入" className={styles.input} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="remember" valuePropName="checked">
|
||||
<div className={styles.rememberRow}>
|
||||
<Checkbox className={styles.remember}>记住我</Checkbox>
|
||||
<a className={styles.forgotPassword} onClick={goToForgot}>忘记密码?</a>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
disabled={accountLoginDisabled}
|
||||
block
|
||||
className={styles.loginBtn}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div style={{ display: loginType === 'code' ? 'block' : 'none' }}>
|
||||
<Form
|
||||
form={codeForm}
|
||||
name="codeLogin"
|
||||
onFinish={handleCodeLogin}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
label="手机号"
|
||||
name="phone"
|
||||
required={false}
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<MobileOutlined className={styles.inputIcon} />} placeholder="请输入" className={styles.input} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="" style={{ marginBottom: 16 }}>
|
||||
<SliderVerify onSuccess={handleSliderSuccess} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="验证码" required={false}>
|
||||
<div className={styles.codeInputWrapper}>
|
||||
<Form.Item name="code" noStyle rules={[{ required: true, message: '请输入验证码' }]}>
|
||||
<Input prefix={<SafetyOutlined className={styles.inputIcon} />} placeholder="验证码" className={styles.codeInput} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
onClick={sendCode}
|
||||
loading={codeLoading}
|
||||
disabled={countdown > 0 || !sliderVerified}
|
||||
className={styles.codeBtn}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s后重发` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
disabled={codeLoginDisabled}
|
||||
block
|
||||
className={styles.loginBtn}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{pageMode === 'forgot' && (
|
||||
<div className={styles.loginCard}>
|
||||
<h1 className={styles.title}>{loginConfig.title}</h1>
|
||||
|
||||
{forgotStep === 1 && (
|
||||
<>
|
||||
<div className={styles.forgotTitle}>找回密码</div>
|
||||
<Form form={forgotForm} name="forgotPassword" layout="vertical">
|
||||
<Form.Item
|
||||
label="手机号"
|
||||
name="phone"
|
||||
required={false}
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<MobileOutlined className={styles.inputIcon} />} placeholder="请输入" className={styles.input} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="" style={{ marginBottom: 16 }}>
|
||||
<SliderVerify onSuccess={handleForgotSliderSuccess} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="验证码" required={false}>
|
||||
<div className={styles.codeInputWrapper}>
|
||||
<Form.Item name="code" noStyle rules={[{ required: true, message: '请输入验证码' }]}>
|
||||
<Input prefix={<SafetyOutlined className={styles.inputIcon} />} placeholder="验证码" className={styles.codeInput} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
onClick={sendForgotCode}
|
||||
loading={forgotCodeLoading}
|
||||
disabled={forgotCountdown > 0 || !forgotSliderVerified}
|
||||
className={styles.codeBtn}
|
||||
>
|
||||
{forgotCountdown > 0 ? `${forgotCountdown}s后重发` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
disabled={forgotNextDisabled}
|
||||
block
|
||||
className={styles.loginBtn}
|
||||
onClick={handleForgotNext}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{forgotStep === 2 && (
|
||||
<>
|
||||
<div className={styles.forgotTitle}>确认密码</div>
|
||||
<a className={styles.backLink} onClick={() => setForgotStep(1)}>
|
||||
<ArrowLeftOutlined /> 上一步
|
||||
</a>
|
||||
<Form form={resetForm} name="resetPassword" onFinish={handleResetPassword} layout="vertical">
|
||||
<Form.Item
|
||||
label="新密码"
|
||||
name="newPassword"
|
||||
required={false}
|
||||
rules={[{ required: true, message: '请输入新密码' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined className={styles.inputIcon} />} placeholder="请输入" className={styles.input} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="再次输入新密码"
|
||||
name="confirmPassword"
|
||||
required={false}
|
||||
rules={[
|
||||
{ required: true, message: '请再次输入新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined className={styles.inputIcon} />} placeholder="请输入" className={styles.input} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
disabled={resetDisabled}
|
||||
block
|
||||
className={styles.loginBtn}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
297
src/pages/Resume/Create/index.less
Normal file
297
src/pages/Resume/Create/index.less
Normal file
@@ -0,0 +1,297 @@
|
||||
@themeColor: #00A196;
|
||||
@themeHover: #009185;
|
||||
@themeBg: rgba(0, 161, 150, 0.06);
|
||||
@themeBorder: rgba(0, 161, 150, 0.15);
|
||||
@themeShadow: rgba(0, 161, 150, 0.1);
|
||||
|
||||
.createPage {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #eef6f5 0%, #F4F4F4 280px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbarWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.contentArea {
|
||||
flex: 1;
|
||||
width: 960px;
|
||||
max-width: calc(100% - 80px);
|
||||
margin: 0 auto;
|
||||
padding: 20px 0 48px;
|
||||
}
|
||||
|
||||
.topBar { margin-bottom: 16px; }
|
||||
|
||||
.backLink {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
&:hover { color: @themeColor; }
|
||||
}
|
||||
|
||||
.headerCard {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 12px @themeShadow;
|
||||
}
|
||||
|
||||
.headerCardInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-family: HuXiaoBo-NanShen, HuXiaoBo-NanShen;
|
||||
font-size: 26px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.templateBadge {
|
||||
display: inline-block;
|
||||
padding: 3px 12px;
|
||||
font-size: 12px;
|
||||
color: @themeColor;
|
||||
background: @themeBg;
|
||||
border: 1px solid @themeBorder;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.headerDesc { font-size: 14px; color: #999; margin: 0; }
|
||||
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.2s;
|
||||
&:hover { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); }
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.sectionIcon {
|
||||
width: 32px; height: 32px; border-radius: 8px;
|
||||
background: @themeBg;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; color: @themeColor; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sectionTitle { font-size: 16px; font-weight: 600; color: #333; }
|
||||
.sectionHint { font-size: 12px; color: #bbb; font-weight: 400; }
|
||||
|
||||
.formGrid4 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px 28px;
|
||||
}
|
||||
|
||||
.formRowSingle { margin-top: 20px; }
|
||||
.formItem { display: flex; flex-direction: column; gap: 8px; }
|
||||
.formItemFull { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
|
||||
|
||||
.label {
|
||||
font-size: 13px; color: #555; font-weight: 500;
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
}
|
||||
|
||||
.required { color: #ff4d4f; }
|
||||
|
||||
.input {
|
||||
height: 40px; border-radius: 8px; border: 1px solid #e0e0e0;
|
||||
caret-color: @themeColor; transition: all 0.2s;
|
||||
&:hover { border-color: @themeColor !important; }
|
||||
&:focus { border-color: @themeColor !important; box-shadow: 0 0 0 3px @themeShadow !important; }
|
||||
:global { .ant-input { caret-color: @themeColor; } }
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 220px !important;
|
||||
:global {
|
||||
.ant-select-selector { height: 40px !important; border-radius: 8px !important; border-color: #e0e0e0 !important; display: flex; align-items: center; }
|
||||
.ant-select-selector:hover { border-color: @themeColor !important; }
|
||||
.ant-select-focused .ant-select-selector { border-color: @themeColor !important; box-shadow: 0 0 0 3px @themeShadow !important; }
|
||||
}
|
||||
}
|
||||
|
||||
.selectFull {
|
||||
width: 100% !important;
|
||||
:global {
|
||||
.ant-select-selector { height: 40px !important; border-radius: 8px !important; border-color: #e0e0e0 !important; display: flex; align-items: center; }
|
||||
.ant-select-selector:hover { border-color: @themeColor !important; }
|
||||
.ant-select-focused .ant-select-selector { border-color: @themeColor !important; box-shadow: 0 0 0 3px @themeShadow !important; }
|
||||
}
|
||||
}
|
||||
|
||||
.selectDropdown {
|
||||
:global {
|
||||
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) { color: @themeColor !important; font-weight: 500; background: @themeBg !important; }
|
||||
.ant-select-item-option-active:not(.ant-select-item-option-disabled):not(.ant-select-item-option-selected) { background: #f5f5f5 !important; }
|
||||
}
|
||||
}
|
||||
|
||||
.textarea {
|
||||
border-radius: 8px; border: 1px solid #e0e0e0;
|
||||
caret-color: @themeColor; padding: 10px 12px; transition: all 0.2s;
|
||||
&:hover { border-color: @themeColor !important; }
|
||||
&:focus { border-color: @themeColor !important; box-shadow: 0 0 0 3px @themeShadow !important; }
|
||||
}
|
||||
|
||||
.expCard {
|
||||
background: #FAFCFE; border: 1px solid #EDF1F5; border-radius: 12px;
|
||||
padding: 20px 24px; margin-bottom: 16px; transition: border-color 0.2s, box-shadow 0.2s;
|
||||
&:hover { border-color: @themeBorder; box-shadow: 0 2px 8px @themeShadow; }
|
||||
}
|
||||
|
||||
.expCardHeader { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
|
||||
.expBadge {
|
||||
font-size: 13px; font-weight: 600; color: @themeColor;
|
||||
background: @themeBg; padding: 2px 12px; border-radius: 12px;
|
||||
}
|
||||
|
||||
.addBtnWrap { display: flex; justify-content: center; margin-top: 8px; }
|
||||
|
||||
.addBtn {
|
||||
height: 40px; border-radius: 20px;
|
||||
border: 1px dashed @themeColor !important; color: @themeColor !important;
|
||||
background: @themeBg !important; font-size: 14px; padding: 0 32px; transition: all 0.2s;
|
||||
&:hover { background: rgba(0, 161, 150, 0.12) !important; border-color: @themeHover !important; color: @themeHover !important; }
|
||||
}
|
||||
|
||||
/* ===== Footer Actions ===== */
|
||||
.footerActions { display: flex; justify-content: center; gap: 24px; margin-top: 16px; padding: 24px 0; }
|
||||
|
||||
.cancelBtn {
|
||||
min-width: 140px; height: 46px; border-radius: 23px; font-size: 15px;
|
||||
border: 1px solid #d9d9d9 !important; color: #666 !important; background: #fff !important; transition: all 0.2s;
|
||||
&:hover { border-color: #bbb !important; color: #333 !important; background: #fafafa !important; }
|
||||
}
|
||||
|
||||
.generateBtn {
|
||||
min-width: 180px; height: 46px; border-radius: 23px; font-size: 15px; font-weight: 500;
|
||||
background: @themeColor !important; border-color: @themeColor !important;
|
||||
box-shadow: 0 4px 16px rgba(0, 161, 150, 0.3); transition: all 0.2s;
|
||||
&:hover { background: @themeHover !important; border-color: @themeHover !important; box-shadow: 0 6px 20px rgba(0, 161, 150, 0.4); transform: translateY(-1px); }
|
||||
}
|
||||
|
||||
/* ===== Loading Overlay ===== */
|
||||
.loadingOverlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.loadingCard {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 48px 56px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
min-width: 400px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.loadingSpinner {
|
||||
font-size: 48px;
|
||||
color: @themeColor;
|
||||
margin-bottom: 20px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(1.08); }
|
||||
}
|
||||
|
||||
.loadingTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.loadingHint {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
.progressSteps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stepItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stepDone {
|
||||
color: @themeColor;
|
||||
.stepDot { color: @themeColor; }
|
||||
}
|
||||
|
||||
.stepActive {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
.stepDot { color: @themeColor; }
|
||||
}
|
||||
|
||||
.stepDot {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepLabel { line-height: 1.4; }
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media screen and (max-width: 768px) {
|
||||
.contentArea { max-width: calc(100% - 24px); padding: 12px 0 32px; }
|
||||
.headerCard { padding: 20px; border-radius: 12px; }
|
||||
.headerCardInner { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.section { padding: 20px 16px; border-radius: 12px; }
|
||||
.formGrid4 { grid-template-columns: 1fr; gap: 14px; }
|
||||
.select, .selectFull { width: 100% !important; }
|
||||
.expCard { padding: 16px; }
|
||||
.footerActions { flex-direction: column-reverse; align-items: center; gap: 12px; }
|
||||
.cancelBtn, .generateBtn { width: 100%; min-width: unset; }
|
||||
}
|
||||
371
src/pages/Resume/Create/index.tsx
Normal file
371
src/pages/Resume/Create/index.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Input, Button, Select, message, ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
UserOutlined,
|
||||
BankOutlined,
|
||||
ReadOutlined,
|
||||
FileSearchOutlined,
|
||||
ArrowLeftOutlined,
|
||||
LoadingOutlined,
|
||||
CheckCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
RocketOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useSearchParams, request } from '@umijs/max';
|
||||
import Navbar from '../../Home/components/Navbar';
|
||||
import Footer from '../../Home/components/Footer';
|
||||
import styles from './index.less';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface WorkExp {
|
||||
company: string;
|
||||
position: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Education {
|
||||
school: string;
|
||||
major: string;
|
||||
degree: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PROGRESS_STEPS = [
|
||||
{ label: '提交表单数据', duration: 2000 },
|
||||
{ label: 'AI 正在分析岗位要求', duration: 6000 },
|
||||
{ label: 'AI 正在生成简历内容', duration: 12000 },
|
||||
{ label: '正在填充 Word 模板', duration: 8000 },
|
||||
{ label: '正在上传文件', duration: 3000 },
|
||||
];
|
||||
|
||||
const degreeOptions = [
|
||||
{ value: '博士', label: '博士' },
|
||||
{ value: '硕士', label: '硕士' },
|
||||
{ value: '本科', label: '本科' },
|
||||
{ value: '大专', label: '大专' },
|
||||
];
|
||||
|
||||
const emptyWork = (): WorkExp => ({ company: '', position: '', startDate: '', endDate: '', description: '' });
|
||||
const emptyEdu = (): Education => ({ school: '', major: '', degree: '', startDate: '', endDate: '', description: '' });
|
||||
|
||||
const ResumeCreate: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const templateId = searchParams.get('templateId') || '1';
|
||||
const templateTitle = searchParams.get('templateTitle') || '';
|
||||
|
||||
// Form fields
|
||||
const [resumeName, setResumeName] = useState('');
|
||||
const [jobIntention, setJobIntention] = useState('');
|
||||
const [expectedSalary, setExpectedSalary] = useState('');
|
||||
const [preferredCity, setPreferredCity] = useState('');
|
||||
const [language, setLanguage] = useState('中文');
|
||||
const [jobDescription, setJobDescription] = useState('');
|
||||
const [workList, setWorkList] = useState<WorkExp[]>([emptyWork()]);
|
||||
const [eduList, setEduList] = useState<Education[]>([emptyEdu()]);
|
||||
|
||||
// Generation state
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [progressStep, setProgressStep] = useState(0);
|
||||
const timerRef = useRef<any>(null);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
}, []);
|
||||
|
||||
// Work experience helpers
|
||||
const addWork = () => setWorkList([...workList, emptyWork()]);
|
||||
const removeWork = (i: number) => setWorkList(workList.filter((_, idx) => idx !== i));
|
||||
const updateWork = (i: number, field: keyof WorkExp, val: string) => {
|
||||
const list = [...workList];
|
||||
list[i] = { ...list[i], [field]: val };
|
||||
setWorkList(list);
|
||||
};
|
||||
|
||||
// Education helpers
|
||||
const addEdu = () => setEduList([...eduList, emptyEdu()]);
|
||||
const removeEdu = (i: number) => setEduList(eduList.filter((_, idx) => idx !== i));
|
||||
const updateEdu = (i: number, field: keyof Education, val: string) => {
|
||||
const list = [...eduList];
|
||||
list[i] = { ...list[i], [field]: val };
|
||||
setEduList(list);
|
||||
};
|
||||
|
||||
// Animate progress steps
|
||||
const animateProgress = useCallback(() => {
|
||||
let step = 0;
|
||||
setProgressStep(0);
|
||||
const next = () => {
|
||||
if (step < PROGRESS_STEPS.length - 1) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
step++;
|
||||
setProgressStep(step);
|
||||
next();
|
||||
}, PROGRESS_STEPS[step].duration);
|
||||
}
|
||||
};
|
||||
next();
|
||||
}, []);
|
||||
|
||||
// Generate resume
|
||||
const handleGenerate = async () => {
|
||||
if (!resumeName.trim()) { message.warning('请输入简历名称'); return; }
|
||||
if (!jobIntention.trim()) { message.warning('请输入求职意向'); return; }
|
||||
if (!jobDescription.trim()) { message.warning('请粘贴岗位描述/JD'); return; }
|
||||
|
||||
setGenerating(true);
|
||||
animateProgress();
|
||||
|
||||
try {
|
||||
const payload: any = {
|
||||
resumeName: resumeName.trim(),
|
||||
jobIntention: jobIntention.trim(),
|
||||
jobDescription: jobDescription.trim(),
|
||||
templateId,
|
||||
};
|
||||
if (expectedSalary.trim()) payload.expectedSalary = expectedSalary.trim();
|
||||
if (preferredCity.trim()) payload.preferredCity = preferredCity.trim();
|
||||
if (language) payload.language = language;
|
||||
|
||||
const validWork = workList.filter(w => w.company.trim() || w.position.trim());
|
||||
if (validWork.length) payload.workExperience = validWork;
|
||||
|
||||
const validEdu = eduList.filter(e => e.school.trim() || e.major.trim());
|
||||
if (validEdu.length) payload.education = validEdu;
|
||||
|
||||
const res = await request('/api/resume/student/ai/generate', {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
if (res.code === 0 && res.data?.url) {
|
||||
setProgressStep(PROGRESS_STEPS.length - 1);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
message.success('简历生成成功');
|
||||
// 跳转到预览页
|
||||
navigate(`/resume/preview?url=${encodeURIComponent(res.data.url)}&name=${encodeURIComponent(resumeName.trim())}`);
|
||||
} else {
|
||||
message.error(res.message || '生成失败,请重试');
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '请求超时或网络异常,请重试');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => navigate(-1);
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<div className={styles.createPage}>
|
||||
<div className={styles.navbarWrap}><Navbar solidBg /></div>
|
||||
|
||||
<div className={styles.contentArea}>
|
||||
{/* Top bar */}
|
||||
<div className={styles.topBar}>
|
||||
<span className={styles.backLink} onClick={handleCancel}>
|
||||
<ArrowLeftOutlined /> 返回模板列表
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className={styles.headerCard}>
|
||||
<div className={styles.headerCardInner}>
|
||||
<h1 className={styles.pageTitle}>AI 智能生成简历</h1>
|
||||
{templateTitle && <span className={styles.templateBadge}>模板:{decodeURIComponent(templateTitle)}</span>}
|
||||
</div>
|
||||
<p className={styles.headerDesc}>填写以下信息,AI 将根据岗位要求为你量身定制简历</p>
|
||||
</div>
|
||||
|
||||
{/* Loading overlay */}
|
||||
{generating && (
|
||||
<div className={styles.loadingOverlay}>
|
||||
<div className={styles.loadingCard}>
|
||||
<div className={styles.loadingSpinner}><LoadingOutlined /></div>
|
||||
<h2 className={styles.loadingTitle}>AI 正在为你生成简历</h2>
|
||||
<p className={styles.loadingHint}>预计需要 10~30 秒,请耐心等待</p>
|
||||
<div className={styles.progressSteps}>
|
||||
{PROGRESS_STEPS.map((s, i) => (
|
||||
<div key={i} className={`${styles.stepItem} ${i < progressStep ? styles.stepDone : ''} ${i === progressStep ? styles.stepActive : ''}`}>
|
||||
<span className={styles.stepDot}>
|
||||
{i < progressStep ? <CheckCircleFilled /> : i === progressStep ? <LoadingOutlined /> : <ClockCircleOutlined />}
|
||||
</span>
|
||||
<span className={styles.stepLabel}>{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 1: Basic Info */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionIcon}><UserOutlined /></div>
|
||||
<span className={styles.sectionTitle}>基本信息</span>
|
||||
<span className={styles.sectionHint}>带 * 为必填项</span>
|
||||
</div>
|
||||
<div className={styles.formGrid4}>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}><span className={styles.required}>*</span> 简历名称</label>
|
||||
<Input className={styles.input} placeholder="如:Java开发工程师简历" value={resumeName} onChange={e => setResumeName(e.target.value)} />
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}><span className={styles.required}>*</span> 求职意向</label>
|
||||
<Input className={styles.input} placeholder="如:Java开发工程师" value={jobIntention} onChange={e => setJobIntention(e.target.value)} />
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}>期望薪资</label>
|
||||
<Input className={styles.input} placeholder="如:15-20K" value={expectedSalary} onChange={e => setExpectedSalary(e.target.value)} />
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}>意向城市</label>
|
||||
<Input className={styles.input} placeholder="如:上海" value={preferredCity} onChange={e => setPreferredCity(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formRowSingle}>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}>语种</label>
|
||||
<Select className={styles.select} value={language} onChange={setLanguage}
|
||||
popupClassName={styles.selectDropdown}
|
||||
options={[{ value: '中文', label: '中文' }, { value: '英文', label: '英文' }]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Work Experience */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionIcon}><BankOutlined /></div>
|
||||
<span className={styles.sectionTitle}>工作/实习经历</span>
|
||||
<span className={styles.sectionHint}>选填,可添加多段经历</span>
|
||||
</div>
|
||||
{workList.map((w, i) => (
|
||||
<div key={i} className={styles.expCard}>
|
||||
<div className={styles.expCardHeader}>
|
||||
<span className={styles.expBadge}>经历 {i + 1}</span>
|
||||
{workList.length > 1 && (
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeWork(i)}>删除</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.formGrid4}>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}>公司名称</label>
|
||||
<Input className={styles.input} placeholder="公司名称" value={w.company} onChange={e => updateWork(i, 'company', e.target.value)} />
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}>职位</label>
|
||||
<Input className={styles.input} placeholder="职位名称" value={w.position} onChange={e => updateWork(i, 'position', e.target.value)} />
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}>开始时间</label>
|
||||
<Input className={styles.input} placeholder="如:2022.07" value={w.startDate} onChange={e => updateWork(i, 'startDate', e.target.value)} />
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}>结束时间</label>
|
||||
<Input className={styles.input} placeholder="如:至今" value={w.endDate} onChange={e => updateWork(i, 'endDate', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formItemFull}>
|
||||
<label className={styles.label}>工作描述</label>
|
||||
<TextArea className={styles.textarea} rows={3} placeholder="简要描述工作内容" value={w.description} onChange={e => updateWork(i, 'description', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.addBtnWrap}>
|
||||
<Button className={styles.addBtn} icon={<PlusOutlined />} onClick={addWork}>添加经历</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Education */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionIcon}><ReadOutlined /></div>
|
||||
<span className={styles.sectionTitle}>教育背景</span>
|
||||
<span className={styles.sectionHint}>选填,可添加多段教育经历</span>
|
||||
</div>
|
||||
{eduList.map((e, i) => (
|
||||
<div key={i} className={styles.expCard}>
|
||||
<div className={styles.expCardHeader}>
|
||||
<span className={styles.expBadge}>教育 {i + 1}</span>
|
||||
{eduList.length > 1 && (
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeEdu(i)}>删除</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.formGrid4}>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}>学校名称</label>
|
||||
<Input className={styles.input} placeholder="学校名称" value={e.school} onChange={ev => updateEdu(i, 'school', ev.target.value)} />
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}>专业</label>
|
||||
<Input className={styles.input} placeholder="专业名称" value={e.major} onChange={ev => updateEdu(i, 'major', ev.target.value)} />
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}>学历</label>
|
||||
<Select className={styles.selectFull} value={e.degree || undefined} onChange={v => updateEdu(i, 'degree', v)}
|
||||
popupClassName={styles.selectDropdown} placeholder="选择学历" options={degreeOptions} />
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}>开始时间</label>
|
||||
<Input className={styles.input} placeholder="如:2018.09" value={e.startDate} onChange={ev => updateEdu(i, 'startDate', ev.target.value)} />
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.label}>结束时间</label>
|
||||
<Input className={styles.input} placeholder="如:2022.06" value={e.endDate} onChange={ev => updateEdu(i, 'endDate', ev.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formItemFull}>
|
||||
<label className={styles.label}>主修课程/描述</label>
|
||||
<TextArea className={styles.textarea} rows={3} placeholder="主修课程或其他描述" value={e.description} onChange={ev => updateEdu(i, 'description', ev.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.addBtnWrap}>
|
||||
<Button className={styles.addBtn} icon={<PlusOutlined />} onClick={addEdu}>添加教育经历</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 4: Job Description */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionIcon}><FileSearchOutlined /></div>
|
||||
<span className={styles.sectionTitle}>岗位描述 / JD</span>
|
||||
<span className={styles.sectionHint}>必填,直接粘贴招聘要求即可</span>
|
||||
</div>
|
||||
<div className={styles.formItemFull}>
|
||||
<label className={styles.label}><span className={styles.required}>*</span> 应聘岗位要求</label>
|
||||
<TextArea className={styles.textarea} rows={6}
|
||||
placeholder="请粘贴目标岗位的招聘要求,AI 将根据 JD 为你量身定制简历内容..."
|
||||
value={jobDescription} onChange={e => setJobDescription(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className={styles.footerActions}>
|
||||
<Button className={styles.cancelBtn} onClick={handleCancel}>取消</Button>
|
||||
<Button type="primary" className={styles.generateBtn} loading={generating} onClick={handleGenerate}>
|
||||
{generating ? 'AI 生成中...' : <><RocketOutlined /> AI 生成简历</>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResumeCreate;
|
||||
130
src/pages/Resume/Preview/index.less
Normal file
130
src/pages/Resume/Preview/index.less
Normal file
@@ -0,0 +1,130 @@
|
||||
@themeColor: #00A196;
|
||||
@themeHover: #009185;
|
||||
|
||||
.previewPage {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.navbarWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
/* ===== Toolbar ===== */
|
||||
.toolbar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
padding: 0 24px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.toolbarInner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toolbarLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s;
|
||||
&:hover { color: @themeColor; }
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #e0e0e0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
font-size: 18px;
|
||||
color: @themeColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbarRight {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.downloadBtn {
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
padding: 0 24px;
|
||||
background: @themeColor !important;
|
||||
border-color: @themeColor !important;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: @themeHover !important;
|
||||
border-color: @themeHover !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Preview Area ===== */
|
||||
.previewArea {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.previewIframe {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
height: calc(100vh - 200px);
|
||||
min-height: 600px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.emptyTip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 15px;
|
||||
padding: 120px 0;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media screen and (max-width: 768px) {
|
||||
.previewArea { padding: 12px; }
|
||||
.previewIframe { height: calc(100vh - 180px); min-height: 400px; border-radius: 4px; }
|
||||
.toolbar { padding: 0 12px; }
|
||||
.fileName { max-width: 160px; }
|
||||
}
|
||||
68
src/pages/Resume/Preview/index.tsx
Normal file
68
src/pages/Resume/Preview/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
DownloadOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useSearchParams } from '@umijs/max';
|
||||
import Navbar from '../../Home/components/Navbar';
|
||||
import Footer from '../../Home/components/Footer';
|
||||
import styles from './index.less';
|
||||
|
||||
const ResumePreview: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const url = searchParams.get('url') || '';
|
||||
const name = searchParams.get('name') || '我的简历';
|
||||
|
||||
const previewSrc = url
|
||||
? `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(url)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={styles.previewPage}>
|
||||
<div className={styles.navbarWrap}><Navbar solidBg /></div>
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarInner}>
|
||||
<div className={styles.toolbarLeft}>
|
||||
<span className={styles.backLink} onClick={() => navigate(-1)}>
|
||||
<ArrowLeftOutlined /> 返回
|
||||
</span>
|
||||
<span className={styles.divider} />
|
||||
<FileTextOutlined className={styles.fileIcon} />
|
||||
<span className={styles.fileName}>{decodeURIComponent(name)}</span>
|
||||
</div>
|
||||
<div className={styles.toolbarRight}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
className={styles.downloadBtn}
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
disabled={!url}
|
||||
>
|
||||
下载简历
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.previewArea}>
|
||||
{previewSrc ? (
|
||||
<iframe
|
||||
className={styles.previewIframe}
|
||||
src={previewSrc}
|
||||
title="简历预览"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.emptyTip}>没有可预览的简历文件</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResumePreview;
|
||||
320
src/pages/Resume/index.less
Normal file
320
src/pages/Resume/index.less
Normal file
@@ -0,0 +1,320 @@
|
||||
.resumePage {
|
||||
min-height: 100vh;
|
||||
background: #F4F4F4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbarWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.contentArea {
|
||||
flex: 1;
|
||||
width: 1200px;
|
||||
max-width: calc(100% - 80px);
|
||||
margin: 0 auto;
|
||||
padding: 24px 0 48px;
|
||||
}
|
||||
|
||||
/* ===== Page Header ===== */
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.headerIcon {
|
||||
font-size: 36px;
|
||||
color: #00A196;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-family: HuXiaoBo-NanShen, HuXiaoBo-NanShen;
|
||||
font-size: 28px;
|
||||
color: #00A196;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pageSubtitle {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
/* ===== Card Grid ===== */
|
||||
.cardGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* ===== Template Card ===== */
|
||||
.templateCard {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
aspect-ratio: 3 / 4;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 161, 150, 0.18);
|
||||
|
||||
.cardHoverInfo {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cardNameBar {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.templateCardSelected {
|
||||
border-color: #00A196;
|
||||
box-shadow: 0 4px 16px rgba(0, 161, 150, 0.2);
|
||||
}
|
||||
|
||||
.coverImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.selectedBadge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: #00A196;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* 底部名称条 - 默认显示 */
|
||||
.cardNameBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.55));
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 悬浮详情面板 */
|
||||
.cardHoverInfo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tagList {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
background: rgba(0, 161, 150, 0.6);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.useCount {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hoverActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.overlayBtn {
|
||||
height: 34px;
|
||||
border-radius: 17px;
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border: none !important;
|
||||
color: #333 !important;
|
||||
font-size: 13px;
|
||||
padding: 0 16px;
|
||||
|
||||
&:hover {
|
||||
background: #fff !important;
|
||||
color: #00A196 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.overlayBtnPrimary {
|
||||
height: 34px;
|
||||
border-radius: 17px;
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
font-size: 13px;
|
||||
padding: 0 16px;
|
||||
|
||||
&:hover {
|
||||
background: #009185 !important;
|
||||
border-color: #009185 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Preview Modal ===== */
|
||||
.previewModal {
|
||||
:global {
|
||||
.ant-modal-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.previewBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.previewImg {
|
||||
width: 100%;
|
||||
max-height: 360px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.previewDesc {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.previewTags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.tag {
|
||||
color: #00A196;
|
||||
background: rgba(0, 161, 150, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.previewUseCount {
|
||||
font-size: 13px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.previewUseBtn {
|
||||
min-width: 140px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: #00A196 !important;
|
||||
border-color: #00A196 !important;
|
||||
font-size: 15px;
|
||||
|
||||
&:hover {
|
||||
background: #009185 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media screen and (max-width: 1200px) {
|
||||
.cardGrid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
.cardGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.contentArea {
|
||||
max-width: calc(100% - 24px);
|
||||
padding: 12px 0 32px;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.headerIcon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.cardGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
160
src/pages/Resume/index.tsx
Normal file
160
src/pages/Resume/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button, Modal, Spin, Empty, ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import {
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
RocketOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, request } from '@umijs/max';
|
||||
import Navbar from '../Home/components/Navbar';
|
||||
import Footer from '../Home/components/Footer';
|
||||
import styles from './index.less';
|
||||
|
||||
interface ResumeTemplate {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
cover: string;
|
||||
tags: string[];
|
||||
useCount: number;
|
||||
}
|
||||
|
||||
const Resume: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [templates, setTemplates] = useState<ResumeTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [previewTemplate, setPreviewTemplate] = useState<ResumeTemplate | null>(null);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request('/api/resume/student/templates');
|
||||
if (res.code === 0) {
|
||||
setTemplates(res.data || []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
const handleSelect = (tpl: ResumeTemplate) => {
|
||||
navigate(`/resume/create?templateId=${tpl.id}&templateTitle=${encodeURIComponent(tpl.title)}`);
|
||||
};
|
||||
|
||||
const handlePreview = (tpl: ResumeTemplate) => {
|
||||
setPreviewTemplate(tpl);
|
||||
setPreviewVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<div className={styles.resumePage}>
|
||||
<div className={styles.navbarWrap}>
|
||||
<Navbar solidBg />
|
||||
</div>
|
||||
|
||||
<div className={styles.contentArea}>
|
||||
<div className={styles.pageHeader}>
|
||||
<RocketOutlined className={styles.headerIcon} />
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>AI智能简历</h1>
|
||||
<p className={styles.pageSubtitle}>选择模板,AI帮你生成专业简历</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{!loading && templates.length === 0 ? (
|
||||
<Empty description="暂无模板" style={{ padding: '80px 0' }} />
|
||||
) : (
|
||||
<div className={styles.cardGrid}>
|
||||
{templates.map((tpl) => (
|
||||
<div key={tpl.id} className={styles.templateCard}>
|
||||
<img src={tpl.cover} alt={tpl.title} className={styles.coverImg} />
|
||||
<div className={styles.cardNameBar}>
|
||||
<span>{tpl.title}</span>
|
||||
</div>
|
||||
<div className={styles.cardHoverInfo}>
|
||||
<h3 className={styles.cardTitle}>{tpl.title}</h3>
|
||||
<p className={styles.cardDesc}>{tpl.description}</p>
|
||||
<div className={styles.tagList}>
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className={styles.tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<span className={styles.useCount}>{tpl.useCount}人使用</span>
|
||||
<div className={styles.hoverActions}>
|
||||
<Button
|
||||
className={styles.overlayBtn}
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handlePreview(tpl)}
|
||||
>
|
||||
预览
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
className={styles.overlayBtnPrimary}
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleSelect(tpl)}
|
||||
>
|
||||
选择模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
|
||||
<Modal
|
||||
title={previewTemplate?.title}
|
||||
open={previewVisible}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
className={styles.previewUseBtn}
|
||||
onClick={() => {
|
||||
if (previewTemplate) {
|
||||
handleSelect(previewTemplate);
|
||||
setPreviewVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
选择此模板
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
width={520}
|
||||
centered
|
||||
className={styles.previewModal}
|
||||
>
|
||||
{previewTemplate && (
|
||||
<div className={styles.previewBody}>
|
||||
<img src={previewTemplate.cover} alt={previewTemplate.title} className={styles.previewImg} />
|
||||
<p className={styles.previewDesc}>{previewTemplate.description}</p>
|
||||
<div className={styles.previewTags}>
|
||||
{previewTemplate.tags.map((tag) => (
|
||||
<span key={tag} className={styles.tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className={styles.previewUseCount}>{previewTemplate.useCount} 人已使用此模板</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Resume;
|
||||
277
src/pages/Statistics/Overview/index.less
Normal file
277
src/pages/Statistics/Overview/index.less
Normal file
@@ -0,0 +1,277 @@
|
||||
.overview-container {
|
||||
padding: 20px 16px;
|
||||
background: #ffffff;
|
||||
border-radius: 0 8px 8px 8px;
|
||||
min-height: calc(100vh - 60px);
|
||||
overflow: hidden;
|
||||
|
||||
.top-cards-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.usage-card-wrapper {
|
||||
width: 1108px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.today-card-wrapper {
|
||||
width: 540px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.usage-card,
|
||||
.today-card {
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e1e7ef;
|
||||
background: #f2fdf5;
|
||||
padding: 0;
|
||||
|
||||
.ant-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.icon-wrapper {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.usage-icon {
|
||||
background: #d2e0ff;
|
||||
}
|
||||
|
||||
&.today-icon {
|
||||
background: #d2e0ff;
|
||||
}
|
||||
|
||||
.icon-placeholder {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #1560f7;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: 'PingFang SC';
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #43504f;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 160px;
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
min-width: fit-content;
|
||||
|
||||
.stat-label {
|
||||
font-family: 'PingFang SC';
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
color: #43504f;
|
||||
line-height: 1.57;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.disabled .badge-number {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.badge-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 26px;
|
||||
|
||||
.number {
|
||||
font-family: 'DIN Alternate', 'Arial', sans-serif;
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
color: #00a196;
|
||||
line-height: 0.6;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-family: 'PingFang SC';
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: #43504f;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.today-card {
|
||||
.stats-row {
|
||||
gap: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e1e7ef;
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
box-shadow: 0 6px 3.5px rgba(0, 0, 0, 0.03);
|
||||
|
||||
.ant-card-body {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
gap: 20px;
|
||||
|
||||
.feature-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 154px;
|
||||
|
||||
.feature-title {
|
||||
font-family: 'PingFang SC';
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #00a196;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-family: 'PingFang SC';
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
color: #43504f;
|
||||
line-height: 1.57;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-image {
|
||||
.image-placeholder {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 4px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #e1e7ef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
|
||||
&::after {
|
||||
content: '图片占位';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.interview-card {
|
||||
background: #f0fdf7;
|
||||
}
|
||||
|
||||
&.resume-card {
|
||||
background: #faf5ff;
|
||||
}
|
||||
|
||||
&.employment-card {
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
&.additional-card-1 {
|
||||
background: #fff0f1;
|
||||
}
|
||||
|
||||
&.additional-card-2 {
|
||||
background: #e7fff8;
|
||||
}
|
||||
|
||||
&.additional-card-3 {
|
||||
background: #fff6eb;
|
||||
}
|
||||
|
||||
&.single-card {
|
||||
background: #f3f6ff;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 1200px) {
|
||||
.top-cards-container {
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
|
||||
.usage-card-wrapper,
|
||||
.today-card-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.usage-card .stats-row {
|
||||
gap: 80px;
|
||||
}
|
||||
|
||||
.today-card .stats-row {
|
||||
gap: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 16px 12px;
|
||||
|
||||
.top-cards-container {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.usage-card .stats-row,
|
||||
.today-card .stats-row {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
.feature-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
|
||||
.feature-image .image-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
239
src/pages/Statistics/Overview/index.tsx
Normal file
239
src/pages/Statistics/Overview/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, message } from 'antd';
|
||||
import { request } from '@umijs/max';
|
||||
import './index.less';
|
||||
|
||||
interface OverviewData {
|
||||
aiInterviewUsage: number;
|
||||
aiResumeUsage: number;
|
||||
currentTasks: number;
|
||||
completedPersons: number;
|
||||
todayInterviewUsage: number;
|
||||
todayResumeUsage: number;
|
||||
}
|
||||
|
||||
const Overview: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<OverviewData>({
|
||||
aiInterviewUsage: 900,
|
||||
aiResumeUsage: 900,
|
||||
currentTasks: 0,
|
||||
completedPersons: 150,
|
||||
todayInterviewUsage: 10,
|
||||
todayResumeUsage: 8,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request('/api/overview/data');
|
||||
if (response.code === 200) {
|
||||
setData(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('数据加载失败');
|
||||
console.error('Failed to fetch overview data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="overview-container">
|
||||
{/* 顶部统计卡片区域 - 按照设计稿UbXf0还原 */}
|
||||
<div className="top-cards-container">
|
||||
<div className="usage-card-wrapper">
|
||||
<Card className="usage-card" loading={loading}>
|
||||
<div className="card-header">
|
||||
<div className="icon-wrapper usage-icon">
|
||||
<div className="icon-placeholder" />
|
||||
</div>
|
||||
<span className="card-title">使用数据</span>
|
||||
</div>
|
||||
<div className="stats-row">
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">AI面试使用数</div>
|
||||
<div className="stat-badge">
|
||||
<div className="badge-number">
|
||||
<span className="number">{data.aiInterviewUsage}</span>
|
||||
<span className="unit">次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">AI面试使用数</div>
|
||||
<div className="stat-badge">
|
||||
<div className="badge-number">
|
||||
<span className="number">{data.aiResumeUsage}</span>
|
||||
<span className="unit">次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">当前任务数</div>
|
||||
<div className="stat-badge disabled">
|
||||
<div className="badge-number">
|
||||
<span className="number">{data.currentTasks}</span>
|
||||
<span className="unit">次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">完成任务人数</div>
|
||||
<div className="stat-badge">
|
||||
<div className="badge-number">
|
||||
<span className="number">{data.completedPersons}</span>
|
||||
<span className="unit">人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="today-card-wrapper">
|
||||
<Card className="today-card" loading={loading}>
|
||||
<div className="card-header">
|
||||
<div className="icon-wrapper today-icon">
|
||||
<div className="icon-placeholder" />
|
||||
</div>
|
||||
<span className="card-title">今日数据</span>
|
||||
</div>
|
||||
<div className="stats-row">
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">AI面试使用数</div>
|
||||
<div className="stat-badge">
|
||||
<div className="badge-number">
|
||||
<span className="number">{data.todayInterviewUsage}</span>
|
||||
<span className="unit">次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">AI简历使用数</div>
|
||||
<div className="stat-badge">
|
||||
<div className="badge-number">
|
||||
<span className="number">{data.todayResumeUsage}</span>
|
||||
<span className="unit">次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/* 功能卡片区域 */}
|
||||
<Row gutter={[40, 40]} style={{ marginTop: 30 }}>
|
||||
<Col span={8}>
|
||||
<Card className="feature-card interview-card" loading={loading}>
|
||||
<div className="feature-content">
|
||||
<div className="feature-text">
|
||||
<h3 className="feature-title">AI面试数据统计</h3>
|
||||
<p className="feature-desc">可查看学生详细面试数据</p>
|
||||
</div>
|
||||
<div className="feature-image">
|
||||
<div className="image-placeholder interview-image" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="feature-card resume-card" loading={loading}>
|
||||
<div className="feature-content">
|
||||
<div className="feature-text">
|
||||
<h3 className="feature-title">AI简历数据统计</h3>
|
||||
<p className="feature-desc">可查看学生详细AI简历数据</p>
|
||||
</div>
|
||||
<div className="feature-image">
|
||||
<div className="image-placeholder resume-image" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="feature-card employment-card" loading={loading}>
|
||||
<div className="feature-content">
|
||||
<div className="feature-text">
|
||||
<h3 className="feature-title">学生就业能力信息</h3>
|
||||
<p className="feature-desc">可查看具体学生平台使用情况</p>
|
||||
</div>
|
||||
<div className="feature-image">
|
||||
<div className="image-placeholder employment-image" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 更多功能卡片 */}
|
||||
<Row gutter={[40, 40]} style={{ marginTop: 40 }}>
|
||||
<Col span={8}>
|
||||
<Card className="feature-card additional-card-1" loading={loading}>
|
||||
<div className="feature-content">
|
||||
<div className="feature-text">
|
||||
<h3 className="feature-title">功能模块1</h3>
|
||||
<p className="feature-desc">功能描述信息</p>
|
||||
</div>
|
||||
<div className="feature-image">
|
||||
<div className="image-placeholder additional-image-1" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="feature-card additional-card-2" loading={loading}>
|
||||
<div className="feature-content">
|
||||
<div className="feature-text">
|
||||
<h3 className="feature-title">功能模块2</h3>
|
||||
<p className="feature-desc">功能描述信息</p>
|
||||
</div>
|
||||
<div className="feature-image">
|
||||
<div className="image-placeholder additional-image-2" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="feature-card additional-card-3" loading={loading}>
|
||||
<div className="feature-content">
|
||||
<div className="feature-text">
|
||||
<h3 className="feature-title">功能模块3</h3>
|
||||
<p className="feature-desc">功能描述信息</p>
|
||||
</div>
|
||||
<div className="feature-image">
|
||||
<div className="image-placeholder additional-image-3" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 单独的功能卡片 */}
|
||||
<Row gutter={[40, 40]} style={{ marginTop: 40 }}>
|
||||
<Col span={8}>
|
||||
<Card className="feature-card single-card" loading={loading}>
|
||||
<div className="feature-content">
|
||||
<div className="feature-text">
|
||||
<h3 className="feature-title">单独功能模块</h3>
|
||||
<p className="feature-desc">单独功能描述信息</p>
|
||||
</div>
|
||||
<div className="feature-image">
|
||||
<div className="image-placeholder single-image" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
{/* 空占位 */}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
{/* 空占位 */}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overview;
|
||||
97
src/pages/User/index.tsx
Normal file
97
src/pages/User/index.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Card, Button, Space, message } from 'antd';
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
interface UserType {
|
||||
id: number;
|
||||
name: string;
|
||||
age: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const User: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [users, setUsers] = useState<UserType[]>([]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request('/api/users');
|
||||
if (res.code === 200) {
|
||||
setUsers(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '年龄',
|
||||
dataIndex: 'age',
|
||||
key: 'age',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: UserType) => (
|
||||
<Space>
|
||||
<Button type="link" size="small">
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="用户管理"
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchUsers}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
新增用户
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default User;
|
||||
Reference in New Issue
Block a user