first commit

This commit is contained in:
2026-03-17 14:30:02 +08:00
commit 313f5b3550
136 changed files with 57671 additions and 0 deletions

3776
src/.umi/appData.json Normal file

File diff suppressed because one or more lines are too long

View 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} />;
}

View 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
View 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);
}

View 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
View 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 };

View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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';

View 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>
);
}

View 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
View 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;

View 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;
}

View File

@@ -0,0 +1,6 @@
// @ts-nocheck
// This file is generated by Umi automatically
// DO NOT CHANGE IT MANUALLY!
export const models = {
} as const

View 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>;
}

View 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';

View 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,
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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;
},
],
};

View 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;
}

View 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
View 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
View 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;
}
}

View 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
View 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
View 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
View 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;

View 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;
}
}

View 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;

View 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;

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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[];
}

View 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;

View 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;

View 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;

View 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; }

View 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;

View 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[];
}

View 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;

View 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;

View 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;

View 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;

View 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;
}
}
}
}
}

View 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;

View 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
View 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
View 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;

View 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;
}
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}
}

View 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;

View 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; }
}

View 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;

View 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;
}
}

View 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
View 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
View 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
View 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
View 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;

View 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;
}
}

View 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
View 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
View 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;

View 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; }
}

View 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;

View 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; }
}

View 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
View 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
View 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;

View 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;
}
}
}
}
}

View 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
View 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;