✨
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
46
README.md
Normal file
46
README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 简介
|
||||
|
||||
**停止购物**不同于传统由商户上架商品消费者进行下单的购物模式,提供相反的由消费者发布需求商户竞标的商品、服务交易的方式。
|
||||
|
||||
## 业务流程
|
||||
卖家:上架商品➡️寻找需求➡️推荐商品➡️⬇️➡️➡️发货➡️⬇️
|
||||
买家:发布需求➡️➡️➡️➡️➡️➡️➡️➡️➡️️️️️下单付款➡️⬆️➡️➡️收货
|
||||
> 用户注册后同时具有卖家、买家身份,默认为买家,可以在用户中心切换即可使用相应身份的功能
|
||||
|
||||
## 系统功能
|
||||
- **通用功能**
|
||||
+ [ ] 注册
|
||||
账号、密码、默认角色(买家、卖家)
|
||||
+ [ ] 登录
|
||||
+ [ ] 退出登录
|
||||
+ 个人中心
|
||||
* [ ] 昵称、头像等维护
|
||||
* [ ] 收货地址维护
|
||||
* [ ] 角色切换(买家版、卖家版)
|
||||
- **作为卖家时:**
|
||||
+ 上架商品
|
||||
* [ ] 商品基本信息维护
|
||||
* [ ] 商品详情维护
|
||||
+ 浏览需求
|
||||
* [ ] 分类检索
|
||||
* [ ] 关键词检索
|
||||
* [ ] 地区检索
|
||||
* [ ] 排序展示(发布时间、距离、竞标者数量)
|
||||
+ 竞标
|
||||
* [ ] 详情页展示(买家信息、需求描述、竞标者列表)
|
||||
* [ ] 竞标,选择商品
|
||||
+ 订单管理
|
||||
* [ ] 竞标中:查看详情
|
||||
* [ ] 已中标:查看详情、发货、在线沟通
|
||||
* [ ] 已发货:查看详情、物流
|
||||
* [ ] 已完成(买家已收货):查看详情、评价买家
|
||||
* [ ] 已关闭(未中标):查看详情、删除
|
||||
- **作为买家时:**
|
||||
+ 发布需求
|
||||
* [ ] 发布页:分类、简短描述、详细描述、预算
|
||||
+ 订单管理
|
||||
* [ ] 已发布:查看详情、修改详情
|
||||
* [ ] 有竞标:查看详情、查看竞标详情、接受
|
||||
* [ ] 待发货:查看详情、留言、在线沟通
|
||||
* [ ] 已发货:查看详情、物流、确认收货
|
||||
* [ ] 已完成:评价卖家
|
||||
1
env/.env.development
vendored
Normal file
1
env/.env.development
vendored
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE=http://localhost:5162
|
||||
1
env/.env.production
vendored
Normal file
1
env/.env.production
vendored
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE=https://api.stopshopping.bjbj.me
|
||||
37
eslint.config.js
Normal file
37
eslint.config.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
args: "all",
|
||||
argsIgnorePattern: "^_",
|
||||
caughtErrors: "all",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
destructuredArrayIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/stopshopping.svg" />
|
||||
<link rel="stylesheet" href="/iconfont/iconfont.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>🤚停止购物🛍️</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4033
package-lock.json
generated
Normal file
4033
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "stop-shopping-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"prebuild": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"qs": "^6.15.0",
|
||||
"quill": "^2.0.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/qs": "^6.14.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss-nesting": "^14.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
71
public/iconfont/iconfont.css
Normal file
71
public/iconfont/iconfont.css
Normal file
@@ -0,0 +1,71 @@
|
||||
@font-face {
|
||||
font-family: "ss"; /* Project id 5133620 */
|
||||
src: url('iconfont.woff2?t=1774167321603') format('woff2'),
|
||||
url('iconfont.woff?t=1774167321603') format('woff'),
|
||||
url('iconfont.ttf?t=1774167321603') format('truetype');
|
||||
}
|
||||
|
||||
.ss {
|
||||
font-family: "ss" !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.ssi-settings:before {
|
||||
content: "\e61d";
|
||||
}
|
||||
|
||||
.ssi-view-right:before {
|
||||
content: "\e66d";
|
||||
}
|
||||
|
||||
.ssi-edit:before {
|
||||
content: "\e60c";
|
||||
}
|
||||
|
||||
.ssi-create:before {
|
||||
content: "\e617";
|
||||
}
|
||||
|
||||
.ssi-triangledownfill:before {
|
||||
content: "\e79b";
|
||||
}
|
||||
|
||||
.ssi-triangleupfill:before {
|
||||
content: "\e79c";
|
||||
}
|
||||
|
||||
.ssi-expand:before {
|
||||
content: "\e631";
|
||||
}
|
||||
|
||||
.ssi-sousuo:before {
|
||||
content: "\e60d";
|
||||
}
|
||||
|
||||
.ssi-shangpin:before {
|
||||
content: "\e64b";
|
||||
}
|
||||
|
||||
.ssi-toudi:before {
|
||||
content: "\e605";
|
||||
}
|
||||
|
||||
.ssi-gerenzhongxin:before {
|
||||
content: "\e611";
|
||||
}
|
||||
|
||||
.ssi-xiaoxi:before {
|
||||
content: "\e736";
|
||||
}
|
||||
|
||||
.ssi-xuqiuqingdan:before {
|
||||
content: "\e600";
|
||||
}
|
||||
|
||||
.ssi-yitoudi:before {
|
||||
content: "\e667";
|
||||
}
|
||||
|
||||
BIN
public/iconfont/iconfont.ttf
Normal file
BIN
public/iconfont/iconfont.ttf
Normal file
Binary file not shown.
BIN
public/iconfont/iconfont.woff
Normal file
BIN
public/iconfont/iconfont.woff
Normal file
Binary file not shown.
BIN
public/iconfont/iconfont.woff2
Normal file
BIN
public/iconfont/iconfont.woff2
Normal file
Binary file not shown.
64
public/stopshopping.svg
Normal file
64
public/stopshopping.svg
Normal file
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="64mm"
|
||||
height="64mm"
|
||||
viewBox="0 0 64 64"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||
sodipodi:docname="stopshopping.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="0.40921756"
|
||||
inkscape:cx="333.56339"
|
||||
inkscape:cy="208.93531"
|
||||
inkscape:window-width="1440"
|
||||
inkscape:window-height="754"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="30"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" /><defs
|
||||
id="defs1" /><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"><g
|
||||
id="g1"
|
||||
transform="matrix(0.04383615,0,0,0.04237764,9.4063452,10.472973)"><path
|
||||
d="M 796.672,905.6256 H 234.1376 c -63.8976,0 -112.2304,-57.8048 -100.9664,-120.6784 l 75.3664,-419.9936 c 8.7552,-48.896 51.3024,-84.4288 100.9664,-84.4288 h 417.1776 c 50.1248,0 92.928,36.2496 101.1712,85.7088 l 69.9904,419.9936 c 10.4448,62.464 -37.7856,119.3984 -101.1712,119.3984 z"
|
||||
fill="#ff5f5f"
|
||||
p-id="3464"
|
||||
data-spm-anchor-id="a313x.search_index.0.i4.44a63a815gtofp"
|
||||
class="selected"
|
||||
id="path1" /><path
|
||||
d="M 912.9984,783.6672 843.008,363.6736 a 117.57568,117.57568 0 0 0 -116.3264,-98.56 h -30.5664 c -2.8672,-94.2592 -80.384,-170.0864 -175.36,-170.0864 -94.976,0 -172.3904,75.8272 -175.3088,170.0864 H 309.504 c -57.1392,0 -105.984,40.8576 -116.0704,97.0752 l -75.3664,419.9936 c -6.1952,34.4576 3.1744,69.632 25.6,96.512 22.4256,26.88 55.4496,42.24 90.4704,42.24 H 796.672 c 34.7136,0 67.5328,-15.2064 90.0096,-41.728 a 117.47328,117.47328 0 0 0 26.3168,-95.5392 z m -392.192,-657.92 c 78.0288,0 141.7728,62.0544 144.64,139.3664 h -289.28 c 2.8672,-77.312 66.6112,-139.3664 144.64,-139.3664 z M 863.232,859.392 a 86.9888,86.9888 0 0 1 -66.56,30.8224 H 234.1376 c -25.9072,0 -50.2784,-11.3664 -66.8672,-31.232 a 86.9888,86.9888 0 0 1 -18.944,-71.3728 L 223.6928,367.616 a 87.11168,87.11168 0 0 1 85.8624,-71.7824 h 35.84 v 91.904 c 0,8.4992 6.8608,15.36 15.36,15.36 8.4992,0 15.36,-6.8608 15.36,-15.36 v -91.904 H 665.6 v 91.904 c 0,8.4992 6.8608,15.36 15.36,15.36 8.4992,0 15.36,-6.8608 15.36,-15.36 v -91.904 h 30.4128 c 42.8032,0 79.0016,30.6688 86.016,72.8576 l 69.9904,419.9936 c 4.1984,25.344 -2.9184,51.1488 -19.5072,70.7072 z"
|
||||
fill="#424242"
|
||||
p-id="3465"
|
||||
data-spm-anchor-id="a313x.search_index.0.i6.44a63a815gtofp"
|
||||
class=""
|
||||
id="path2" /><path
|
||||
d="M 673.28,764.672 H 342.3232 c -8.4992,0 -15.36,-6.8608 -15.36,-15.36 0,-8.4992 6.8608,-15.36 15.36,-15.36 H 673.28 c 8.4992,0 15.36,6.8608 15.36,15.36 0,8.4992 -6.912,15.36 -15.36,15.36 z"
|
||||
fill="#ffffff"
|
||||
p-id="3466"
|
||||
data-spm-anchor-id="a313x.search_index.0.i5.44a63a815gtofp"
|
||||
class="selected"
|
||||
id="path3" /></g><path
|
||||
d="M 50.273894,47.688222 19.6381,11.331697 c 9.955892,-5.9721105 23.051885,-4.0160558 30.775796,5.152098 7.723985,9.164092 7.435966,22.404309 -0.139958,31.204427 M 13.586112,47.520283 C 5.862128,38.356126 6.1541459,25.111913 13.726114,16.311791 l 30.635735,36.356524 c -9.955888,5.97211 -23.051822,4.016056 -30.775807,-5.148032 M 11.378076,7.5316122 C -2.1338163,18.919842 -3.8578147,39.104195 7.5301189,52.620312 18.91811,66.136552 39.105971,67.856584 52.621873,56.468412 66.133827,45.084244 67.857813,24.895893 56.469891,11.379713 45.081896,-2.136525 24.89403,-3.8566214 11.378138,7.5316122"
|
||||
p-id="17445"
|
||||
data-spm-anchor-id="a313x.search_index.0.i15.44a63a815gtofp"
|
||||
class="selected"
|
||||
id="path1-8"
|
||||
style="fill:#f70012;stroke-width:0.0625053;fill-opacity:0.67073172" /></g></svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
161
src/api/AxiosHelper.ts
Normal file
161
src/api/AxiosHelper.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import qs from "qs";
|
||||
import Axios, { HttpStatusCode } from "axios";
|
||||
import type { AxiosInstance, AxiosResponse, CreateAxiosDefaults } from "axios";
|
||||
import type { ProblemDetails } from "./ProblemDetails";
|
||||
import type { AccessToken, AccessTokenResponse } from "./models/AccessToken";
|
||||
import type {
|
||||
AntiForgeryToken,
|
||||
AntiForgeryTokenResponse,
|
||||
} from "./models/AntiForgeryToken";
|
||||
|
||||
class AxiosHelper {
|
||||
private readonly REFRESH_TOKEN_URL: string = "/common/refreshtoken";
|
||||
private constructor(config?: CreateAxiosDefaults) {
|
||||
this.axios = Axios.create({
|
||||
withCredentials: true,
|
||||
baseURL: import.meta.env.VITE_API_BASE,
|
||||
paramsSerializer: (params) => {
|
||||
return qs.stringify(params, {
|
||||
indices: false,
|
||||
arrayFormat: "repeat",
|
||||
});
|
||||
},
|
||||
...config,
|
||||
});
|
||||
|
||||
this.axios.interceptors.request.use(async (config) => {
|
||||
if (this.accessToken?.token) {
|
||||
config.headers.Authorization = `Bearer ${this.accessToken?.token}`;
|
||||
}
|
||||
if (this.csrfToken)
|
||||
config.headers[this.csrfToken.headerName] = this.csrfToken.token;
|
||||
return config;
|
||||
});
|
||||
|
||||
this.axios.interceptors.response.use(null, async (err) => {
|
||||
if (err.response?.status === HttpStatusCode.Unauthorized) {
|
||||
if (err.config.url == this.REFRESH_TOKEN_URL) {
|
||||
window.location.href = "/signin";
|
||||
return;
|
||||
}
|
||||
const succed = await this.refreshAccessToken();
|
||||
if (succed) return await this.axios(err.config);
|
||||
else window.location.href = "/signin";
|
||||
} else if (err.response?.status === HttpStatusCode.BadRequest) {
|
||||
const details = err.response.data as ProblemDetails;
|
||||
|
||||
if (details.code == 1000) {
|
||||
const succed = await this.refreshCsrfToken();
|
||||
if (succed) return await this.axios(err.config);
|
||||
else throw Error("网络错误");
|
||||
} else if (details.code == 1001) {
|
||||
throw Error(details.detail || "");
|
||||
} else {
|
||||
throw Error(details.title || "");
|
||||
}
|
||||
} else {
|
||||
throw Error("网络错误");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private accessToken: AccessToken | null = null;
|
||||
private csrfToken: AntiForgeryToken | null = null;
|
||||
|
||||
private axios: AxiosInstance;
|
||||
|
||||
private static instance: AxiosHelper;
|
||||
|
||||
static getInstance(config?: CreateAxiosDefaults): AxiosHelper {
|
||||
if (!this.instance) {
|
||||
this.instance = new AxiosHelper(config);
|
||||
}
|
||||
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
private async refreshCsrfToken(): Promise<boolean> {
|
||||
const csrfResp =
|
||||
await AxiosHelper.getInstance().post<AntiForgeryTokenResponse>(
|
||||
"/common/antiforgery-token",
|
||||
);
|
||||
|
||||
if (csrfResp.isSucced) {
|
||||
this.csrfToken = csrfResp.data;
|
||||
} else {
|
||||
throw Error(csrfResp.message || "服务器错误,请刷新重试");
|
||||
}
|
||||
return csrfResp.isSucced;
|
||||
}
|
||||
|
||||
private async refreshAccessToken(): Promise<boolean> {
|
||||
const tokenResp = await AxiosHelper.getInstance().post<AccessTokenResponse>(
|
||||
this.REFRESH_TOKEN_URL,
|
||||
);
|
||||
|
||||
if (tokenResp.isSucced) {
|
||||
this.accessToken = tokenResp.data;
|
||||
this.autoRefreshToken();
|
||||
}
|
||||
|
||||
return tokenResp.isSucced;
|
||||
}
|
||||
|
||||
private autoRefreshToken() {
|
||||
if (this.accessToken?.expiresIn) {
|
||||
setTimeout(
|
||||
async () => {
|
||||
await this.refreshAccessToken();
|
||||
},
|
||||
(this.accessToken.expiresIn - 10) * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleResult<T>(result: AxiosResponse<T, unknown, unknown>): T {
|
||||
if (result.status === HttpStatusCode.Ok) {
|
||||
return result.data;
|
||||
} else {
|
||||
console.error(result);
|
||||
throw new Error(`${result.status}:${result.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功后调用
|
||||
* @param accessToken
|
||||
*/
|
||||
async initToken(accessToken: AccessToken) {
|
||||
this.accessToken = accessToken;
|
||||
if (this.accessToken?.expiresIn) {
|
||||
this.autoRefreshToken();
|
||||
}
|
||||
}
|
||||
|
||||
async initCsrfToken(csrfToken: AntiForgeryToken) {
|
||||
this.csrfToken = csrfToken;
|
||||
}
|
||||
|
||||
async get<TOut, TIn = unknown>(path: string, data?: TIn): Promise<TOut> {
|
||||
const result = await AxiosHelper.getInstance().axios.get(path, {
|
||||
params: data,
|
||||
});
|
||||
|
||||
return this.handleResult(result);
|
||||
}
|
||||
|
||||
async post<TOut, TIn = unknown>(path: string, data?: TIn): Promise<TOut> {
|
||||
const result = await AxiosHelper.getInstance().axios.post(path, data);
|
||||
return this.handleResult(result);
|
||||
}
|
||||
|
||||
async postFormData<TOut, TIn = unknown>(
|
||||
path: string,
|
||||
data?: TIn,
|
||||
): Promise<TOut> {
|
||||
const result = await AxiosHelper.getInstance().axios.postForm(path, data);
|
||||
return this.handleResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
export { AxiosHelper };
|
||||
7
src/api/ProblemDetails.ts
Normal file
7
src/api/ProblemDetails.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface ProblemDetails {
|
||||
code: number
|
||||
detail: string | null
|
||||
instance: string | null
|
||||
status: number | null
|
||||
title: string | null
|
||||
}
|
||||
150
src/api/index.ts
Normal file
150
src/api/index.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as M from "./models";
|
||||
import { AxiosHelper } from "./AxiosHelper";
|
||||
|
||||
//user
|
||||
export const signUp = async (
|
||||
params: M.SignUpParams,
|
||||
): Promise<M.ApiResponseMessage> => {
|
||||
return await AxiosHelper.getInstance().post("/user/signup", params);
|
||||
};
|
||||
|
||||
export const signIn = async (
|
||||
params: M.SignInParams,
|
||||
): Promise<M.SignInResponse> => {
|
||||
const signInResult = await AxiosHelper.getInstance().post<M.SignInResponse>(
|
||||
"/user/signin",
|
||||
params,
|
||||
);
|
||||
if (signInResult.isSucced && signInResult.data?.accessToken)
|
||||
await AxiosHelper.getInstance().initToken(signInResult.data.accessToken);
|
||||
return signInResult;
|
||||
};
|
||||
|
||||
export const changePassword = async (
|
||||
params: M.ChangePasswordParams,
|
||||
): Promise<M.ApiResponseMessage> => {
|
||||
return await AxiosHelper.getInstance().post("/user/changepassword", params);
|
||||
};
|
||||
|
||||
export const userInfo = async (): Promise<M.UserResponse> => {
|
||||
return await AxiosHelper.getInstance().get("/user/info");
|
||||
};
|
||||
|
||||
export const editUser = async (
|
||||
params: M.EditUserParams,
|
||||
): Promise<M.ApiResponseMessage> => {
|
||||
return await AxiosHelper.getInstance().post("/user/edit", params);
|
||||
};
|
||||
|
||||
//district
|
||||
export const districtTop3Levels = async (): Promise<M.DistrictsResponse> => {
|
||||
return await AxiosHelper.getInstance().get("/district/top3level");
|
||||
};
|
||||
|
||||
export const districtChildren = async (
|
||||
parentId: number,
|
||||
): Promise<M.DistrictsResponse> => {
|
||||
return await AxiosHelper.getInstance().get("/district/children", {
|
||||
parentId,
|
||||
});
|
||||
};
|
||||
|
||||
//common
|
||||
export const upload = async (
|
||||
params: M.UploadParams,
|
||||
): Promise<M.UploadResponse> => {
|
||||
return await AxiosHelper.getInstance().postFormData("/common/upload", params);
|
||||
};
|
||||
|
||||
export const csrfToken = async (): Promise<M.AntiForgeryTokenResponse> => {
|
||||
const csrfResult =
|
||||
await AxiosHelper.getInstance().post<M.AntiForgeryTokenResponse>(
|
||||
"/common/antiforgery-token",
|
||||
);
|
||||
if (csrfResult.isSucced && csrfResult.data)
|
||||
await AxiosHelper.getInstance().initCsrfToken(csrfResult.data);
|
||||
|
||||
return csrfResult;
|
||||
};
|
||||
|
||||
export const signOut = async (): Promise<M.ApiResponseMessage> => {
|
||||
return await AxiosHelper.getInstance().post("/common/signout");
|
||||
};
|
||||
|
||||
export const refreshToken = async (): Promise<M.AccessTokenResponse> => {
|
||||
return await AxiosHelper.getInstance().post("/common/refreshtoken");
|
||||
};
|
||||
|
||||
//category
|
||||
export const getCategoryTree = async (): Promise<M.CategoryListResponse> => {
|
||||
return await AxiosHelper.getInstance().get("/category/list");
|
||||
};
|
||||
|
||||
//product
|
||||
export const productSearch = async (
|
||||
params: M.ProductSearchParms,
|
||||
): Promise<M.ProductSearchResponse> => {
|
||||
return await AxiosHelper.getInstance().get("/product/list", params);
|
||||
};
|
||||
|
||||
export const productDetail = async (
|
||||
productId: number,
|
||||
): Promise<M.ProductInfoResponse> => {
|
||||
return await AxiosHelper.getInstance().get("/product/detail", { productId });
|
||||
};
|
||||
|
||||
export const productEdit = async (
|
||||
params: M.EditProductParams,
|
||||
): Promise<M.ApiResponseMessage> => {
|
||||
return await AxiosHelper.getInstance().post("/product/edit", params);
|
||||
};
|
||||
|
||||
export const productDelete = async (
|
||||
productId: number,
|
||||
): Promise<M.ApiResponseMessage> => {
|
||||
return await AxiosHelper.getInstance().post("/product/delete", { productId });
|
||||
};
|
||||
|
||||
//request
|
||||
export const requestPublish = async (
|
||||
params: M.CreateRequestParams,
|
||||
): Promise<M.ApiResponseMessage> => {
|
||||
return await AxiosHelper.getInstance().post("/request/publish", params);
|
||||
};
|
||||
|
||||
export const requestSearch = async (
|
||||
params: M.RequestSearchParams,
|
||||
): Promise<M.RequestSearchResponse> => {
|
||||
return await AxiosHelper.getInstance().get("/request/search", params);
|
||||
};
|
||||
|
||||
export const requestOrders = async (
|
||||
params: M.RequestOrdersParams,
|
||||
): Promise<M.RequestSearchResponse> => {
|
||||
return await AxiosHelper.getInstance().get("/request/orders", params);
|
||||
};
|
||||
|
||||
export const requestDelete = async (
|
||||
requestId: number,
|
||||
): Promise<M.ApiResponseMessage> => {
|
||||
return await AxiosHelper.getInstance().post("/request/delete", { requestId });
|
||||
};
|
||||
|
||||
//reply
|
||||
export const replyPost = async (
|
||||
params: M.ReplyParams,
|
||||
): Promise<M.ApiResponseMessage> => {
|
||||
return await AxiosHelper.getInstance().post("/reply/post", params);
|
||||
};
|
||||
|
||||
export const replyList = async (
|
||||
requestId: number,
|
||||
): Promise<M.ReplyResponse> => {
|
||||
return await AxiosHelper.getInstance().get("/reply/list", { requestId });
|
||||
};
|
||||
|
||||
export const replyOrders = async (
|
||||
params: M.RequestOrdersParams,
|
||||
): Promise<M.RequestSearchResponse> => {
|
||||
return await AxiosHelper.getInstance().get("/reply/orders", params);
|
||||
};
|
||||
8
src/api/models/AccessToken.ts
Normal file
8
src/api/models/AccessToken.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ApiResponseType } from './ApiResponseType';
|
||||
|
||||
export interface AccessToken {
|
||||
token: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
|
||||
export type AccessTokenResponse = ApiResponseType<AccessToken>;
|
||||
8
src/api/models/AntiForgeryToken.ts
Normal file
8
src/api/models/AntiForgeryToken.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ApiResponseType } from './ApiResponseType';
|
||||
|
||||
export interface AntiForgeryToken {
|
||||
headerName: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type AntiForgeryTokenResponse = ApiResponseType<AntiForgeryToken>;
|
||||
7
src/api/models/ApiResponseType.ts
Normal file
7
src/api/models/ApiResponseType.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface ApiResponseType<TData> {
|
||||
isSucced: boolean;
|
||||
message: string | null;
|
||||
data: TData | null;
|
||||
}
|
||||
|
||||
export type ApiResponseMessage = ApiResponseType<null>;
|
||||
21
src/api/models/Category.ts
Normal file
21
src/api/models/Category.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { type ApiResponseType } from './ApiResponseType'
|
||||
|
||||
export interface Category {
|
||||
id: number
|
||||
parentId: number
|
||||
name: string
|
||||
logoUrl: string
|
||||
order: number
|
||||
children: Array<Category>
|
||||
}
|
||||
|
||||
export type CategoryListResponse = ApiResponseType<Array<Category>>
|
||||
|
||||
export interface EditCategoryParams {
|
||||
id: number | null
|
||||
parentId: number
|
||||
name: string
|
||||
logo: string | null
|
||||
}
|
||||
|
||||
export type EditCategoryResponse = ApiResponseType<Category>
|
||||
4
src/api/models/ChangePassword.ts
Normal file
4
src/api/models/ChangePassword.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ChangePasswordParams {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
};
|
||||
11
src/api/models/District.ts
Normal file
11
src/api/models/District.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ApiResponseType } from './ApiResponseType';
|
||||
|
||||
interface District {
|
||||
id: number;
|
||||
parentId: number;
|
||||
level: number;
|
||||
fullName: string;
|
||||
children: Array<District>;
|
||||
};
|
||||
|
||||
export type DistrictsResponse = ApiResponseType<Array<District>>;
|
||||
58
src/api/models/Enums.ts
Normal file
58
src/api/models/Enums.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export const UploadScences = {
|
||||
Avatar: 0,
|
||||
Product: 1,
|
||||
Category: 2,
|
||||
} as const;
|
||||
export type UploadScencesType =
|
||||
(typeof UploadScences)[keyof typeof UploadScences];
|
||||
|
||||
export const UserRoles = {
|
||||
Seller: 0,
|
||||
Buyer: 1,
|
||||
} as const;
|
||||
export type UserRolesType = (typeof UserRoles)[keyof typeof UserRoles];
|
||||
|
||||
export const ProductOrderBys = {
|
||||
CreateTime: 0,
|
||||
CreateTimeDesc: 1,
|
||||
Category: 2,
|
||||
CategoryDesc: 3,
|
||||
SoldAmount: 4,
|
||||
SoldAmountDesc: 5,
|
||||
} as const;
|
||||
export type ProductOrderBysType =
|
||||
(typeof ProductOrderBys)[keyof typeof ProductOrderBys];
|
||||
|
||||
export const RequestOrderBys = {
|
||||
PublishTime: 0,
|
||||
PublishTimeDesc: 1,
|
||||
CategoryId: 2,
|
||||
CategoryIdDesc: 3,
|
||||
ReplyAmount: 4,
|
||||
ReplyAmountDesc: 5,
|
||||
} as const;
|
||||
export type RequestOrderBysType =
|
||||
(typeof RequestOrderBys)[keyof typeof RequestOrderBys];
|
||||
|
||||
export const RequestStatus = {
|
||||
All: -1,
|
||||
Publish: 0,
|
||||
Replied: 1,
|
||||
Accepted: 2,
|
||||
Sent: 3,
|
||||
Completed: 4,
|
||||
Commented: 5,
|
||||
} as const;
|
||||
export type RequestStatusType =
|
||||
(typeof RequestStatus)[keyof typeof RequestStatus];
|
||||
export const RequestStatusNames: { [K in keyof typeof RequestStatus]: K } = {
|
||||
All: "All",
|
||||
Publish: "Publish",
|
||||
Replied: "Replied",
|
||||
Accepted: "Accepted",
|
||||
Sent: "Sent",
|
||||
Completed: "Completed",
|
||||
Commented: "Commented",
|
||||
} as const;
|
||||
export type RequestStatusNamesType =
|
||||
(typeof RequestStatusNames)[keyof typeof RequestStatusNames];
|
||||
6
src/api/models/PagedResult.ts
Normal file
6
src/api/models/PagedResult.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface PagedResult<TData> {
|
||||
pageCount: number;
|
||||
pageSize: number;
|
||||
pageIndex: number;
|
||||
data: Array<TData>;
|
||||
}
|
||||
4
src/api/models/PagedSearchType.ts
Normal file
4
src/api/models/PagedSearchType.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface PagedSearchType {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
};
|
||||
40
src/api/models/Product.ts
Normal file
40
src/api/models/Product.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ApiResponseType } from "./ApiResponseType";
|
||||
import type { PagedResult } from "./PagedResult";
|
||||
import type { ProductOrderBysType } from "./Enums";
|
||||
import type { PagedSearchType } from "./PagedSearchType";
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
logoUrl: string | null;
|
||||
categoryName: string;
|
||||
minimumUnit: string;
|
||||
unitPrice: number;
|
||||
soldAmount: number;
|
||||
createTime: string;
|
||||
}
|
||||
export type ProductSearchResponse = ApiResponseType<PagedResult<Product>>;
|
||||
|
||||
interface ProductInfo extends Product {
|
||||
categoryId: number;
|
||||
detail: string | null;
|
||||
}
|
||||
export type ProductInfoResponse = ApiResponseType<ProductInfo>;
|
||||
|
||||
export interface ProductSearchParms extends PagedSearchType {
|
||||
categoryId: number | null;
|
||||
keyword: string | null;
|
||||
orderBys: Array<ProductOrderBysType>;
|
||||
}
|
||||
|
||||
export interface EditProductParams {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
logoName: string | null;
|
||||
categoryId: number;
|
||||
minimumUnit: string | null;
|
||||
unitPrice: number;
|
||||
detail: string | null;
|
||||
}
|
||||
23
src/api/models/Reply.ts
Normal file
23
src/api/models/Reply.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ApiResponseType } from "./ApiResponseType";
|
||||
|
||||
export interface ReplyParams {
|
||||
requestId: number;
|
||||
productId: number;
|
||||
amount: number;
|
||||
price: number;
|
||||
memo: string | null;
|
||||
}
|
||||
|
||||
export interface Reply {
|
||||
id: number;
|
||||
productId: number;
|
||||
productName: string;
|
||||
unitPrice: number;
|
||||
minimumUnit: string;
|
||||
amount: number;
|
||||
replyTime: string;
|
||||
replier: string;
|
||||
memo: string | null;
|
||||
}
|
||||
|
||||
export type ReplyResponse = ApiResponseType<Array<Reply>>;
|
||||
35
src/api/models/Request.ts
Normal file
35
src/api/models/Request.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ApiResponseType } from "./ApiResponseType";
|
||||
import type { PagedResult } from "./PagedResult";
|
||||
import type { PagedSearchType } from "./PagedSearchType";
|
||||
import type { RequestOrderBysType, RequestStatusType } from "./Enums";
|
||||
|
||||
export interface CreateRequestParams {
|
||||
name: string;
|
||||
description: string | null;
|
||||
categoryId: number;
|
||||
deadline: string;
|
||||
}
|
||||
|
||||
export interface RequestSearchParams extends PagedSearchType {
|
||||
categoryId: number | null;
|
||||
keyword: string | null;
|
||||
orderBys: Array<RequestOrderBysType>;
|
||||
}
|
||||
|
||||
export interface RequestOrdersParams extends RequestSearchParams {
|
||||
status: RequestStatusType;
|
||||
}
|
||||
|
||||
export interface Request {
|
||||
id: number;
|
||||
serialNo: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
publisher: string;
|
||||
publishTime: string;
|
||||
deadline: string;
|
||||
replyAmount: number;
|
||||
}
|
||||
export type RequestSearchResponse = ApiResponseType<PagedResult<Request>>;
|
||||
17
src/api/models/SignIn.ts
Normal file
17
src/api/models/SignIn.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ApiResponseType } from "./ApiResponseType";
|
||||
import type { UserRolesType } from "./Enums";
|
||||
import type { AccessToken } from "./AccessToken";
|
||||
|
||||
interface SignIn {
|
||||
defaultRole: UserRolesType;
|
||||
accessToken: AccessToken;
|
||||
avatarUrl?: string;
|
||||
nickName: string;
|
||||
}
|
||||
|
||||
export type SignInResponse = ApiResponseType<SignIn>;
|
||||
|
||||
export interface SignInParams {
|
||||
account: string;
|
||||
password: string;
|
||||
}
|
||||
8
src/api/models/SignUp.ts
Normal file
8
src/api/models/SignUp.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { UserRolesType } from './Enums';
|
||||
|
||||
export interface SignUpParams {
|
||||
account: string;
|
||||
nickName: string;
|
||||
defaultRole: UserRolesType;
|
||||
password: string;
|
||||
};
|
||||
14
src/api/models/Upload.ts
Normal file
14
src/api/models/Upload.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ApiResponseType } from './ApiResponseType';
|
||||
import type { UploadScencesType } from './Enums';
|
||||
|
||||
interface Upload {
|
||||
newName: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type UploadResponse = ApiResponseType<Upload>;
|
||||
|
||||
export interface UploadParams {
|
||||
scences: UploadScencesType,
|
||||
file: File
|
||||
};
|
||||
20
src/api/models/User.ts
Normal file
20
src/api/models/User.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ApiResponseType } from './ApiResponseType';
|
||||
import type { UserRolesType } from './Enums';
|
||||
|
||||
interface User {
|
||||
account: string;
|
||||
nickName: string;
|
||||
telephone: string | null;
|
||||
defaultRole: UserRolesType;
|
||||
avatarUrl: string | null;
|
||||
lastLoginTime: string | null;
|
||||
};
|
||||
|
||||
export type UserResponse = ApiResponseType<User>;
|
||||
|
||||
export interface EditUserParams {
|
||||
nickName: string;
|
||||
avatarFileName: string | null;
|
||||
defaultRole: UserRolesType;
|
||||
telephone: string | null;
|
||||
};
|
||||
16
src/api/models/index.ts
Normal file
16
src/api/models/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export * from "./AccessToken";
|
||||
export * from "./AntiForgeryToken";
|
||||
export * from "./ApiResponseType";
|
||||
export * from "./ChangePassword";
|
||||
export * from "./District";
|
||||
export * from "./Enums";
|
||||
export * from "./PagedResult";
|
||||
export * from "./PagedSearchType";
|
||||
export * from "./Product";
|
||||
export * from "./Reply";
|
||||
export * from "./Request";
|
||||
export * from "./SignIn";
|
||||
export * from "./SignUp";
|
||||
export * from "./Upload";
|
||||
export * from "./User";
|
||||
export * from "./Category";
|
||||
BIN
src/assets/signin-bg.jpeg
Normal file
BIN
src/assets/signin-bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
59
src/components/LoadMore/index.tsx
Normal file
59
src/components/LoadMore/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export interface Props {
|
||||
pageIndex: number;
|
||||
pageCount: number;
|
||||
loadMore: () => void;
|
||||
}
|
||||
|
||||
export function LoadMore(props: Props) {
|
||||
const loader = useRef<HTMLDivElement>(null);
|
||||
const { pageIndex, pageCount, loadMore } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (pageIndex >= pageCount) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) loadMore();
|
||||
});
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: "0px",
|
||||
threshold: 0.9,
|
||||
},
|
||||
);
|
||||
|
||||
const currentLoader = loader.current;
|
||||
|
||||
if (currentLoader) observer.observe(currentLoader);
|
||||
|
||||
return () => {
|
||||
if (currentLoader) observer.unobserve(currentLoader);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [pageIndex, pageCount, loadMore]);
|
||||
|
||||
function getFooter() {
|
||||
if (props.pageCount == 0) {
|
||||
return "没有更多数据了";
|
||||
} else {
|
||||
return `${props.pageIndex >= props.pageCount ? "没有更多数据了" : "继续滚动加载更多"},当前页:${props.pageIndex},总页数:${props.pageCount}`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={loader}
|
||||
style={{
|
||||
color: "#666",
|
||||
fontSize: "16px",
|
||||
width: "100%",
|
||||
padding: "20px 0",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{getFooter()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/Nav/index.module.css
Normal file
64
src/components/Nav/index.module.css
Normal file
@@ -0,0 +1,64 @@
|
||||
.menu {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
|
||||
> li {
|
||||
position: relative;
|
||||
> a {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-size: 1.8em;
|
||||
padding: 20px;
|
||||
|
||||
> i {
|
||||
flex: 1;
|
||||
font-size: 1em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
> span {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-style: italic;
|
||||
color: var(--hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.settings {
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
|
||||
li {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-size: 1.2em;
|
||||
padding: 10px 20px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:last-of-type a {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/components/Nav/index.tsx
Normal file
77
src/components/Nav/index.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useContext } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { AppContext } from "../../store/AppContexts";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export function Nav() {
|
||||
const location = useLocation();
|
||||
const appContext = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<ul className={styles.menu}>
|
||||
<li>
|
||||
<Link to="/" className={location.pathname == "/" ? styles.active : ""}>
|
||||
<i className="ss ssi-xuqiuqingdan"></i>
|
||||
<span>需求大厅</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/product"
|
||||
className={location.pathname == "/product" ? styles.active : ""}
|
||||
>
|
||||
<i className="ss ssi-shangpin"></i>
|
||||
<span>我的商品</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/request"
|
||||
className={location.pathname == "/request" ? styles.active : ""}
|
||||
>
|
||||
<i className="ss ssi-toudi"></i>
|
||||
<span>我的需求</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/reply"
|
||||
className={location.pathname == "/reply" ? styles.active : ""}
|
||||
>
|
||||
<i className="ss ssi-yitoudi"></i>
|
||||
<span>我的竞标</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/message"
|
||||
className={location.pathname == "/message" ? styles.active : ""}
|
||||
>
|
||||
<i className="ss ssi-xiaoxi"></i>
|
||||
<span>消息</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<ul className={styles.settings}>
|
||||
<li>
|
||||
<Link
|
||||
to="/settings"
|
||||
className={location.pathname == "/settings" ? styles.active : ""}
|
||||
>
|
||||
<i className="ss ssi-settings"></i> 设置
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/user"
|
||||
className={location.pathname == "/user" ? styles.active : ""}
|
||||
>
|
||||
<i className="ss ssi-gerenzhongxin"></i>
|
||||
{appContext.nickName || "登录"}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
20
src/components/TreeSelector/Props.ts
Normal file
20
src/components/TreeSelector/Props.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
interface TreeItem {
|
||||
id: number;
|
||||
parentId: number;
|
||||
name: string;
|
||||
logoUrl: string | null;
|
||||
}
|
||||
|
||||
export type TreeItemWithUnknown = Omit<TreeItem & unknown, "children">;
|
||||
|
||||
interface TreeActions {
|
||||
onChecked: (item: Omit<TreeItemWithChildren, "children">) => void;
|
||||
}
|
||||
|
||||
export type TreeItemWithChildren = TreeItemWithUnknown & {
|
||||
children: Array<TreeItemWithChildren>;
|
||||
};
|
||||
|
||||
export type TreeProps = {
|
||||
items: Array<TreeItemWithChildren>;
|
||||
} & TreeActions;
|
||||
89
src/components/TreeSelector/index.module.css
Normal file
89
src/components/TreeSelector/index.module.css
Normal file
@@ -0,0 +1,89 @@
|
||||
.toggler {
|
||||
border: none;
|
||||
color: white;
|
||||
background-color: rgba(21, 144, 251, 0.8);
|
||||
padding: 5px 10px;
|
||||
font-size: 1em;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background-color: rgba(19, 105, 210, 0.8);
|
||||
}
|
||||
i {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.show {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 5px var(--border-color);
|
||||
overflow-y: auto;
|
||||
visibility: visible;
|
||||
height: 100vh;
|
||||
transition: all 0.1s ease-in-out;
|
||||
|
||||
&.hidden {
|
||||
height: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.path {
|
||||
padding: 5px 10px;
|
||||
border-top: solid var(--border-color) 1px;
|
||||
border-bottom: solid var(--border-color) 1px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
background-color: white;
|
||||
|
||||
ol {
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
|
||||
a {
|
||||
padding: 5px 10px;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
li {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
a {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 2em;
|
||||
|
||||
&.pager {
|
||||
flex: 2;
|
||||
font-size: 4em;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
&.pager:hover {
|
||||
color: var(--hover-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
147
src/components/TreeSelector/index.tsx
Normal file
147
src/components/TreeSelector/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useRef, useLayoutEffect } from "react";
|
||||
import type {
|
||||
TreeProps,
|
||||
TreeItemWithChildren,
|
||||
TreeItemWithUnknown,
|
||||
} from "./Props";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export function TreeSelector(props: TreeProps) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const togglerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const defaultItem: TreeItemWithUnknown = {
|
||||
id: 0,
|
||||
logoUrl: "",
|
||||
name: "全部分类",
|
||||
parentId: 0,
|
||||
};
|
||||
const [treePath, setTreePath] = useState<Array<TreeItemWithUnknown>>([
|
||||
defaultItem,
|
||||
]);
|
||||
const [checkedItem, setCheckedItem] =
|
||||
useState<TreeItemWithUnknown>(defaultItem);
|
||||
const [show, toggleShow] = useState<boolean>(false);
|
||||
const withoutChildren = props.items.map(({ children: _, ...rest }) => rest);
|
||||
const [list, setList] = useState<Array<TreeItemWithUnknown>>(withoutChildren);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (contentRef.current && togglerRef.current && show) {
|
||||
const top =
|
||||
togglerRef.current.offsetTop + togglerRef.current.clientHeight;
|
||||
const left = contentRef.current.offsetLeft;
|
||||
contentRef.current.style.position = "absolute";
|
||||
contentRef.current.style.top = `${top}px`;
|
||||
contentRef.current.style.left = `$${left}px`;
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
function pathClick(item: TreeItemWithUnknown) {
|
||||
if (treePath.indexOf(item) != treePath.length - 1) {
|
||||
setTreePath((state) => state.slice(0, state.indexOf(item) + 1));
|
||||
setList(getChildren(item, props.items));
|
||||
} else if (item.id == 0) {
|
||||
listClick(item);
|
||||
}
|
||||
}
|
||||
|
||||
function listClick(item: TreeItemWithUnknown) {
|
||||
setCheckedItem(item);
|
||||
toggleShow(false);
|
||||
props.onChecked(item);
|
||||
}
|
||||
|
||||
function prevClick(item: TreeItemWithUnknown) {
|
||||
if (item.parentId == 0) return;
|
||||
setTreePath((state) => state.slice(0, state.length - 1));
|
||||
setList(getSiblings(item.parentId, props.items));
|
||||
}
|
||||
|
||||
function nextClick(item: TreeItemWithUnknown) {
|
||||
if (treePath.indexOf(item) != treePath.length - 1) {
|
||||
setTreePath((state) => [...state, item]);
|
||||
const children = getChildren(item, props.items);
|
||||
setList(children || []);
|
||||
}
|
||||
}
|
||||
|
||||
function getSiblings(
|
||||
id: number,
|
||||
items: Array<TreeItemWithChildren>,
|
||||
): Array<TreeItemWithUnknown> {
|
||||
for (const i of items) {
|
||||
if (i.id == id) return items;
|
||||
if (i.children) {
|
||||
const siblings = getSiblings(id, i.children);
|
||||
if (siblings.length) return siblings;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getChildren(
|
||||
item: TreeItemWithUnknown,
|
||||
items: Array<TreeItemWithChildren>,
|
||||
): Array<TreeItemWithUnknown> {
|
||||
if (item.id == 0) return items;
|
||||
for (const i of items) {
|
||||
if (i.id == item.id) {
|
||||
return i.children;
|
||||
}
|
||||
if (i.children) {
|
||||
const children = getChildren(item, i.children);
|
||||
if (children.length) return children;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={togglerRef}
|
||||
className={styles.toggler}
|
||||
onClick={() => toggleShow(!show)}
|
||||
>
|
||||
{checkedItem.name}
|
||||
<i className={`ss ssi-expand ${show ? styles.show : ""}`}></i>
|
||||
</button>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`${styles.content} ${show ? "" : styles.hidden}`}
|
||||
>
|
||||
<div className={styles.path}>
|
||||
<ol>
|
||||
{treePath.map((t) => {
|
||||
return (
|
||||
<li key={t.id}>
|
||||
<a onClick={() => pathClick(t)}>{t.name}</a>
|
||||
<span>{">"}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
<ul>
|
||||
{list.map((l) => {
|
||||
return (
|
||||
<li key={l.id}>
|
||||
<a className={styles.pager} onClick={() => prevClick(l)}>
|
||||
{"<"}
|
||||
</a>
|
||||
<a onClick={() => listClick(l)}>{l.name}</a>
|
||||
<a className={styles.pager} onClick={() => nextClick(l)}>
|
||||
{">"}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { type TreeItemWithUnknown } from "./Props";
|
||||
84
src/index.css
Normal file
84
src/index.css
Normal file
@@ -0,0 +1,84 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
--text-color: #224234;
|
||||
--button-text-color: #fff;
|
||||
--hover-color: #006ed5;
|
||||
--primary-color: #005bb0;
|
||||
--danger-color: #e50039;
|
||||
--success-color: #00c951;
|
||||
--success-hover-color: #04df72;
|
||||
--border-color: #ccc;
|
||||
--form-label-color: #aaa;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a,
|
||||
a:visited {
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--hover-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
color: var(--button-text-color);
|
||||
background-color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--hover-color);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
outline: none;
|
||||
padding: 5px 10px;
|
||||
color: var(--text-color);
|
||||
border: solid var(--border-color) 1px;
|
||||
box-shadow: inset 0 0 3px var(--border-color);
|
||||
}
|
||||
|
||||
input:hover,
|
||||
textarea:hover {
|
||||
box-shadow: 0 0 3px var(--border-color);
|
||||
}
|
||||
|
||||
input:disabled,
|
||||
textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ss {
|
||||
display: inline-block;
|
||||
}
|
||||
14
src/main.tsx
Normal file
14
src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "react-router";
|
||||
import { AppContextProvider } from "./store/AppContextProvider";
|
||||
import Router from "./routes";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.body).render(
|
||||
<StrictMode>
|
||||
<AppContextProvider>
|
||||
<RouterProvider router={Router} />
|
||||
</AppContextProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
44
src/pages/Error/index.tsx
Normal file
44
src/pages/Error/index.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
useRouteError,
|
||||
type ErrorResponse,
|
||||
isRouteErrorResponse,
|
||||
} from "react-router";
|
||||
|
||||
export function ErrorPage() {
|
||||
const error = useRouteError();
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
const statusError = error as ErrorResponse;
|
||||
if (statusError.status == 404) {
|
||||
return (
|
||||
<div>
|
||||
<h1>页面不存在</h1>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<h1>错误</h1>
|
||||
<h3>状态码:{statusError.status}</h3>
|
||||
<h3>消息:{statusError.statusText}</h3>
|
||||
<h3>数据{JSON.stringify(statusError.data)}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
const err = error as Error;
|
||||
return (
|
||||
<div>
|
||||
<h1>错误</h1>
|
||||
<h3>名称:{err.name}</h3>
|
||||
<h3>消息:{err.message}</h3>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<h1>未知错误</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
220
src/pages/Home/index.module.css
Normal file
220
src/pages/Home/index.module.css
Normal file
@@ -0,0 +1,220 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.filter {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(5px);
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: row;
|
||||
font-size: 16px;
|
||||
border-bottom: solid 1px var(--border-color);
|
||||
|
||||
.keyword {
|
||||
flex: 1;
|
||||
|
||||
input {
|
||||
line-height: 2em;
|
||||
border: none;
|
||||
border-right: solid 1px var(--border-color);
|
||||
padding-right: 2em;
|
||||
}
|
||||
|
||||
a {
|
||||
line-height: 2em;
|
||||
margin-left: -1.5em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sort {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
box-shadow: 0 3px 3px var(--border-color);
|
||||
|
||||
li {
|
||||
flex: 1;
|
||||
a {
|
||||
display: block;
|
||||
|
||||
i {
|
||||
transition: transform 0.1s ease;
|
||||
&.desc {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 50vh;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: white;
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.head {
|
||||
background-color: white;
|
||||
box-shadow: 0 3px 3px var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--form-label-color);
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 1.2em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
border-left: 1px solid var(--border-color);
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.splitter {
|
||||
height: 1px;
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
|
||||
i {
|
||||
font-size: 4em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aside {
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 20px;
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 5px 0;
|
||||
|
||||
label {
|
||||
font-size: 0.8em;
|
||||
color: var(--form-label-color);
|
||||
}
|
||||
|
||||
.product-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 5px var(--border-color);
|
||||
|
||||
li {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 5px 10px;
|
||||
|
||||
.name {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&:nth-last-of-type() {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
button {
|
||||
padding: 5px 10px;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 5px 5px var(--border-color);
|
||||
margin-bottom: 5px;
|
||||
|
||||
.header {
|
||||
background-color: white;
|
||||
border-bottom: solid 1px var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.memo {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
> p {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
425
src/pages/Home/index.tsx
Normal file
425
src/pages/Home/index.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
import { useEffect, useReducer, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useOutletContext } from "react-router";
|
||||
import {
|
||||
TreeSelector,
|
||||
type TreeItemWithUnknown,
|
||||
} from "../../components/TreeSelector";
|
||||
import { LoadMore } from "../../components/LoadMore";
|
||||
import { orderByTypes } from "../../store/Types";
|
||||
import {
|
||||
homeReducer,
|
||||
initialState,
|
||||
type ProductSearchType,
|
||||
} from "../../store/home";
|
||||
import * as actions from "../../store/home/action";
|
||||
import {
|
||||
getCategoryTree,
|
||||
replyList,
|
||||
requestSearch,
|
||||
replyPost,
|
||||
productSearch,
|
||||
} from "../../api";
|
||||
import {
|
||||
type Category,
|
||||
ProductOrderBys,
|
||||
type Request,
|
||||
RequestOrderBys,
|
||||
type RequestOrderBysType,
|
||||
} from "../../api/models";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export function Component() {
|
||||
const [states, dispatch] = useReducer(homeReducer, initialState);
|
||||
const asideContainer = useOutletContext<HTMLDivElement>();
|
||||
const keywordRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function getTree() {
|
||||
try {
|
||||
const resp = await getCategoryTree();
|
||||
if (resp.isSucced) {
|
||||
dispatch(actions.setCategories(resp.data || []));
|
||||
} else {
|
||||
throw Error(resp.message || "");
|
||||
}
|
||||
} finally {
|
||||
dispatch(actions.setLoading(false));
|
||||
}
|
||||
}
|
||||
|
||||
getTree();
|
||||
}, []);
|
||||
|
||||
const search = useCallback(async () => {
|
||||
try {
|
||||
dispatch(actions.setIsSearch(true));
|
||||
|
||||
const orderBys: RequestOrderBysType[] = [];
|
||||
if (
|
||||
!states.orderBys.categoryId ||
|
||||
states.orderBys.categoryId == orderByTypes.ASC
|
||||
)
|
||||
orderBys.push(RequestOrderBys.CategoryId);
|
||||
else orderBys.push(RequestOrderBys.CategoryIdDesc);
|
||||
if (
|
||||
!states.orderBys.publishTime ||
|
||||
states.orderBys.publishTime == orderByTypes.ASC
|
||||
)
|
||||
orderBys.push(RequestOrderBys.PublishTime);
|
||||
else orderBys.push(RequestOrderBys.PublishTimeDesc);
|
||||
if (
|
||||
!states.orderBys.replyAmount ||
|
||||
states.orderBys.replyAmount == orderByTypes.ASC
|
||||
)
|
||||
orderBys.push(RequestOrderBys.ReplyAmount);
|
||||
else orderBys.push(RequestOrderBys.ReplyAmountDesc);
|
||||
|
||||
const result = await requestSearch({
|
||||
categoryId: states.categoryId,
|
||||
keyword: states.keyword,
|
||||
orderBys: orderBys,
|
||||
pageIndex: states.pageIndex,
|
||||
pageSize: states.pageSize,
|
||||
});
|
||||
if (result.isSucced) {
|
||||
dispatch(
|
||||
actions.setSearchResult({
|
||||
pageCount: result.data?.pageCount || 0,
|
||||
requests: result.data?.data || [],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
alert(result.message || "服务器错误");
|
||||
}
|
||||
} finally {
|
||||
dispatch(actions.setIsSearch(false));
|
||||
}
|
||||
}, [
|
||||
states.categoryId,
|
||||
states.keyword,
|
||||
states.orderBys,
|
||||
states.pageIndex,
|
||||
states.pageSize,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
search();
|
||||
}, [search]);
|
||||
|
||||
const searchProducts = useCallback(async () => {
|
||||
const searchResult = await productSearch({
|
||||
categoryId: states.categoryId,
|
||||
keyword: states.reply.productName || "",
|
||||
orderBys: [ProductOrderBys.CreateTimeDesc],
|
||||
pageIndex: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
if (searchResult.isSucced) {
|
||||
dispatch(
|
||||
actions.setSearchProducts(
|
||||
searchResult.data?.data.map<ProductSearchType>((p) => ({
|
||||
productId: p.id,
|
||||
productName: p.name,
|
||||
productDescription: p.description,
|
||||
price: p.unitPrice,
|
||||
})) || [],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
throw Error(searchResult.message || "服务器错误");
|
||||
}
|
||||
}, [states.categoryId, states.reply.productName]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
dispatch(actions.setWillSearching(true));
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [states.reply.productName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!states.willSearching) return;
|
||||
|
||||
searchProducts();
|
||||
}, [states.willSearching, searchProducts]);
|
||||
|
||||
async function onLoadMore() {
|
||||
dispatch(actions.setPageIndex(states.pageIndex + 1));
|
||||
}
|
||||
|
||||
function categoryChecked(item: TreeItemWithUnknown) {
|
||||
const category = item as Category;
|
||||
dispatch(actions.setCategoryId(category.id));
|
||||
}
|
||||
|
||||
async function viewRepliersClick(requestId: number) {
|
||||
try {
|
||||
const replies = await replyList(requestId);
|
||||
if (replies.isSucced) {
|
||||
dispatch(actions.setReplies(replies.data));
|
||||
} else {
|
||||
throw Error(replies.message || "");
|
||||
}
|
||||
} catch (err) {
|
||||
throw Error(err as string);
|
||||
}
|
||||
}
|
||||
|
||||
function replyToClick(request: Request) {
|
||||
dispatch(
|
||||
actions.setReplyTo({
|
||||
categoryId: request.categoryId,
|
||||
requestId: request.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function replyTo() {
|
||||
try {
|
||||
const reply = states.reply;
|
||||
if (!reply.amount) {
|
||||
alert("请输入数量");
|
||||
return;
|
||||
}
|
||||
if (!reply.productId) {
|
||||
alert("请选择商品");
|
||||
return;
|
||||
}
|
||||
const replyResult = await replyPost({
|
||||
amount: reply.amount,
|
||||
memo: reply.memo || null,
|
||||
price: reply.price || 0,
|
||||
productId: reply.productId,
|
||||
requestId: reply.requestId || 0,
|
||||
});
|
||||
if (replyResult.isSucced) {
|
||||
alert("提交成功");
|
||||
dispatch(actions.setIsRepling(false));
|
||||
} else {
|
||||
alert(replyResult.message);
|
||||
}
|
||||
} catch (err) {
|
||||
throw Error(err as string);
|
||||
}
|
||||
}
|
||||
|
||||
function selectProduct(product: ProductSearchType) {
|
||||
dispatch(
|
||||
actions.setReplyTo({
|
||||
productId: product.productId,
|
||||
productName: product.productName,
|
||||
price: product.price,
|
||||
memo: states.reply.memo || product.productDescription || "",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function searchClick() {
|
||||
if (keywordRef.current) {
|
||||
dispatch(actions.setKeyword(keywordRef.current.value));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filter}>
|
||||
<div className={styles.search}>
|
||||
{!states.isLoading && (
|
||||
<TreeSelector
|
||||
items={states.categories}
|
||||
onChecked={categoryChecked}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.keyword}>
|
||||
<input
|
||||
ref={keywordRef}
|
||||
type="text"
|
||||
placeholder="请输入关键词"
|
||||
// value={states.keyword}
|
||||
// onChange={(e) => dispatch(actions.setKeyword(e.target.value))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") searchClick();
|
||||
}}
|
||||
/>
|
||||
<a onClick={searchClick}>
|
||||
<i className="ss ssi-sousuo"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ul className={styles.sort}>
|
||||
<li>
|
||||
<a onClick={() => dispatch(actions.toggleOrderByPublishTime())}>
|
||||
发布时间
|
||||
{states.orderBys.publishTime ? (
|
||||
<i
|
||||
className={`ss ssi-triangleupfill ${states.orderBys.publishTime == "asc" ? "" : styles.desc}`}
|
||||
></i>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a onClick={() => dispatch(actions.toggleOrderByCategoryId())}>
|
||||
分类
|
||||
{states.orderBys.categoryId ? (
|
||||
<i
|
||||
className={`ss ssi-triangleupfill ${states.orderBys.categoryId == "asc" ? "" : styles.desc}`}
|
||||
></i>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a onClick={() => dispatch(actions.toggleOrderByReplyAmount())}>
|
||||
竞标数量
|
||||
{states.orderBys.replyAmount ? (
|
||||
<i
|
||||
className={`ss ssi-triangleupfill ${states.orderBys.replyAmount == "asc" ? "" : styles.desc}`}
|
||||
></i>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<i></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{states.requests.map((req) => {
|
||||
return (
|
||||
<div key={req.id} className={styles.card}>
|
||||
<div className={styles.head}>
|
||||
<p>发布时间:{req.publishTime}</p>
|
||||
<p>序列号:{req.serialNo}</p>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.left}>
|
||||
<p className={styles.name}>{req.name}</p>
|
||||
<p>类别:{req.categoryName}</p>
|
||||
<p className={styles.desc}>{req.description}</p>
|
||||
<p>
|
||||
竞标者:<span>{req.replyAmount}</span>人
|
||||
</p>
|
||||
<p>截止日期:{req.deadline}</p>
|
||||
<p>发布人:{req.publisher}</p>
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<a onClick={() => viewRepliersClick(req.id)}>
|
||||
<i className="ss ssi-view-right"></i>
|
||||
</a>
|
||||
<span className={styles.splitter}></span>
|
||||
<a onClick={() => replyToClick(req)}>
|
||||
<i className="ss ssi-edit"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<LoadMore
|
||||
pageIndex={states.pageIndex}
|
||||
pageCount={states.pageCount}
|
||||
loadMore={onLoadMore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{asideContainer &&
|
||||
createPortal(
|
||||
<div className={styles.aside}>
|
||||
{states.isRepling ? (
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>商品</label>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="请输入商品名检索"
|
||||
value={states.reply.productName || ""}
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
actions.setReplyTo({ productName: e.target.value }),
|
||||
)
|
||||
}
|
||||
onFocus={() => dispatch(actions.setSearchFocused(true))}
|
||||
onBlur={() => dispatch(actions.setSearchFocused(false))}
|
||||
/>
|
||||
<ul
|
||||
className={`${styles.productSearch} ${states.searchFocused ? "" : styles.hidden}`}
|
||||
>
|
||||
{states.products.map((p) => {
|
||||
return (
|
||||
<li>
|
||||
<a onMouseDown={() => selectProduct(p)}>
|
||||
<p className={styles.name}>{p.productName}</p>
|
||||
<p>{p.productDescription}</p>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label>价格</label>
|
||||
<input type="text" value={states.reply.price || 0} disabled />
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label>数量</label>
|
||||
<input
|
||||
type="number"
|
||||
value={states.reply.amount || 0}
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
actions.setReplyTo({ amount: Number(e.target.value) }),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label>备注</label>
|
||||
<textarea
|
||||
value={states.reply.memo || ""}
|
||||
onChange={(e) =>
|
||||
dispatch(actions.setReplyTo({ memo: e.target.value }))
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.btnGroup}>
|
||||
<button onClick={replyTo}>提交</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className={styles.list}>
|
||||
{states.replies &&
|
||||
states.replies.map((r) => {
|
||||
return (
|
||||
<li key={r.id} className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<p>{r.replyTime}</p>
|
||||
<p>{r.replier}</p>
|
||||
</div>
|
||||
<p className={styles.name}>{r.productName}</p>
|
||||
<p>{`${r.unitPrice} X ${r.amount}${r.minimumUnit} = ${r.unitPrice * r.amount}元`}</p>
|
||||
<p className={styles.memo}>{r.memo}</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>,
|
||||
asideContainer,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
48
src/pages/Layout.module.css
Normal file
48
src/pages/Layout.module.css
Normal file
@@ -0,0 +1,48 @@
|
||||
.scroll-container {
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
|
||||
.scrollable {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
|
||||
nav {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.content {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 2;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-left: 1px solid #eee;
|
||||
border-right: 1px solid #eee;
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
background-color: #efefef;
|
||||
}
|
||||
}
|
||||
|
||||
aside {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.content {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
src/pages/Layout.tsx
Normal file
83
src/pages/Layout.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { AppContext, AppContextDispatch } from "../store/AppContexts";
|
||||
import { type AppContextType } from "../store/appcontext";
|
||||
import { Outlet } from "react-router";
|
||||
import { Nav } from "../components/Nav";
|
||||
import styles from "./Layout.module.css";
|
||||
import "../themes";
|
||||
|
||||
export function Component() {
|
||||
const asideRef = useRef<HTMLDivElement>(null);
|
||||
const [asideContainer] = useState(() => document.createElement("div"));
|
||||
const mounted = useRef(false); //首次加载不保存,dispatch改变appcontext时保存
|
||||
const appContext = useContext(AppContext);
|
||||
const appDispatch = useContext(AppContextDispatch);
|
||||
|
||||
const [mouseX, setMouseX] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const sContext = localStorage.getItem("app_context");
|
||||
if (sContext) {
|
||||
const state = JSON.parse(sContext) as AppContextType;
|
||||
appDispatch({
|
||||
type: "RESTORE_APPCONTEXT",
|
||||
payload: state,
|
||||
});
|
||||
}
|
||||
}, [appDispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted.current) {
|
||||
localStorage.setItem("app_context", JSON.stringify(appContext));
|
||||
} else {
|
||||
mounted.current = true;
|
||||
}
|
||||
}, [appContext]);
|
||||
|
||||
useEffect(() => {
|
||||
const aside = asideRef.current;
|
||||
aside?.appendChild(asideContainer);
|
||||
|
||||
return () => {
|
||||
aside?.removeChild(asideContainer);
|
||||
};
|
||||
}, [asideContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
setMouseX(e.clientX);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const scroll = (evt: React.UIEvent<HTMLElement>) => {
|
||||
if (mouseX > (asideRef.current?.offsetLeft ?? 0))
|
||||
asideRef.current?.style.setProperty(
|
||||
"top",
|
||||
`-${evt.currentTarget.scrollTop}px`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id="root"
|
||||
className={`${styles.scrollContainer} ${appContext?.theme}`}
|
||||
onScroll={scroll}
|
||||
>
|
||||
<div className={styles.scrollable}>
|
||||
<nav>
|
||||
<div className={styles.content}>
|
||||
<Nav />
|
||||
</div>
|
||||
</nav>
|
||||
<main>
|
||||
<div className={styles.content}>
|
||||
<Outlet context={asideContainer} />
|
||||
</div>
|
||||
</main>
|
||||
<aside>
|
||||
<div ref={asideRef} className={styles.content}></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/pages/Message/index.tsx
Normal file
7
src/pages/Message/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export function Component() {
|
||||
return (
|
||||
<div>
|
||||
<h1>开发中</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
src/pages/Product/index.module.css
Normal file
167
src/pages/Product/index.module.css
Normal file
@@ -0,0 +1,167 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.filter {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(5px);
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: row;
|
||||
font-size: 16px;
|
||||
border-bottom: solid 1px var(--border-color);
|
||||
|
||||
.keyword {
|
||||
flex: 1;
|
||||
|
||||
input {
|
||||
line-height: 2em;
|
||||
border: none;
|
||||
border-right: solid 1px var(--border-color);
|
||||
padding-right: 2em;
|
||||
}
|
||||
|
||||
i {
|
||||
line-height: 2em;
|
||||
margin-left: -1.5em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sort {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
box-shadow: 0 3px 3px var(--border-color);
|
||||
|
||||
li {
|
||||
flex: 1;
|
||||
a {
|
||||
display: block;
|
||||
|
||||
i {
|
||||
transition: transform 0.1s ease;
|
||||
&.desc {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 50vh;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
box-shadow: 0 0 3px var(--border-color);
|
||||
background-color: white;
|
||||
margin-bottom: 10px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.logo {
|
||||
flex: 1;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 2;
|
||||
font-size: 14px;
|
||||
color: var(--form-label-color);
|
||||
|
||||
p {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
|
||||
button {
|
||||
color: var(--button-text-color);
|
||||
background-color: var(--danger-color);
|
||||
padding: 5px 10px;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aside {
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 20px;
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 5px 0;
|
||||
|
||||
label {
|
||||
font-size: 0.8em;
|
||||
color: var(--form-label-color);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
button {
|
||||
padding: 5px 10px;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
389
src/pages/Product/index.tsx
Normal file
389
src/pages/Product/index.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import React, { useEffect, useReducer, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useOutletContext } from "react-router";
|
||||
import {
|
||||
TreeSelector,
|
||||
type TreeItemWithUnknown,
|
||||
} from "../../components/TreeSelector";
|
||||
import { LoadMore } from "../../components/LoadMore";
|
||||
import { orderByTypes } from "../../store/Types";
|
||||
import { productReducer, initialState } from "../../store/product";
|
||||
import * as actions from "../../store/product/action";
|
||||
import {
|
||||
getCategoryTree,
|
||||
productSearch,
|
||||
productDetail,
|
||||
productDelete,
|
||||
productEdit,
|
||||
upload,
|
||||
} from "../../api";
|
||||
import {
|
||||
type Category,
|
||||
ProductOrderBys,
|
||||
type ProductOrderBysType,
|
||||
UploadScences,
|
||||
} from "../../api/models";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export function Component() {
|
||||
const [states, dispatch] = useReducer(productReducer, initialState);
|
||||
const asideContainer = useOutletContext<HTMLDivElement>();
|
||||
const keywordRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function getTree() {
|
||||
try {
|
||||
const resp = await getCategoryTree();
|
||||
if (resp.isSucced) {
|
||||
dispatch(actions.setCategories(resp.data || []));
|
||||
} else {
|
||||
throw Error(resp.message || "");
|
||||
}
|
||||
} finally {
|
||||
dispatch(actions.setLoading(false));
|
||||
}
|
||||
}
|
||||
|
||||
getTree();
|
||||
}, []);
|
||||
|
||||
function categoryChecked(item: TreeItemWithUnknown) {
|
||||
const category = item as Category;
|
||||
dispatch(actions.setCategory(category));
|
||||
}
|
||||
|
||||
const search = useCallback(async () => {
|
||||
try {
|
||||
dispatch(actions.setIsSearch(true));
|
||||
|
||||
const orderBys: ProductOrderBysType[] = [];
|
||||
if (
|
||||
!states.orderBys.createTime ||
|
||||
states.orderBys.createTime == orderByTypes.ASC
|
||||
)
|
||||
orderBys.push(ProductOrderBys.CreateTimeDesc);
|
||||
if (
|
||||
!states.orderBys.categoryId ||
|
||||
states.orderBys.categoryId == orderByTypes.ASC
|
||||
)
|
||||
orderBys.push(ProductOrderBys.Category);
|
||||
else orderBys.push(ProductOrderBys.CategoryDesc);
|
||||
if (
|
||||
!states.orderBys.createTime ||
|
||||
states.orderBys.createTime == orderByTypes.ASC
|
||||
)
|
||||
orderBys.push(ProductOrderBys.CreateTime);
|
||||
else orderBys.push(ProductOrderBys.CreateTimeDesc);
|
||||
|
||||
const result = await productSearch({
|
||||
categoryId: states.categoryId,
|
||||
keyword: states.keyword,
|
||||
orderBys: orderBys,
|
||||
pageIndex: states.pageIndex,
|
||||
pageSize: states.pageSize,
|
||||
});
|
||||
if (result.isSucced) {
|
||||
dispatch(
|
||||
actions.setSearchResult({
|
||||
pageCount: result.data?.pageCount || 0,
|
||||
products: result.data?.data || [],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
alert(result.message || "服务器错误");
|
||||
}
|
||||
} finally {
|
||||
dispatch(actions.setIsSearch(false));
|
||||
}
|
||||
}, [
|
||||
states.categoryId,
|
||||
states.keyword,
|
||||
states.orderBys,
|
||||
states.pageIndex,
|
||||
states.pageSize,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
search();
|
||||
}, [search]);
|
||||
|
||||
async function onLoadMore() {
|
||||
dispatch(actions.setPageIndex(states.pageIndex + 1));
|
||||
}
|
||||
|
||||
async function viewDetailClick(id: number) {
|
||||
try {
|
||||
const detailResult = await productDetail(id);
|
||||
if (detailResult.isSucced) {
|
||||
dispatch(actions.setEditProduct({ ...detailResult.data }));
|
||||
} else {
|
||||
throw Error(detailResult.message || "服务器错误");
|
||||
}
|
||||
} catch (err) {
|
||||
throw Error(err as string);
|
||||
}
|
||||
}
|
||||
|
||||
async function pictureSelected(e: React.ChangeEvent) {
|
||||
const el = e.target as HTMLInputElement;
|
||||
if (el.files && el.files.length) {
|
||||
try {
|
||||
const uploadResult = await upload({
|
||||
file: el.files[0],
|
||||
scences: UploadScences.Product,
|
||||
});
|
||||
if (uploadResult.isSucced) {
|
||||
dispatch(
|
||||
actions.setEditProduct({
|
||||
logoName: uploadResult.data?.newName,
|
||||
logoUrl: uploadResult.data?.url,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
throw Error(uploadResult.message || "服务器错误");
|
||||
}
|
||||
} catch (err) {
|
||||
throw Error(err as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveDetail = useCallback(async () => {
|
||||
if (!states.editProduct.categoryId) {
|
||||
alert("请选择分类");
|
||||
return;
|
||||
}
|
||||
if (!states.editProduct.name) {
|
||||
alert("请输入名称");
|
||||
return;
|
||||
}
|
||||
if (!states.editProduct.minimumUnit) {
|
||||
alert("请输入单位");
|
||||
return;
|
||||
}
|
||||
if (!states.editProduct.unitPrice) {
|
||||
alert("请输入单价");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const editResult = await productEdit({
|
||||
categoryId: states.editProduct.categoryId || 0,
|
||||
description: states.editProduct.description || "",
|
||||
detail: states.editProduct.detail || "",
|
||||
id: states.editProduct.id || 0,
|
||||
logoName: states.editProduct.logoName || "",
|
||||
minimumUnit: states.editProduct.minimumUnit || "",
|
||||
name: states.editProduct.name || "",
|
||||
unitPrice: states.editProduct.unitPrice || 0,
|
||||
});
|
||||
if (editResult.isSucced) {
|
||||
alert("保存成功");
|
||||
} else {
|
||||
throw Error(editResult.message || "服务器错误");
|
||||
}
|
||||
} catch (err) {
|
||||
throw Error(err as string);
|
||||
}
|
||||
}, [states.editProduct]);
|
||||
|
||||
async function deleteProduct(productId: number) {
|
||||
try {
|
||||
const deleteResult = await productDelete(productId);
|
||||
if (deleteResult.isSucced) {
|
||||
dispatch(actions.removeProduct(productId));
|
||||
} else {
|
||||
throw Error(deleteResult.message || "服务器错误");
|
||||
}
|
||||
} catch (err) {
|
||||
throw Error(err as string);
|
||||
}
|
||||
}
|
||||
|
||||
function searchClick() {
|
||||
if (keywordRef.current) {
|
||||
dispatch(actions.setKeyword(keywordRef.current.value));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filter}>
|
||||
<div className={styles.search}>
|
||||
{!states.isLoading && (
|
||||
<TreeSelector
|
||||
items={states.categories}
|
||||
onChecked={categoryChecked}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.keyword}>
|
||||
<input
|
||||
ref={keywordRef}
|
||||
type="text"
|
||||
placeholder="请输入关键词"
|
||||
// value={states.keyword}
|
||||
// onChange={(e) => dispatch(actions.setKeyword(e.target.value))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") searchClick();
|
||||
}}
|
||||
/>
|
||||
<a onClick={searchClick}>
|
||||
<i className="ss ssi-sousuo"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ul className={styles.sort}>
|
||||
<li>
|
||||
<a onClick={() => dispatch(actions.toggleOrderByCreateTime())}>
|
||||
创建时间
|
||||
{states.orderBys.createTime ? (
|
||||
<i
|
||||
className={`ss ssi-triangleupfill ${states.orderBys.createTime == "asc" ? "" : styles.desc}`}
|
||||
></i>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a onClick={() => dispatch(actions.toggleOrderBySoldAmount())}>
|
||||
售出数量
|
||||
{states.orderBys.soldAmount ? (
|
||||
<i
|
||||
className={`ss ssi-triangleupfill ${states.orderBys.soldAmount == "asc" ? "" : styles.desc}`}
|
||||
></i>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<i></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{states.products.map((prod) => {
|
||||
return (
|
||||
<div
|
||||
key={prod.id}
|
||||
className={styles.card}
|
||||
onClick={() => viewDetailClick(prod.id)}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.logo}>
|
||||
<img src={prod.logoUrl || ""} />
|
||||
</div>
|
||||
<div className={styles.text}>
|
||||
<p className={styles.name}>{prod.name}</p>
|
||||
<p>{prod.categoryName}</p>
|
||||
<p>
|
||||
<span>{prod.unitPrice}</span>元/
|
||||
<span>{prod.minimumUnit}</span>
|
||||
</p>
|
||||
<p>已售:{prod.soldAmount}</p>
|
||||
<p>{prod.createTime}</p>
|
||||
<p className={styles.desc}>{prod.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<button onClick={() => deleteProduct(prod.id)}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<LoadMore
|
||||
pageIndex={states.pageIndex}
|
||||
pageCount={states.pageCount}
|
||||
loadMore={onLoadMore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{asideContainer &&
|
||||
createPortal(
|
||||
<div className={styles.aside}>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>分类</label>
|
||||
<input
|
||||
type="text"
|
||||
value={states.editProduct.categoryName || ""}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>名称</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入名称"
|
||||
value={states.editProduct.name || ""}
|
||||
onChange={(e) =>
|
||||
dispatch(actions.setEditProduct({ name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>描述</label>
|
||||
<textarea
|
||||
placeholder="请输入描述"
|
||||
value={states.editProduct.description || ""}
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
actions.setEditProduct({ description: e.target.value }),
|
||||
)
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>图片</label>
|
||||
<div>
|
||||
{states.editProduct.logoUrl && (
|
||||
<img src={states.editProduct.logoUrl || ""} />
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={pictureSelected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>最小销售单位</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入最小销售单位"
|
||||
value={states.editProduct.minimumUnit || ""}
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
actions.setEditProduct({ minimumUnit: e.target.value }),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>单价</label>
|
||||
<input
|
||||
type="number"
|
||||
value={states.editProduct.unitPrice || ""}
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
actions.setEditProduct({
|
||||
unitPrice: Number.parseFloat(e.target.value),
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<textarea rows={10}>TODO:</textarea>
|
||||
</div>
|
||||
<div className={styles.btnGroup}>
|
||||
<button onClick={saveDetail}>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
asideContainer,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
src/pages/Reply/index.tsx
Normal file
3
src/pages/Reply/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function Component() {
|
||||
return <div>开发中</div>;
|
||||
}
|
||||
218
src/pages/Request/index.module.css
Normal file
218
src/pages/Request/index.module.css
Normal file
@@ -0,0 +1,218 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.filter {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(5px);
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: row;
|
||||
font-size: 16px;
|
||||
border-bottom: solid 1px var(--border-color);
|
||||
|
||||
.keyword {
|
||||
flex: 1;
|
||||
|
||||
input {
|
||||
line-height: 2em;
|
||||
border: none;
|
||||
border-right: solid 1px var(--border-color);
|
||||
padding-right: 2em;
|
||||
}
|
||||
|
||||
i {
|
||||
line-height: 2em;
|
||||
margin-left: -1.5em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.create {
|
||||
margin-left: auto;
|
||||
|
||||
button {
|
||||
font-size: 1em;
|
||||
padding: 5px 10px;
|
||||
height: 100%;
|
||||
background-color: var(--success-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--success-hover-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-bottom: solid 1px var(--border-color);
|
||||
|
||||
li {
|
||||
flex: 1;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
text-align: center;
|
||||
&.active {
|
||||
color: var(--hover-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sort {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
box-shadow: 0 3px 3px var(--border-color);
|
||||
|
||||
li {
|
||||
flex: 1;
|
||||
a {
|
||||
display: block;
|
||||
|
||||
i {
|
||||
transition: transform 0.1s ease;
|
||||
&.desc {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 50vh;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: white;
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.head {
|
||||
background-color: white;
|
||||
box-shadow: 0 3px 3px var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--form-label-color);
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 1.2em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
a {
|
||||
display: block;
|
||||
|
||||
i {
|
||||
font-size: 5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aside {
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 20px;
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 5px 0;
|
||||
|
||||
label {
|
||||
font-size: 0.8em;
|
||||
color: var(--form-label-color);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
button {
|
||||
padding: 5px 10px;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 5px 5px var(--border-color);
|
||||
margin-bottom: 5px;
|
||||
|
||||
.header {
|
||||
background-color: white;
|
||||
border-bottom: solid 1px var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.memo {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
> p {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
439
src/pages/Request/index.tsx
Normal file
439
src/pages/Request/index.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { useEffect, useReducer, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link, useOutletContext, useLocation } from "react-router";
|
||||
import {
|
||||
TreeSelector,
|
||||
type TreeItemWithUnknown,
|
||||
} from "../../components/TreeSelector";
|
||||
import { LoadMore } from "../../components/LoadMore";
|
||||
import { orderByTypes } from "../../store/Types";
|
||||
import { requestReducer, initialState } from "../../store/request";
|
||||
import * as actions from "../../store/request/action";
|
||||
import {
|
||||
getCategoryTree,
|
||||
replyList,
|
||||
requestOrders,
|
||||
requestPublish,
|
||||
} from "../../api";
|
||||
import {
|
||||
type Category,
|
||||
RequestOrderBys,
|
||||
type RequestOrderBysType,
|
||||
RequestStatus,
|
||||
RequestStatusNames,
|
||||
type RequestStatusNamesType,
|
||||
} from "../../api/models";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export function Component() {
|
||||
const [states, dispatch] = useReducer(requestReducer, initialState);
|
||||
const asideContainer = useOutletContext<HTMLDivElement>();
|
||||
const keywordRef = useRef<HTMLInputElement>(null);
|
||||
const location = useLocation();
|
||||
const statusMatchs = location.search.match(/status=(\w+)/);
|
||||
let status: RequestStatusNamesType = RequestStatusNames.All;
|
||||
if (statusMatchs && statusMatchs.length > 1) {
|
||||
status = statusMatchs[1] as RequestStatusNamesType;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function getTree() {
|
||||
try {
|
||||
const resp = await getCategoryTree();
|
||||
if (resp.isSucced) {
|
||||
dispatch(actions.setCategories(resp.data || []));
|
||||
} else {
|
||||
throw Error(resp.message || "");
|
||||
}
|
||||
} finally {
|
||||
dispatch(actions.setLoading(false));
|
||||
}
|
||||
}
|
||||
|
||||
getTree();
|
||||
}, []);
|
||||
|
||||
const search = useCallback(async () => {
|
||||
try {
|
||||
dispatch(actions.setIsSearch(true));
|
||||
|
||||
const orderBys: RequestOrderBysType[] = [];
|
||||
if (
|
||||
!states.orderBys.categoryId ||
|
||||
states.orderBys.categoryId == orderByTypes.ASC
|
||||
)
|
||||
orderBys.push(RequestOrderBys.CategoryId);
|
||||
else orderBys.push(RequestOrderBys.CategoryIdDesc);
|
||||
if (
|
||||
!states.orderBys.publishTime ||
|
||||
states.orderBys.publishTime == orderByTypes.ASC
|
||||
)
|
||||
orderBys.push(RequestOrderBys.PublishTime);
|
||||
else orderBys.push(RequestOrderBys.PublishTimeDesc);
|
||||
if (
|
||||
!states.orderBys.replyAmount ||
|
||||
states.orderBys.replyAmount == orderByTypes.ASC
|
||||
)
|
||||
orderBys.push(RequestOrderBys.ReplyAmount);
|
||||
else orderBys.push(RequestOrderBys.ReplyAmountDesc);
|
||||
|
||||
const result = await requestOrders({
|
||||
categoryId: states.categoryId,
|
||||
keyword: states.keyword,
|
||||
orderBys: orderBys,
|
||||
pageIndex: states.pageIndex,
|
||||
pageSize: states.pageSize,
|
||||
status: RequestStatus[status],
|
||||
});
|
||||
if (result.isSucced) {
|
||||
dispatch(
|
||||
actions.setSearchResult({
|
||||
pageCount: result.data?.pageCount || 0,
|
||||
requests: result.data?.data || [],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
alert(result.message || "服务器错误");
|
||||
}
|
||||
} finally {
|
||||
dispatch(actions.setIsSearch(false));
|
||||
}
|
||||
}, [
|
||||
states.categoryId,
|
||||
states.keyword,
|
||||
states.orderBys,
|
||||
states.pageIndex,
|
||||
states.pageSize,
|
||||
status,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
search();
|
||||
}, [search]);
|
||||
|
||||
async function onLoadMore() {
|
||||
dispatch(actions.setPageIndex(states.pageIndex + 1));
|
||||
}
|
||||
|
||||
function categoryChecked(item: TreeItemWithUnknown) {
|
||||
const category = item as Category;
|
||||
dispatch(actions.setCategoryId(category.id));
|
||||
}
|
||||
|
||||
async function viewRepliersClick(requestId: number) {
|
||||
try {
|
||||
const replies = await replyList(requestId);
|
||||
if (replies.isSucced) {
|
||||
dispatch(actions.setReplies(replies.data));
|
||||
} else {
|
||||
throw Error(replies.message || "");
|
||||
}
|
||||
} catch (err) {
|
||||
throw Error(err as string);
|
||||
}
|
||||
}
|
||||
|
||||
function create() {
|
||||
if (states.categoryId <= 0) {
|
||||
alert("请选择分类");
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(actions.setIsPublish(true));
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (!states.requestPublish.deadline) {
|
||||
alert("请选择截止日期");
|
||||
return;
|
||||
}
|
||||
if (!states.requestPublish.name) {
|
||||
alert("请输入商品名");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await requestPublish({
|
||||
categoryId: states.categoryId,
|
||||
deadline: states.requestPublish.deadline,
|
||||
description: states.requestPublish.description || "",
|
||||
name: states.requestPublish.name,
|
||||
});
|
||||
if (result.isSucced) {
|
||||
alert("发布成功");
|
||||
} else {
|
||||
throw Error(result.message || "服务器错误");
|
||||
}
|
||||
} catch (err) {
|
||||
throw Error(err as string);
|
||||
}
|
||||
}
|
||||
|
||||
function searchClick() {
|
||||
if (keywordRef.current) {
|
||||
dispatch(actions.setKeyword(keywordRef.current.value));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filter}>
|
||||
<div className={styles.search}>
|
||||
{!states.isLoading && (
|
||||
<TreeSelector
|
||||
items={states.categories}
|
||||
onChecked={categoryChecked}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.keyword}>
|
||||
<input
|
||||
ref={keywordRef}
|
||||
type="text"
|
||||
placeholder="请输入关键词"
|
||||
// value={states.keyword}
|
||||
// onChange={(e) => dispatch(actions.setKeyword(e.target.value))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") searchClick();
|
||||
}}
|
||||
/>
|
||||
<a onClick={searchClick}>
|
||||
<i className="ss ssi-sousuo"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.create}>
|
||||
<button onClick={create}>
|
||||
发布
|
||||
<i className="ss ssi-create"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className={styles.status}>
|
||||
<li>
|
||||
<Link
|
||||
to={{
|
||||
pathname: "/request",
|
||||
search: `?status=${RequestStatusNames.All}`,
|
||||
}}
|
||||
className={`${status == RequestStatusNames.All ? styles.active : ""}`}
|
||||
>
|
||||
全部
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={{
|
||||
pathname: "/request",
|
||||
search: `?status=${RequestStatusNames.Publish}`,
|
||||
}}
|
||||
className={`${status == RequestStatusNames.Publish ? styles.active : ""}`}
|
||||
>
|
||||
已发布
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={{
|
||||
pathname: "/request",
|
||||
search: `?status=${RequestStatusNames.Replied}`,
|
||||
}}
|
||||
className={`${status == RequestStatusNames.Replied ? styles.active : ""}`}
|
||||
>
|
||||
有竞标
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={{
|
||||
pathname: "/request",
|
||||
search: `?status=${RequestStatusNames.Accepted}`,
|
||||
}}
|
||||
className={`${status == RequestStatusNames.Accepted ? styles.active : ""}`}
|
||||
>
|
||||
待发货
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={{
|
||||
pathname: "/request",
|
||||
search: `?status=${RequestStatusNames.Sent}`,
|
||||
}}
|
||||
className={`${status == RequestStatusNames.Sent ? styles.active : ""}`}
|
||||
>
|
||||
已发货
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={{
|
||||
pathname: "/request",
|
||||
search: `?status=${RequestStatusNames.Completed}`,
|
||||
}}
|
||||
className={`${status == RequestStatusNames.Completed ? styles.active : ""}`}
|
||||
>
|
||||
已完成
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={{
|
||||
pathname: "/request",
|
||||
search: `?status=${RequestStatusNames.Commented}`,
|
||||
}}
|
||||
className={`${status == RequestStatusNames.Commented ? styles.active : ""}`}
|
||||
>
|
||||
已评价
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<ul className={styles.sort}>
|
||||
<li>
|
||||
<a onClick={() => dispatch(actions.toggleOrderByPublishTime())}>
|
||||
发布时间
|
||||
{states.orderBys.publishTime ? (
|
||||
<i
|
||||
className={`ss ssi-triangleupfill ${states.orderBys.publishTime == "asc" ? "" : styles.desc}`}
|
||||
></i>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a onClick={() => dispatch(actions.toggleOrderByCategoryId())}>
|
||||
分类
|
||||
{states.orderBys.categoryId ? (
|
||||
<i
|
||||
className={`ss ssi-triangleupfill ${states.orderBys.categoryId == "asc" ? "" : styles.desc}`}
|
||||
></i>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a onClick={() => dispatch(actions.toggleOrderByReplyAmount())}>
|
||||
竞标数量
|
||||
{states.orderBys.replyAmount ? (
|
||||
<i
|
||||
className={`ss ssi-triangleupfill ${states.orderBys.replyAmount == "asc" ? "" : styles.desc}`}
|
||||
></i>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<i></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{states.requests.map((req) => {
|
||||
return (
|
||||
<div key={req.id} className={styles.card}>
|
||||
<div className={styles.head}>
|
||||
<p>发布时间:{req.publishTime}</p>
|
||||
<p>序列号:{req.serialNo}</p>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.left}>
|
||||
<p className={styles.name}>{req.name}</p>
|
||||
<p>类别:{req.categoryName}</p>
|
||||
<p className={styles.desc}>{req.description}</p>
|
||||
<p>
|
||||
竞标者:<span>{req.replyAmount}</span>人
|
||||
</p>
|
||||
<p>截止日期:{req.deadline}</p>
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<a onClick={() => viewRepliersClick(req.id)}>
|
||||
<i className="ss ssi-view-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<LoadMore
|
||||
pageIndex={states.pageIndex}
|
||||
pageCount={states.pageCount}
|
||||
loadMore={onLoadMore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{asideContainer &&
|
||||
createPortal(
|
||||
<div className={styles.aside}>
|
||||
{states.isPublish ? (
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>商品名</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入商品名"
|
||||
value={states.requestPublish.name || ""}
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
actions.setRequestPublish({ name: e.target.value }),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>截止日期</label>
|
||||
<input
|
||||
type="date"
|
||||
placeholder="请选择截止日期"
|
||||
value={states.requestPublish.deadline || ""}
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
actions.setRequestPublish({ deadline: e.target.value }),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>描述</label>
|
||||
<textarea
|
||||
placeholder="请输入描述"
|
||||
value={states.requestPublish.description || ""}
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
actions.setRequestPublish({
|
||||
description: e.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.btnGroup}>
|
||||
<button onClick={publish}>提交</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className={styles.list}>
|
||||
{states.replies &&
|
||||
states.replies.map((r) => {
|
||||
return (
|
||||
<li key={r.id} className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<p>{r.replyTime}</p>
|
||||
<p>{r.replier}</p>
|
||||
</div>
|
||||
<p className={styles.name}>
|
||||
<strong>{r.productName}</strong>
|
||||
</p>
|
||||
<p>{`${r.unitPrice} X ${r.amount}${r.minimumUnit} = ${r.unitPrice * r.amount}元`}</p>
|
||||
<p className={styles.memo}>{r.memo}</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>,
|
||||
asideContainer,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
src/pages/Settings/index.tsx
Normal file
7
src/pages/Settings/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export function Component() {
|
||||
return (
|
||||
<div>
|
||||
<h1>开发中</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/pages/SignIn/Layout.tsx
Normal file
10
src/pages/SignIn/Layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { type PropsWithChildren } from "react";
|
||||
import styles from "./SignIn.module.css";
|
||||
|
||||
export function Layout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/pages/SignIn/SignIn.module.css
Normal file
50
src/pages/SignIn/SignIn.module.css
Normal file
@@ -0,0 +1,50 @@
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: no-repeat center url(../../assets/signin-bg.jpeg);
|
||||
|
||||
.card {
|
||||
background-color: rgba(32, 173, 255, 0.3);
|
||||
box-shadow: 0 0 5px rgba(32, 173, 255, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 5px;
|
||||
padding: 20px 40px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2em;
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
color: var(--text-color);
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
label {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/pages/SignIn/SignIn.tsx
Normal file
98
src/pages/SignIn/SignIn.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useReducer } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Layout } from "./Layout";
|
||||
import {
|
||||
signInReducer,
|
||||
setIsSubmiting,
|
||||
setSignIn,
|
||||
initialState,
|
||||
} from "../../store/signin";
|
||||
import type { AppContextType } from "../../store/appcontext/state";
|
||||
import { signIn, csrfToken } from "../../api";
|
||||
import styles from "./SignIn.module.css";
|
||||
|
||||
export function Component() {
|
||||
const [states, dispatch] = useReducer(signInReducer, initialState);
|
||||
|
||||
function accountInput(e: React.ChangeEvent) {
|
||||
const account = (e.target as HTMLInputElement).value;
|
||||
dispatch(setSignIn({ account }));
|
||||
}
|
||||
|
||||
function passwordInput(e: React.ChangeEvent) {
|
||||
const password = (e.target as HTMLInputElement).value;
|
||||
dispatch(setSignIn({ password }));
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!states.signInUser?.account) {
|
||||
return;
|
||||
}
|
||||
if (!states.signInUser?.password) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setIsSubmiting(true));
|
||||
try {
|
||||
const signInResult = await signIn({
|
||||
account: states.signInUser?.account || "",
|
||||
password: states.signInUser?.password || "",
|
||||
});
|
||||
if (!signInResult.isSucced) {
|
||||
alert(signInResult.message || "服务器错误");
|
||||
} else {
|
||||
const csrfResult = await csrfToken();
|
||||
if (!csrfResult.isSucced) {
|
||||
alert(csrfResult.message || "服务器错误");
|
||||
} else {
|
||||
const appContext: AppContextType = {
|
||||
avatarUrl: signInResult.data?.avatarUrl,
|
||||
nickName: signInResult.data?.nickName || "",
|
||||
theme: "light",
|
||||
};
|
||||
localStorage.setItem("app_context", JSON.stringify(appContext));
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw Error(err as string);
|
||||
} finally {
|
||||
dispatch(setIsSubmiting(false));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<p className={styles.title}>登 录</p>
|
||||
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>账号</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入账号"
|
||||
value={states.signInUser.account}
|
||||
onChange={accountInput}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={states.signInUser.password}
|
||||
onChange={passwordInput}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.btnGroup}>
|
||||
<button onClick={submit}>登录</button>
|
||||
<Link to="/signup">注册</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
154
src/pages/SignIn/SignUp.tsx
Normal file
154
src/pages/SignIn/SignUp.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useReducer } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Layout } from "./Layout";
|
||||
import {
|
||||
signInReducer,
|
||||
setIsSubmiting,
|
||||
setSignUp,
|
||||
initialState,
|
||||
} from "../../store/signin";
|
||||
import { signUp } from "../../api";
|
||||
import { UserRoles, type UserRolesType } from "../../api/models/Enums";
|
||||
import styles from "./SignIn.module.css";
|
||||
|
||||
export function Component() {
|
||||
const [states, dispatch] = useReducer(signInReducer, initialState);
|
||||
const navigate = useNavigate();
|
||||
|
||||
function accountChanged(e: React.ChangeEvent) {
|
||||
const account = (e.target as HTMLInputElement).value;
|
||||
dispatch(setSignUp({ account }));
|
||||
}
|
||||
|
||||
function passwordChanged(e: React.ChangeEvent) {
|
||||
const password = (e.target as HTMLInputElement).value;
|
||||
dispatch(setSignUp({ password }));
|
||||
}
|
||||
|
||||
function confirmPasswordChanged(e: React.ChangeEvent) {
|
||||
const confirmPassword = (e.target as HTMLInputElement).value;
|
||||
dispatch(setSignUp({ confirmPassword }));
|
||||
}
|
||||
|
||||
function nickNameChanged(e: React.ChangeEvent) {
|
||||
const nickName = (e.target as HTMLInputElement).value;
|
||||
dispatch(setSignUp({ nickName }));
|
||||
}
|
||||
|
||||
function roleChanged(e: React.ChangeEvent) {
|
||||
const defaultRole = Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) as UserRolesType;
|
||||
dispatch(setSignUp({ defaultRole }));
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!states.signUpUser?.account) {
|
||||
return;
|
||||
}
|
||||
if (!states.signUpUser?.password) {
|
||||
return;
|
||||
}
|
||||
if (states.signUpUser?.confirmPassword != states.signUpUser?.password) {
|
||||
alert("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setIsSubmiting(true));
|
||||
try {
|
||||
const signUpResult = await signUp({
|
||||
account: states.signUpUser.account!,
|
||||
defaultRole: states.signUpUser.defaultRole!,
|
||||
nickName: states.signUpUser.nickName!,
|
||||
password: states.signUpUser.password!,
|
||||
});
|
||||
if (signUpResult.isSucced) {
|
||||
alert("注册成功");
|
||||
navigate("/signin");
|
||||
} else {
|
||||
alert(signUpResult.message || "服务器错误");
|
||||
}
|
||||
} catch (err) {
|
||||
throw Error(err as string);
|
||||
} finally {
|
||||
dispatch(setIsSubmiting(false));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<p className={styles.title}>注 册</p>
|
||||
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>账号</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入账号"
|
||||
required
|
||||
value={states.signUpUser?.account}
|
||||
onChange={accountChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
value={states.signUpUser?.password}
|
||||
onChange={passwordChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label>确认密码</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
required
|
||||
value={states.signUpUser?.confirmPassword}
|
||||
onChange={confirmPasswordChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label>昵称</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入昵称"
|
||||
required
|
||||
value={states.signUpUser?.nickName}
|
||||
onChange={nickNameChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label>默认角色</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="defaultRole"
|
||||
value={UserRoles.Buyer}
|
||||
checked={states.signUpUser?.defaultRole == UserRoles.Buyer}
|
||||
onChange={roleChanged}
|
||||
/>
|
||||
买家
|
||||
<input
|
||||
type="checkbox"
|
||||
name="defaultRole"
|
||||
checked={states.signUpUser?.defaultRole == UserRoles.Seller}
|
||||
value={UserRoles.Seller}
|
||||
onChange={roleChanged}
|
||||
/>
|
||||
卖家
|
||||
</div>
|
||||
|
||||
<div className={styles.btnGroup}>
|
||||
<button onClick={submit}>注册</button>
|
||||
<Link to="/signin">登录</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
7
src/pages/User/index.tsx
Normal file
7
src/pages/User/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export function Component() {
|
||||
return (
|
||||
<div>
|
||||
<h1>开发中</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/routes.ts
Normal file
48
src/routes.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createBrowserRouter } from "react-router";
|
||||
import { ErrorPage } from "./pages/Error";
|
||||
|
||||
export default createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
lazy: () => import("./pages/Layout"),
|
||||
ErrorBoundary: ErrorPage,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("./pages/Home"),
|
||||
},
|
||||
{
|
||||
path: "product",
|
||||
lazy: () => import("./pages/Product"),
|
||||
},
|
||||
{
|
||||
path: "request",
|
||||
lazy: () => import("./pages/Request"),
|
||||
},
|
||||
{
|
||||
path: "reply",
|
||||
lazy: () => import("./pages/Reply"),
|
||||
},
|
||||
{
|
||||
path: "message",
|
||||
lazy: () => import("./pages/Message"),
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
lazy: () => import("./pages/Settings"),
|
||||
},
|
||||
{
|
||||
path: "user",
|
||||
lazy: () => import("./pages/User"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/signin",
|
||||
lazy: () => import("./pages/SignIn/SignIn"),
|
||||
},
|
||||
{
|
||||
path: "/signup",
|
||||
lazy: () => import("./pages/SignIn/SignUp"),
|
||||
},
|
||||
]);
|
||||
4
src/store/Action.ts
Normal file
4
src/store/Action.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Action<A, T> {
|
||||
type: A;
|
||||
payload?: Partial<T>;
|
||||
}
|
||||
13
src/store/AppContextProvider.tsx
Normal file
13
src/store/AppContextProvider.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useReducer, type PropsWithChildren } from "react";
|
||||
import { appContextReducer, initialState } from "./appcontext";
|
||||
import { AppContext, AppContextDispatch } from "./AppContexts";
|
||||
|
||||
export function AppContextProvider({ children }: PropsWithChildren) {
|
||||
const [state, dispatch] = useReducer(appContextReducer, initialState);
|
||||
|
||||
return (
|
||||
<AppContext value={state}>
|
||||
<AppContextDispatch value={dispatch}>{children}</AppContextDispatch>
|
||||
</AppContext>
|
||||
);
|
||||
}
|
||||
11
src/store/AppContexts.ts
Normal file
11
src/store/AppContexts.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createContext, type ActionDispatch } from "react";
|
||||
import {
|
||||
type AppContextType,
|
||||
type AppContextAction,
|
||||
initialState,
|
||||
} from "./appcontext";
|
||||
|
||||
export const AppContext = createContext<AppContextType>(initialState);
|
||||
export const AppContextDispatch = createContext<
|
||||
ActionDispatch<[AppContextAction]>
|
||||
>(() => {});
|
||||
7
src/store/Types.ts
Normal file
7
src/store/Types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const orderByTypes = {
|
||||
ASC: "asc",
|
||||
DESC: "desc",
|
||||
} as const;
|
||||
export type OrderByTypes =
|
||||
| (typeof orderByTypes)[keyof typeof orderByTypes]
|
||||
| null;
|
||||
9
src/store/appcontext/action.ts
Normal file
9
src/store/appcontext/action.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Action } from "../Action";
|
||||
import type { AppContextType } from "./state";
|
||||
|
||||
export const SWITCH_THEME = "SWITCH_THEME";
|
||||
export const RESTORE_APPCONTEXT = "RESTORE_APPCONTEXT";
|
||||
|
||||
type ActionType = typeof SWITCH_THEME | typeof RESTORE_APPCONTEXT;
|
||||
|
||||
export type AppContextAction = Action<ActionType, AppContextType>;
|
||||
3
src/store/appcontext/index.ts
Normal file
3
src/store/appcontext/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./action";
|
||||
export * from "./reducer";
|
||||
export * from "./state";
|
||||
28
src/store/appcontext/reducer.ts
Normal file
28
src/store/appcontext/reducer.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { AppContextAction } from "./action";
|
||||
import { Themes, type AppContextType } from "./state";
|
||||
|
||||
export const initialState: AppContextType = {
|
||||
theme: "light",
|
||||
};
|
||||
|
||||
export const appContextReducer = (
|
||||
state: AppContextType,
|
||||
action: AppContextAction,
|
||||
): AppContextType => {
|
||||
switch (action.type) {
|
||||
case "SWITCH_THEME": {
|
||||
return {
|
||||
...state,
|
||||
theme: state.theme === Themes.Dark ? Themes.Light : Themes.Dark,
|
||||
};
|
||||
}
|
||||
case "RESTORE_APPCONTEXT": {
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw Error(`wrong action:${action.type}`);
|
||||
}
|
||||
};
|
||||
13
src/store/appcontext/state.ts
Normal file
13
src/store/appcontext/state.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const Themes = {
|
||||
Dark: "dark",
|
||||
Light: "light",
|
||||
} as const;
|
||||
export type ThemesType = (typeof Themes)[keyof typeof Themes];
|
||||
|
||||
interface AppContext {
|
||||
theme: ThemesType;
|
||||
nickName: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export type AppContextType = Partial<AppContext>;
|
||||
182
src/store/home/action.ts
Normal file
182
src/store/home/action.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Action } from "../Action";
|
||||
import type {
|
||||
HomeState,
|
||||
SearchResult,
|
||||
ReplyToType,
|
||||
ProductSearchType,
|
||||
} from "./state";
|
||||
import type { Category, Reply } from "../../api/models";
|
||||
|
||||
export const SET_LOADING = "set_loading";
|
||||
export const SET_CATEGORIES = "set_categories";
|
||||
export const SET_CATEGORY_ID = "set_category_id";
|
||||
export const SET_KEYWORD = "set_keyword";
|
||||
export const SET_PAGE_INDEX = "set_page_index";
|
||||
export const SET_IS_SEARCHING = "set_is_searching";
|
||||
export const SET_SEARCH_RESULT = "set_search_result";
|
||||
export const SET_REPLIES = "set_replies";
|
||||
export const TOGGLE_ORDER_BY_PUBLISH_TIME = "toggle_order_by_publish_time";
|
||||
export const TOGGLE_ORDER_BY_CATEGORY_ID = "toggle_order_by_category_id";
|
||||
export const TOGGLE_ORDER_BY_REPLY_AMOUNT = "toggle_order_by_reply_amount";
|
||||
export const SET_IS_REPLING = "set_is_repling";
|
||||
export const SET_REPLY_TO = "set_reply_to";
|
||||
export const SET_WILL_SEARCHING = "set_will_searching";
|
||||
export const SET_SEARCH_PRODUCTS = "set_search_products";
|
||||
export const SET_SEARCH_FOCUSED = "set_search_focused";
|
||||
|
||||
export type ActionsType =
|
||||
| typeof SET_LOADING
|
||||
| typeof SET_CATEGORIES
|
||||
| typeof SET_CATEGORY_ID
|
||||
| typeof SET_KEYWORD
|
||||
| typeof SET_PAGE_INDEX
|
||||
| typeof SET_IS_SEARCHING
|
||||
| typeof SET_SEARCH_RESULT
|
||||
| typeof SET_REPLIES
|
||||
| typeof TOGGLE_ORDER_BY_PUBLISH_TIME
|
||||
| typeof TOGGLE_ORDER_BY_CATEGORY_ID
|
||||
| typeof TOGGLE_ORDER_BY_REPLY_AMOUNT
|
||||
| typeof SET_IS_REPLING
|
||||
| typeof SET_REPLY_TO
|
||||
| typeof SET_WILL_SEARCHING
|
||||
| typeof SET_SEARCH_PRODUCTS
|
||||
| typeof SET_SEARCH_FOCUSED;
|
||||
|
||||
export type HomeAction = Action<ActionsType, HomeState>;
|
||||
|
||||
export function setLoading(isLoading: boolean): HomeAction {
|
||||
return {
|
||||
type: "set_loading",
|
||||
payload: {
|
||||
isLoading,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setCategories(categories: Array<Category>): HomeAction {
|
||||
return {
|
||||
type: "set_categories",
|
||||
payload: {
|
||||
categories,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setCategoryId(categoryId: number): HomeAction {
|
||||
return {
|
||||
type: "set_category_id",
|
||||
payload: {
|
||||
categoryId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setKeyword(keyword: string): HomeAction {
|
||||
return {
|
||||
type: "set_keyword",
|
||||
payload: {
|
||||
keyword,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setPageIndex(pageIndex: number): HomeAction {
|
||||
return {
|
||||
type: "set_page_index",
|
||||
payload: {
|
||||
pageIndex,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setIsSearch(isSearching: boolean): HomeAction {
|
||||
return {
|
||||
type: "set_is_searching",
|
||||
payload: {
|
||||
isSearching,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setSearchResult(result: SearchResult): HomeAction {
|
||||
return {
|
||||
type: "set_search_result",
|
||||
payload: {
|
||||
...result,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setReplies(replies: Array<Reply> | null): HomeAction {
|
||||
return {
|
||||
type: "set_replies",
|
||||
payload: {
|
||||
replies,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleOrderByPublishTime(): HomeAction {
|
||||
return {
|
||||
type: "toggle_order_by_publish_time",
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleOrderByCategoryId(): HomeAction {
|
||||
return {
|
||||
type: "toggle_order_by_category_id",
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleOrderByReplyAmount(): HomeAction {
|
||||
return {
|
||||
type: "toggle_order_by_reply_amount",
|
||||
};
|
||||
}
|
||||
|
||||
export function setIsRepling(isRepling: boolean): HomeAction {
|
||||
return {
|
||||
type: "set_is_repling",
|
||||
payload: {
|
||||
isRepling,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setReplyTo(reply: Partial<ReplyToType>): HomeAction {
|
||||
return {
|
||||
type: "set_reply_to",
|
||||
payload: {
|
||||
reply,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setWillSearching(willSearching: boolean): HomeAction {
|
||||
return {
|
||||
type: "set_will_searching",
|
||||
payload: {
|
||||
willSearching,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setSearchProducts(
|
||||
products: Array<ProductSearchType>,
|
||||
): HomeAction {
|
||||
return {
|
||||
type: "set_search_products",
|
||||
payload: {
|
||||
products,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setSearchFocused(searchFocused: boolean): HomeAction {
|
||||
return {
|
||||
type: "set_search_focused",
|
||||
payload: {
|
||||
searchFocused,
|
||||
},
|
||||
};
|
||||
}
|
||||
3
src/store/home/index.ts
Normal file
3
src/store/home/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./action";
|
||||
export * from "./reducer";
|
||||
export * from "./state";
|
||||
168
src/store/home/reducer.ts
Normal file
168
src/store/home/reducer.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { HomeAction } from "./action";
|
||||
import type { HomeState } from "./state";
|
||||
|
||||
export const initialState: HomeState = {
|
||||
categories: [],
|
||||
isLoading: true,
|
||||
pageIndex: 1,
|
||||
categoryId: 0,
|
||||
keyword: "",
|
||||
isSearching: false,
|
||||
pageCount: 0,
|
||||
pageSize: 10,
|
||||
requests: [],
|
||||
orderBys: {
|
||||
categoryId: null,
|
||||
publishTime: "desc",
|
||||
replyAmount: null,
|
||||
},
|
||||
replies: null,
|
||||
isRepling: false,
|
||||
reply: {
|
||||
amount: 0,
|
||||
categoryId: 0,
|
||||
memo: "",
|
||||
price: 0,
|
||||
productId: 0,
|
||||
productName: "",
|
||||
requestId: 0,
|
||||
},
|
||||
searchFocused: false,
|
||||
willSearching: false,
|
||||
products: [],
|
||||
};
|
||||
|
||||
export const homeReducer = (
|
||||
state: HomeState,
|
||||
action: HomeAction,
|
||||
): HomeState => {
|
||||
switch (action.type) {
|
||||
case "set_loading": {
|
||||
return {
|
||||
...state,
|
||||
isLoading: action.payload?.isLoading || false,
|
||||
};
|
||||
}
|
||||
case "set_categories": {
|
||||
return {
|
||||
...state,
|
||||
categories: action.payload?.categories || [],
|
||||
};
|
||||
}
|
||||
case "set_category_id": {
|
||||
return {
|
||||
...state,
|
||||
categoryId: action.payload?.categoryId || 0,
|
||||
};
|
||||
}
|
||||
case "set_is_searching": {
|
||||
return {
|
||||
...state,
|
||||
isSearching: action.payload?.isSearching || false,
|
||||
};
|
||||
}
|
||||
case "set_keyword": {
|
||||
return {
|
||||
...state,
|
||||
keyword: action.payload?.keyword || "",
|
||||
};
|
||||
}
|
||||
case "set_page_index": {
|
||||
return {
|
||||
...state,
|
||||
pageIndex: action.payload?.pageIndex || 1,
|
||||
};
|
||||
}
|
||||
case "set_search_result": {
|
||||
return {
|
||||
...state,
|
||||
requests:
|
||||
state.pageIndex == 1
|
||||
? action.payload?.requests || []
|
||||
: [...state.requests, ...(action.payload?.requests || [])],
|
||||
pageCount: action.payload?.pageCount || 0,
|
||||
};
|
||||
}
|
||||
case "set_replies": {
|
||||
return {
|
||||
...state,
|
||||
isRepling: false,
|
||||
replies: action.payload?.replies || null,
|
||||
};
|
||||
}
|
||||
case "toggle_order_by_publish_time": {
|
||||
return {
|
||||
...state,
|
||||
orderBys: {
|
||||
...state.orderBys,
|
||||
publishTime:
|
||||
!state.orderBys.publishTime || state.orderBys.publishTime == "desc"
|
||||
? "asc"
|
||||
: "desc",
|
||||
},
|
||||
};
|
||||
}
|
||||
case "toggle_order_by_category_id": {
|
||||
return {
|
||||
...state,
|
||||
orderBys: {
|
||||
...state.orderBys,
|
||||
categoryId:
|
||||
!state.orderBys.categoryId || state.orderBys.categoryId == "desc"
|
||||
? "asc"
|
||||
: "desc",
|
||||
},
|
||||
};
|
||||
}
|
||||
case "toggle_order_by_reply_amount": {
|
||||
return {
|
||||
...state,
|
||||
orderBys: {
|
||||
...state.orderBys,
|
||||
replyAmount:
|
||||
!state.orderBys.replyAmount || state.orderBys.replyAmount == "desc"
|
||||
? "asc"
|
||||
: "desc",
|
||||
},
|
||||
};
|
||||
}
|
||||
case "set_is_repling": {
|
||||
return {
|
||||
...state,
|
||||
isRepling: action.payload?.isRepling ?? false,
|
||||
};
|
||||
}
|
||||
case "set_reply_to": {
|
||||
return {
|
||||
...state,
|
||||
isRepling: true,
|
||||
willSearching: false,
|
||||
reply: {
|
||||
...state.reply,
|
||||
...action.payload?.reply,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "set_will_searching": {
|
||||
return {
|
||||
...state,
|
||||
willSearching: action.payload?.willSearching ?? false,
|
||||
};
|
||||
}
|
||||
case "set_search_products": {
|
||||
return {
|
||||
...state,
|
||||
willSearching: false,
|
||||
products: action.payload?.products ?? [],
|
||||
};
|
||||
}
|
||||
case "set_search_focused": {
|
||||
return {
|
||||
...state,
|
||||
searchFocused: action.payload?.searchFocused ?? false,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw Error(`未知操作(${action.type})`);
|
||||
}
|
||||
};
|
||||
65
src/store/home/state.ts
Normal file
65
src/store/home/state.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Category } from "../../api/models";
|
||||
import type { Request } from "../../api/models";
|
||||
import type { Reply } from "../../api/models";
|
||||
import type { OrderByTypes } from "../Types";
|
||||
|
||||
interface OrderByState {
|
||||
publishTime: OrderByTypes;
|
||||
categoryId: OrderByTypes;
|
||||
replyAmount: OrderByTypes;
|
||||
}
|
||||
|
||||
interface SearchState {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
categoryId: number;
|
||||
keyword: string;
|
||||
orderBys: OrderByState;
|
||||
isSearching: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
requests: Array<Request>;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
export interface RequestReplyState {
|
||||
replies: Array<Reply> | null;
|
||||
}
|
||||
|
||||
export interface ReplyToType {
|
||||
categoryId: number;
|
||||
requestId: number;
|
||||
productId: number;
|
||||
productName: string;
|
||||
amount: number;
|
||||
price: number;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
export interface ReplyToState {
|
||||
isRepling: boolean;
|
||||
reply: Partial<ReplyToType>;
|
||||
}
|
||||
|
||||
export interface ProductSearchType {
|
||||
productId: number;
|
||||
productName: string;
|
||||
productDescription: string | null;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface ProductSearchState {
|
||||
searchFocused: boolean;
|
||||
willSearching: boolean;
|
||||
products: Array<ProductSearchType>;
|
||||
}
|
||||
|
||||
export type HomeState = {
|
||||
categories: Array<Category>;
|
||||
isLoading: boolean;
|
||||
} & SearchState &
|
||||
SearchResult &
|
||||
RequestReplyState &
|
||||
ReplyToState &
|
||||
ProductSearchState;
|
||||
135
src/store/product/action.ts
Normal file
135
src/store/product/action.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Action } from "../Action";
|
||||
import type { ProductState, SearchResult, EditProductType } from "./state";
|
||||
import type { Category } from "../../api/models";
|
||||
|
||||
export const SET_LOADING = "set_loading";
|
||||
export const SET_CATEGORIES = "set_categories";
|
||||
export const SET_CATEGORY = "set_category";
|
||||
export const SET_KEYWORD = "set_keyword";
|
||||
export const SET_PAGE_INDEX = "set_page_index";
|
||||
export const SET_IS_SEARCHING = "set_is_searching";
|
||||
export const SET_SEARCH_RESULT = "set_search_result";
|
||||
export const SET_EDIT_PRODUCT = "set_edit_product";
|
||||
export const REMOVE_PRODUCT = "remove_product";
|
||||
export const TOGGLE_ORDER_BY_CREATE_TIME = "toggle_order_by_create_time";
|
||||
export const TOGGLE_ORDER_BY_CATEGORY_ID = "toggle_order_by_category_id";
|
||||
export const TOGGLE_ORDER_BY_SOLD_AMOUNT = "toggle_order_by_sold_amount";
|
||||
|
||||
export type ActionsType =
|
||||
| typeof SET_LOADING
|
||||
| typeof SET_CATEGORIES
|
||||
| typeof SET_CATEGORY
|
||||
| typeof SET_KEYWORD
|
||||
| typeof SET_PAGE_INDEX
|
||||
| typeof SET_IS_SEARCHING
|
||||
| typeof SET_SEARCH_RESULT
|
||||
| typeof SET_EDIT_PRODUCT
|
||||
| typeof REMOVE_PRODUCT
|
||||
| typeof TOGGLE_ORDER_BY_CREATE_TIME
|
||||
| typeof TOGGLE_ORDER_BY_CATEGORY_ID
|
||||
| typeof TOGGLE_ORDER_BY_SOLD_AMOUNT;
|
||||
|
||||
export type ProductAction = Action<ActionsType, ProductState>;
|
||||
|
||||
export function setLoading(isLoading: boolean): ProductAction {
|
||||
return {
|
||||
type: "set_loading",
|
||||
payload: {
|
||||
isLoading,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setCategories(categories: Array<Category>): ProductAction {
|
||||
return {
|
||||
type: "set_categories",
|
||||
payload: {
|
||||
categories,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setCategory(category: Category): ProductAction {
|
||||
return {
|
||||
type: "set_category",
|
||||
payload: {
|
||||
categoryId: category.id,
|
||||
editProduct: {
|
||||
categoryId: category.id,
|
||||
categoryName: category.name,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setKeyword(keyword: string): ProductAction {
|
||||
return {
|
||||
type: "set_keyword",
|
||||
payload: {
|
||||
keyword,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setPageIndex(pageIndex: number): ProductAction {
|
||||
return {
|
||||
type: "set_page_index",
|
||||
payload: {
|
||||
pageIndex,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setIsSearch(isSearching: boolean): ProductAction {
|
||||
return {
|
||||
type: "set_is_searching",
|
||||
payload: {
|
||||
isSearching,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setSearchResult(result: SearchResult): ProductAction {
|
||||
return {
|
||||
type: "set_search_result",
|
||||
payload: {
|
||||
...result,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setEditProduct(product: EditProductType): ProductAction {
|
||||
return {
|
||||
type: "set_edit_product",
|
||||
payload: {
|
||||
editProduct: product,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function removeProduct(productId: number): ProductAction {
|
||||
return {
|
||||
type: "remove_product",
|
||||
payload: {
|
||||
removedProductId: productId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleOrderByCreateTime(): ProductAction {
|
||||
return {
|
||||
type: "toggle_order_by_create_time",
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleOrderByCategoryId(): ProductAction {
|
||||
return {
|
||||
type: "toggle_order_by_category_id",
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleOrderBySoldAmount(): ProductAction {
|
||||
return {
|
||||
type: "toggle_order_by_sold_amount",
|
||||
};
|
||||
}
|
||||
3
src/store/product/index.ts
Normal file
3
src/store/product/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./action";
|
||||
export * from "./reducer";
|
||||
export * from "./state";
|
||||
144
src/store/product/reducer.ts
Normal file
144
src/store/product/reducer.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { ProductAction } from "./action";
|
||||
import type { ProductState } from "./state";
|
||||
|
||||
export const initialState: ProductState = {
|
||||
categories: [],
|
||||
isLoading: true,
|
||||
pageIndex: 1,
|
||||
pageSize: 10,
|
||||
categoryId: 0,
|
||||
keyword: "",
|
||||
isSearching: false,
|
||||
pageCount: 0,
|
||||
products: [],
|
||||
removedProductId: 0,
|
||||
editProduct: {
|
||||
categoryId: undefined,
|
||||
description: null,
|
||||
detail: null,
|
||||
id: 0,
|
||||
logoName: null,
|
||||
logoUrl: null,
|
||||
minimumUnit: null,
|
||||
name: null,
|
||||
unitPrice: undefined,
|
||||
},
|
||||
orderBys: {
|
||||
createTime: "desc",
|
||||
categoryId: null,
|
||||
soldAmount: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const productReducer = (
|
||||
state: ProductState,
|
||||
action: ProductAction,
|
||||
): ProductState => {
|
||||
switch (action.type) {
|
||||
case "set_loading": {
|
||||
return {
|
||||
...state,
|
||||
isLoading: action.payload?.isLoading || false,
|
||||
};
|
||||
}
|
||||
case "set_categories": {
|
||||
return {
|
||||
...state,
|
||||
categories: action.payload?.categories || [],
|
||||
};
|
||||
}
|
||||
case "set_category": {
|
||||
return {
|
||||
...state,
|
||||
categoryId: action.payload?.categoryId || 0,
|
||||
editProduct: {
|
||||
...state.editProduct,
|
||||
...action.payload?.editProduct,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "set_is_searching": {
|
||||
return {
|
||||
...state,
|
||||
isSearching: action.payload?.isSearching || false,
|
||||
};
|
||||
}
|
||||
case "set_keyword": {
|
||||
return {
|
||||
...state,
|
||||
keyword: action.payload?.keyword || "",
|
||||
};
|
||||
}
|
||||
case "set_page_index": {
|
||||
return {
|
||||
...state,
|
||||
pageIndex: action.payload?.pageIndex || 1,
|
||||
};
|
||||
}
|
||||
case "set_search_result": {
|
||||
return {
|
||||
...state,
|
||||
products:
|
||||
state.pageIndex == 1
|
||||
? action.payload?.products || []
|
||||
: [...state.products, ...(action.payload?.products || [])],
|
||||
pageCount: action.payload?.pageCount || 0,
|
||||
};
|
||||
}
|
||||
case "set_edit_product": {
|
||||
return {
|
||||
...state,
|
||||
editProduct: {
|
||||
...state.editProduct,
|
||||
...(action.payload?.editProduct || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
case "remove_product": {
|
||||
return {
|
||||
...state,
|
||||
products: state.products.filter(
|
||||
(p) => p.id != action.payload?.removedProductId,
|
||||
),
|
||||
};
|
||||
}
|
||||
case "toggle_order_by_create_time": {
|
||||
return {
|
||||
...state,
|
||||
orderBys: {
|
||||
...state.orderBys,
|
||||
createTime:
|
||||
!state.orderBys.createTime || state.orderBys.createTime == "desc"
|
||||
? "asc"
|
||||
: "desc",
|
||||
},
|
||||
};
|
||||
}
|
||||
case "toggle_order_by_category_id": {
|
||||
return {
|
||||
...state,
|
||||
orderBys: {
|
||||
...state.orderBys,
|
||||
categoryId:
|
||||
!state.orderBys.categoryId || state.orderBys.categoryId == "desc"
|
||||
? "asc"
|
||||
: "desc",
|
||||
},
|
||||
};
|
||||
}
|
||||
case "toggle_order_by_sold_amount": {
|
||||
return {
|
||||
...state,
|
||||
orderBys: {
|
||||
...state.orderBys,
|
||||
soldAmount:
|
||||
!state.orderBys.soldAmount || state.orderBys.soldAmount == "desc"
|
||||
? "asc"
|
||||
: "desc",
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw Error(`未知操作(${action.type})`);
|
||||
}
|
||||
};
|
||||
34
src/store/product/state.ts
Normal file
34
src/store/product/state.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Category, Product, EditProductParams } from "../../api/models";
|
||||
import type { OrderByTypes } from "../Types";
|
||||
|
||||
interface OrderByState {
|
||||
createTime: OrderByTypes;
|
||||
categoryId: OrderByTypes;
|
||||
soldAmount: OrderByTypes;
|
||||
}
|
||||
|
||||
interface SearchState {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
categoryId: number;
|
||||
keyword: string;
|
||||
orderBys: OrderByState;
|
||||
isSearching: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
products: Array<Product>;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
export type EditProductType = Partial<
|
||||
EditProductParams & { logoUrl: string | null; categoryName: string | null }
|
||||
>;
|
||||
|
||||
export type ProductState = {
|
||||
categories: Array<Category>;
|
||||
isLoading: boolean;
|
||||
editProduct: EditProductType;
|
||||
removedProductId: number;
|
||||
} & SearchState &
|
||||
SearchResult;
|
||||
144
src/store/request/action.ts
Normal file
144
src/store/request/action.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Action } from "../Action";
|
||||
import type { RequestState, SearchResult } from "./state";
|
||||
import type { Category, Reply, CreateRequestParams } from "../../api/models";
|
||||
|
||||
export const SET_LOADING = "set_loading";
|
||||
export const SET_CATEGORIES = "set_categories";
|
||||
export const SET_CATEGORY_ID = "set_category_id";
|
||||
export const SET_KEYWORD = "set_keyword";
|
||||
export const SET_PAGE_INDEX = "set_page_index";
|
||||
export const SET_IS_SEARCHING = "set_is_searching";
|
||||
export const SET_SEARCH_RESULT = "set_search_result";
|
||||
export const SET_REPLIES = "set_replies";
|
||||
export const TOGGLE_ORDER_BY_PUBLISH_TIME = "toggle_order_by_publish_time";
|
||||
export const TOGGLE_ORDER_BY_CATEGORY_ID = "toggle_order_by_category_id";
|
||||
export const TOGGLE_ORDER_BY_REPLY_AMOUNT = "toggle_order_by_reply_amount";
|
||||
export const SET_IS_PUBLISH = "set_is_publish";
|
||||
export const SET_REQUEST_PUBLISH = "set_request_publish";
|
||||
|
||||
export type ActionsType =
|
||||
| typeof SET_LOADING
|
||||
| typeof SET_CATEGORIES
|
||||
| typeof SET_CATEGORY_ID
|
||||
| typeof SET_KEYWORD
|
||||
| typeof SET_PAGE_INDEX
|
||||
| typeof SET_IS_SEARCHING
|
||||
| typeof SET_SEARCH_RESULT
|
||||
| typeof SET_REPLIES
|
||||
| typeof TOGGLE_ORDER_BY_PUBLISH_TIME
|
||||
| typeof TOGGLE_ORDER_BY_CATEGORY_ID
|
||||
| typeof TOGGLE_ORDER_BY_REPLY_AMOUNT
|
||||
| typeof SET_IS_PUBLISH
|
||||
| typeof SET_REQUEST_PUBLISH;
|
||||
|
||||
export type RequestAction = Action<ActionsType, RequestState>;
|
||||
|
||||
export function setLoading(isLoading: boolean): RequestAction {
|
||||
return {
|
||||
type: "set_loading",
|
||||
payload: {
|
||||
isLoading,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setCategories(categories: Array<Category>): RequestAction {
|
||||
return {
|
||||
type: "set_categories",
|
||||
payload: {
|
||||
categories,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setCategoryId(categoryId: number): RequestAction {
|
||||
return {
|
||||
type: "set_category_id",
|
||||
payload: {
|
||||
categoryId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setKeyword(keyword: string): RequestAction {
|
||||
return {
|
||||
type: "set_keyword",
|
||||
payload: {
|
||||
keyword,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setPageIndex(pageIndex: number): RequestAction {
|
||||
return {
|
||||
type: "set_page_index",
|
||||
payload: {
|
||||
pageIndex,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setIsSearch(isSearching: boolean): RequestAction {
|
||||
return {
|
||||
type: "set_is_searching",
|
||||
payload: {
|
||||
isSearching,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setSearchResult(result: SearchResult): RequestAction {
|
||||
return {
|
||||
type: "set_search_result",
|
||||
payload: {
|
||||
...result,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setReplies(replies: Array<Reply> | null): RequestAction {
|
||||
return {
|
||||
type: "set_replies",
|
||||
payload: {
|
||||
replies,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleOrderByPublishTime(): RequestAction {
|
||||
return {
|
||||
type: "toggle_order_by_publish_time",
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleOrderByCategoryId(): RequestAction {
|
||||
return {
|
||||
type: "toggle_order_by_category_id",
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleOrderByReplyAmount(): RequestAction {
|
||||
return {
|
||||
type: "toggle_order_by_reply_amount",
|
||||
};
|
||||
}
|
||||
|
||||
export function setIsPublish(isPublish: boolean): RequestAction {
|
||||
return {
|
||||
type: "set_is_publish",
|
||||
payload: {
|
||||
isPublish,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setRequestPublish(
|
||||
requestPublish: Partial<CreateRequestParams>,
|
||||
): RequestAction {
|
||||
return {
|
||||
type: "set_request_publish",
|
||||
payload: {
|
||||
requestPublish,
|
||||
},
|
||||
};
|
||||
}
|
||||
3
src/store/request/index.ts
Normal file
3
src/store/request/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./action";
|
||||
export * from "./reducer";
|
||||
export * from "./state";
|
||||
140
src/store/request/reducer.ts
Normal file
140
src/store/request/reducer.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { RequestAction } from "./action";
|
||||
import type { RequestState } from "./state";
|
||||
|
||||
export const initialState: RequestState = {
|
||||
categories: [],
|
||||
isLoading: true,
|
||||
pageIndex: 1,
|
||||
categoryId: 0,
|
||||
keyword: "",
|
||||
isSearching: false,
|
||||
pageCount: 0,
|
||||
pageSize: 10,
|
||||
requests: [],
|
||||
orderBys: {
|
||||
categoryId: null,
|
||||
publishTime: "desc",
|
||||
replyAmount: null,
|
||||
},
|
||||
replies: null,
|
||||
isPublish: false,
|
||||
requestPublish: {
|
||||
categoryId: 0,
|
||||
deadline: "",
|
||||
description: null,
|
||||
name: "",
|
||||
},
|
||||
};
|
||||
|
||||
export const requestReducer = (
|
||||
state: RequestState,
|
||||
action: RequestAction,
|
||||
): RequestState => {
|
||||
switch (action.type) {
|
||||
case "set_loading": {
|
||||
return {
|
||||
...state,
|
||||
isLoading: action.payload?.isLoading || false,
|
||||
};
|
||||
}
|
||||
case "set_categories": {
|
||||
return {
|
||||
...state,
|
||||
categories: action.payload?.categories || [],
|
||||
};
|
||||
}
|
||||
case "set_category_id": {
|
||||
return {
|
||||
...state,
|
||||
categoryId: action.payload?.categoryId || 0,
|
||||
};
|
||||
}
|
||||
case "set_is_searching": {
|
||||
return {
|
||||
...state,
|
||||
isSearching: action.payload?.isSearching || false,
|
||||
};
|
||||
}
|
||||
case "set_keyword": {
|
||||
return {
|
||||
...state,
|
||||
keyword: action.payload?.keyword || "",
|
||||
};
|
||||
}
|
||||
case "set_page_index": {
|
||||
return {
|
||||
...state,
|
||||
pageIndex: action.payload?.pageIndex || 1,
|
||||
};
|
||||
}
|
||||
case "set_search_result": {
|
||||
return {
|
||||
...state,
|
||||
requests:
|
||||
state.pageIndex == 1
|
||||
? action.payload?.requests || []
|
||||
: [...state.requests, ...(action.payload?.requests || [])],
|
||||
pageCount: action.payload?.pageCount || 0,
|
||||
};
|
||||
}
|
||||
case "set_replies": {
|
||||
return {
|
||||
...state,
|
||||
replies: action.payload?.replies || null,
|
||||
};
|
||||
}
|
||||
case "toggle_order_by_publish_time": {
|
||||
return {
|
||||
...state,
|
||||
orderBys: {
|
||||
...state.orderBys,
|
||||
publishTime:
|
||||
!state.orderBys.publishTime || state.orderBys.publishTime == "desc"
|
||||
? "asc"
|
||||
: "desc",
|
||||
},
|
||||
};
|
||||
}
|
||||
case "toggle_order_by_category_id": {
|
||||
return {
|
||||
...state,
|
||||
orderBys: {
|
||||
...state.orderBys,
|
||||
categoryId:
|
||||
!state.orderBys.categoryId || state.orderBys.categoryId == "desc"
|
||||
? "asc"
|
||||
: "desc",
|
||||
},
|
||||
};
|
||||
}
|
||||
case "toggle_order_by_reply_amount": {
|
||||
return {
|
||||
...state,
|
||||
orderBys: {
|
||||
...state.orderBys,
|
||||
replyAmount:
|
||||
!state.orderBys.replyAmount || state.orderBys.replyAmount == "desc"
|
||||
? "asc"
|
||||
: "desc",
|
||||
},
|
||||
};
|
||||
}
|
||||
case "set_is_publish": {
|
||||
return {
|
||||
...state,
|
||||
isPublish: action.payload?.isPublish ?? false,
|
||||
};
|
||||
}
|
||||
case "set_request_publish": {
|
||||
return {
|
||||
...state,
|
||||
requestPublish: {
|
||||
...state.requestPublish,
|
||||
...action.payload?.requestPublish,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw Error(`未知操作(${action.type})`);
|
||||
}
|
||||
};
|
||||
44
src/store/request/state.ts
Normal file
44
src/store/request/state.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
Category,
|
||||
Request,
|
||||
CreateRequestParams,
|
||||
Reply,
|
||||
} from "../../api/models";
|
||||
import type { OrderByTypes } from "../Types";
|
||||
|
||||
interface OrderByState {
|
||||
publishTime: OrderByTypes;
|
||||
categoryId: OrderByTypes;
|
||||
replyAmount: OrderByTypes;
|
||||
}
|
||||
|
||||
interface SearchState {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
categoryId: number;
|
||||
keyword: string;
|
||||
orderBys: OrderByState;
|
||||
isSearching: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
requests: Array<Request>;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
export interface RequestReplyState {
|
||||
replies: Array<Reply> | null;
|
||||
}
|
||||
|
||||
export interface PublishState {
|
||||
isPublish: boolean;
|
||||
requestPublish: Partial<CreateRequestParams>;
|
||||
}
|
||||
|
||||
export type RequestState = {
|
||||
categories: Array<Category>;
|
||||
isLoading: boolean;
|
||||
} & SearchState &
|
||||
SearchResult &
|
||||
RequestReplyState &
|
||||
PublishState;
|
||||
40
src/store/signin/action.ts
Normal file
40
src/store/signin/action.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { type Action } from "../Action";
|
||||
import type { SignInState, SignInType, SignUpType } from "./state";
|
||||
|
||||
export const SET_SIGN_IN = "set_sign_in";
|
||||
export const SET_SIGN_UP = "set_sign_up";
|
||||
export const SET_IS_SUBMITING = "set_is_submiting";
|
||||
|
||||
export type ActionType =
|
||||
| typeof SET_SIGN_IN
|
||||
| typeof SET_SIGN_UP
|
||||
| typeof SET_IS_SUBMITING;
|
||||
|
||||
export type SignInAction = Action<ActionType, SignInState>;
|
||||
|
||||
export function setSignIn(signIn: SignInType): SignInAction {
|
||||
return {
|
||||
type: "set_sign_in",
|
||||
payload: {
|
||||
signInUser: signIn,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setSignUp(signUp: SignUpType): SignInAction {
|
||||
return {
|
||||
type: "set_sign_up",
|
||||
payload: {
|
||||
signUpUser: signUp,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setIsSubmiting(isSubmiting: boolean): SignInAction {
|
||||
return {
|
||||
type: "set_is_submiting",
|
||||
payload: {
|
||||
isSubmiting,
|
||||
},
|
||||
};
|
||||
}
|
||||
3
src/store/signin/index.ts
Normal file
3
src/store/signin/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./action";
|
||||
export * from "./reducer";
|
||||
export * from "./state";
|
||||
47
src/store/signin/reducer.ts
Normal file
47
src/store/signin/reducer.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { type SignInState } from "./state";
|
||||
import { type SignInAction } from "./action";
|
||||
|
||||
export const initialState: SignInState = {
|
||||
isSubmiting: false,
|
||||
signInUser: {
|
||||
account: "",
|
||||
password: "",
|
||||
},
|
||||
signUpUser: {
|
||||
account: "",
|
||||
defaultRole: "Buyer",
|
||||
nickName: "",
|
||||
password: "",
|
||||
},
|
||||
};
|
||||
|
||||
export function signInReducer(
|
||||
state: SignInState,
|
||||
action: SignInAction,
|
||||
): SignInState {
|
||||
switch (action.type) {
|
||||
case "set_sign_in":
|
||||
return {
|
||||
...state,
|
||||
signInUser: {
|
||||
...state.signInUser,
|
||||
...action.payload?.signInUser,
|
||||
},
|
||||
};
|
||||
case "set_sign_up":
|
||||
return {
|
||||
...state,
|
||||
signUpUser: {
|
||||
...state.signUpUser,
|
||||
...action.payload?.signUpUser,
|
||||
},
|
||||
};
|
||||
case "set_is_submiting":
|
||||
return {
|
||||
...state,
|
||||
isSubmiting: action.payload?.isSubmiting ?? false,
|
||||
};
|
||||
default:
|
||||
throw Error(`wrong action type:${action.type}`);
|
||||
}
|
||||
}
|
||||
10
src/store/signin/state.ts
Normal file
10
src/store/signin/state.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { SignInParams, SignUpParams } from "../../api/models";
|
||||
|
||||
export type SignInType = Partial<SignInParams>;
|
||||
export type SignUpType = Partial<SignUpParams & { confirmPassword: string }>;
|
||||
|
||||
export interface SignInState {
|
||||
isSubmiting: boolean;
|
||||
signInUser: SignInType;
|
||||
signUpUser: SignUpType;
|
||||
}
|
||||
3
src/themes/dark.module.css
Normal file
3
src/themes/dark.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.dark header {
|
||||
background-color: black;
|
||||
}
|
||||
2
src/themes/index.ts
Normal file
2
src/themes/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./dark.module.css";
|
||||
export * from "./light.module.css";
|
||||
3
src/themes/light.module.css
Normal file
3
src/themes/light.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.light header {
|
||||
background-color: white;
|
||||
}
|
||||
34
tsconfig.app.json
Normal file
34
tsconfig.app.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
40
vite.config.ts
Normal file
40
vite.config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
envDir: path.resolve(".", "env"),
|
||||
plugins: [react()],
|
||||
css: {
|
||||
modules: {
|
||||
localsConvention: "camelCase",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: (id) => {
|
||||
if (id.includes("node_modules")) {
|
||||
if (id.includes("react")) {
|
||||
return "react";
|
||||
} else {
|
||||
return "vendors";
|
||||
}
|
||||
}
|
||||
return;
|
||||
},
|
||||
chunkFileNames: (chunkFileInfo) => {
|
||||
for (const id of chunkFileInfo.moduleIds) {
|
||||
const match = id.match(/\/pages\/([^/]+)/);
|
||||
if (match) {
|
||||
return `js/${match[1].toLocaleLowerCase()}-[hash].js`;
|
||||
}
|
||||
}
|
||||
|
||||
return "js/[name]-[hash].js";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user