This commit is contained in:
2026-03-25 14:59:06 +08:00
commit ae315100b4
92 changed files with 9285 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,46 @@
# 简介
**停止购物**不同于传统由商户上架商品消费者进行下单的购物模式,提供相反的由消费者发布需求商户竞标的商品、服务交易的方式。
## 业务流程
卖家:上架商品➡️寻找需求➡️推荐商品➡️⬇️➡️➡️发货➡️⬇️
买家:发布需求➡️➡️➡️➡️➡️➡️➡️➡️➡️️️️️下单付款➡️⬆️➡️➡️收货
> 用户注册后同时具有卖家、买家身份,默认为买家,可以在用户中心切换即可使用相应身份的功能
## 系统功能
- **通用功能**
+ [ ] 注册
账号、密码、默认角色(买家、卖家)
+ [ ] 登录
+ [ ] 退出登录
+ 个人中心
* [ ] 昵称、头像等维护
* [ ] 收货地址维护
* [ ] 角色切换(买家版、卖家版)
- **作为卖家时:**
+ 上架商品
* [ ] 商品基本信息维护
* [ ] 商品详情维护
+ 浏览需求
* [ ] 分类检索
* [ ] 关键词检索
* [ ] 地区检索
* [ ] 排序展示(发布时间、距离、竞标者数量)
+ 竞标
* [ ] 详情页展示(买家信息、需求描述、竞标者列表)
* [ ] 竞标,选择商品
+ 订单管理
* [ ] 竞标中:查看详情
* [ ] 已中标:查看详情、发货、在线沟通
* [ ] 已发货:查看详情、物流
* [ ] 已完成(买家已收货):查看详情、评价买家
* [ ] 已关闭(未中标):查看详情、删除
- **作为买家时:**
+ 发布需求
* [ ] 发布页:分类、简短描述、详细描述、预算
+ 订单管理
* [ ] 已发布:查看详情、修改详情
* [ ] 有竞标:查看详情、查看竞标详情、接受
* [ ] 待发货:查看详情、留言、在线沟通
* [ ] 已发货:查看详情、物流、确认收货
* [ ] 已完成:评价卖家

0
env/.env vendored Normal file
View File

1
env/.env.development vendored Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE=http://localhost:5162

1
env/.env.production vendored Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE=https://api.stopshopping.bjbj.me

37
eslint.config.js Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View 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"
}
}

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

64
public/stopshopping.svg Normal file
View 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
View 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 };

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

View File

@@ -0,0 +1,8 @@
import type { ApiResponseType } from './ApiResponseType';
export interface AccessToken {
token: string;
expiresIn: number;
};
export type AccessTokenResponse = ApiResponseType<AccessToken>;

View File

@@ -0,0 +1,8 @@
import type { ApiResponseType } from './ApiResponseType';
export interface AntiForgeryToken {
headerName: string;
token: string;
};
export type AntiForgeryTokenResponse = ApiResponseType<AntiForgeryToken>;

View File

@@ -0,0 +1,7 @@
export interface ApiResponseType<TData> {
isSucced: boolean;
message: string | null;
data: TData | null;
}
export type ApiResponseMessage = ApiResponseType<null>;

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

View File

@@ -0,0 +1,4 @@
export interface ChangePasswordParams {
oldPassword: string;
newPassword: string;
};

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

View File

@@ -0,0 +1,6 @@
export interface PagedResult<TData> {
pageCount: number;
pageSize: number;
pageIndex: number;
data: Array<TData>;
}

View File

@@ -0,0 +1,4 @@
export interface PagedSearchType {
pageIndex: number;
pageSize: number;
};

40
src/api/models/Product.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

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

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

View 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>&nbsp;
</Link>
</li>
<li>
<Link
to="/user"
className={location.pathname == "/user" ? styles.active : ""}
>
<i className="ss ssi-gerenzhongxin"></i>&nbsp;
{appContext.nickName || "登录"}
</Link>
</li>
</ul>
</li>
</ul>
);
}

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

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

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

View 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
View 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())}>
&nbsp;
{states.orderBys.publishTime ? (
<i
className={`ss ssi-triangleupfill ${states.orderBys.publishTime == "asc" ? "" : styles.desc}`}
></i>
) : (
<></>
)}
</a>
</li>
<li>
<a onClick={() => dispatch(actions.toggleOrderByCategoryId())}>
&nbsp;
{states.orderBys.categoryId ? (
<i
className={`ss ssi-triangleupfill ${states.orderBys.categoryId == "asc" ? "" : styles.desc}`}
></i>
) : (
<></>
)}
</a>
</li>
<li>
<a onClick={() => dispatch(actions.toggleOrderByReplyAmount())}>
&nbsp;
{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,
)}
</>
);
}

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

View File

@@ -0,0 +1,7 @@
export function Component() {
return (
<div>
<h1></h1>
</div>
);
}

View 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
View 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())}>
&nbsp;
{states.orderBys.createTime ? (
<i
className={`ss ssi-triangleupfill ${states.orderBys.createTime == "asc" ? "" : styles.desc}`}
></i>
) : (
<></>
)}
</a>
</li>
<li>
<a onClick={() => dispatch(actions.toggleOrderBySoldAmount())}>
&nbsp;
{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,
)}
</>
);
}

View File

@@ -0,0 +1,3 @@
export function Component() {
return <div></div>;
}

View 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
View 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}>
&nbsp;
<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())}>
&nbsp;
{states.orderBys.publishTime ? (
<i
className={`ss ssi-triangleupfill ${states.orderBys.publishTime == "asc" ? "" : styles.desc}`}
></i>
) : (
<></>
)}
</a>
</li>
<li>
<a onClick={() => dispatch(actions.toggleOrderByCategoryId())}>
&nbsp;
{states.orderBys.categoryId ? (
<i
className={`ss ssi-triangleupfill ${states.orderBys.categoryId == "asc" ? "" : styles.desc}`}
></i>
) : (
<></>
)}
</a>
</li>
<li>
<a onClick={() => dispatch(actions.toggleOrderByReplyAmount())}>
&nbsp;
{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,
)}
</>
);
}

View File

@@ -0,0 +1,7 @@
export function Component() {
return (
<div>
<h1></h1>
</div>
);
}

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

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

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

@@ -0,0 +1,7 @@
export function Component() {
return (
<div>
<h1></h1>
</div>
);
}

48
src/routes.ts Normal file
View 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
View File

@@ -0,0 +1,4 @@
export interface Action<A, T> {
type: A;
payload?: Partial<T>;
}

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

@@ -0,0 +1,7 @@
export const orderByTypes = {
ASC: "asc",
DESC: "desc",
} as const;
export type OrderByTypes =
| (typeof orderByTypes)[keyof typeof orderByTypes]
| null;

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

View File

@@ -0,0 +1,3 @@
export * from "./action";
export * from "./reducer";
export * from "./state";

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

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

@@ -0,0 +1,3 @@
export * from "./action";
export * from "./reducer";
export * from "./state";

168
src/store/home/reducer.ts Normal file
View 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
View 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
View 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",
};
}

View File

@@ -0,0 +1,3 @@
export * from "./action";
export * from "./reducer";
export * from "./state";

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

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

View File

@@ -0,0 +1,3 @@
export * from "./action";
export * from "./reducer";
export * from "./state";

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

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

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

View File

@@ -0,0 +1,3 @@
export * from "./action";
export * from "./reducer";
export * from "./state";

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

View File

@@ -0,0 +1,3 @@
.dark header {
background-color: black;
}

2
src/themes/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./dark.module.css";
export * from "./light.module.css";

View File

@@ -0,0 +1,3 @@
.light header {
background-color: white;
}

34
tsconfig.app.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View 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
View 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";
},
},
},
},
});