From 968078c6db7cc70348e8e98cc626437422980331 Mon Sep 17 00:00:00 2001 From: Jimmy Liow Date: Tue, 20 Aug 2024 14:25:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=20Web=20=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 - web/.eslintrc.cjs | 15 + web/.gitignore | 26 + web/README.md | 26 + web/build.bat | 1 + web/dev.bat | 1 + web/index.html | 26 + web/jsconfig.json | 9 + web/package.json | 38 ++ web/postcss.config.js | 6 + web/prerelease.bat | 1 + web/public/favicon-180x180.png | Bin 0 -> 5626 bytes web/public/favicon.ico | Bin 0 -> 1162 bytes web/src/assets/global.css | 6 + web/src/assets/logo-gh.png | Bin 0 -> 13842 bytes web/src/components/ErrorBoundary.jsx | 33 ++ web/src/components/ErrorPage.jsx | 13 + web/src/config.js | 2 + web/src/hooks/useDatePresets.js | 60 +++ web/src/hooks/useHTLanguageSets.js | 20 + web/src/hooks/useProductsSets.js | 182 +++++++ web/src/hooks/usingStorage.js | 86 ++++ web/src/main.jsx | 49 ++ web/src/pageSpy/index.jsx | 41 ++ web/src/stores/Hotel.js | 66 +++ web/src/stores/ThemeContext.js | 7 + web/src/utils/commons.js | 629 ++++++++++++++++++++++++ web/src/utils/request.js | 154 ++++++ web/src/views/App.jsx | 61 +++ web/src/views/hotel/Detail.jsx | 85 ++++ web/src/views/hotel/HotelComponents.jsx | 144 ++++++ web/src/views/hotel/List.jsx | 106 ++++ web/tailwind.config.js | 26 + web/vite.config.js | 44 ++ 34 files changed, 1963 insertions(+), 2 deletions(-) delete mode 100644 README.md create mode 100644 web/.eslintrc.cjs create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/build.bat create mode 100644 web/dev.bat create mode 100644 web/index.html create mode 100644 web/jsconfig.json create mode 100644 web/package.json create mode 100644 web/postcss.config.js create mode 100644 web/prerelease.bat create mode 100644 web/public/favicon-180x180.png create mode 100644 web/public/favicon.ico create mode 100644 web/src/assets/global.css create mode 100644 web/src/assets/logo-gh.png create mode 100644 web/src/components/ErrorBoundary.jsx create mode 100644 web/src/components/ErrorPage.jsx create mode 100644 web/src/config.js create mode 100644 web/src/hooks/useDatePresets.js create mode 100644 web/src/hooks/useHTLanguageSets.js create mode 100644 web/src/hooks/useProductsSets.js create mode 100644 web/src/hooks/usingStorage.js create mode 100644 web/src/main.jsx create mode 100644 web/src/pageSpy/index.jsx create mode 100644 web/src/stores/Hotel.js create mode 100644 web/src/stores/ThemeContext.js create mode 100644 web/src/utils/commons.js create mode 100644 web/src/utils/request.js create mode 100644 web/src/views/App.jsx create mode 100644 web/src/views/hotel/Detail.jsx create mode 100644 web/src/views/hotel/HotelComponents.jsx create mode 100644 web/src/views/hotel/List.jsx create mode 100644 web/tailwind.config.js create mode 100644 web/vite.config.js diff --git a/README.md b/README.md deleted file mode 100644 index 59a2a65..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# heytripgo.mycht.cn -喜玩酒店接口 diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 0000000..16779ae --- /dev/null +++ b/web/.eslintrc.cjs @@ -0,0 +1,15 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: ['eslint:recommended', 'plugin:react/recommended', 'plugin:react/jsx-runtime', 'plugin:react-hooks/recommended'], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + settings: { react: { version: '18.2' } }, + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + 'no-unused-vars': ['warn', { args: 'after-used', vars: 'all' }], + 'react/prop-types': 'off', + 'react-hooks/rules-of-hooks': 'warn', + }, +}; diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..ec337d7 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,26 @@ +# 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? + +/package-lock.json diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..a335f3e --- /dev/null +++ b/web/README.md @@ -0,0 +1,26 @@ +# 喜玩酒店分销 + +## 开发设置 + +1. 安装组件:npm install +2. 运行开发环境:npm run dev 或者 dev.bat +3. 打包代码:npm run build 或者 build.bat + +## 版本设置 +npm version [ | major | minor | patch | premajor | preminor | prepatch | prerelease | from-git] + +npm version premajor --no-git-tag-version +1.0.0 -> 2.0.0-0 +--preid beta | alpha | rc +npm version prerelease --preid beta --no-git-tag-version +npm version prerelease +2.0.0-alpha-0 -> 2.0.0-alpha-1 -> 2.0.0-alpha-2 ..n -> 2.0.0-alpha-n +npm version patch --no-git-tag-version +2.0.0-n -> 2.0.0 + +## 相关文档 +账号(appId):18daad53d0ec4003a207c41ddaf63b78 +密码(appSecret):f76e547e55964812bf94cc0d31f74333 +文档地址:https://distapi-sandbox.heytripgo.com/swagger +文档账号:heytrip_distapi +文档密码 aAb@a#?*baAyRc6pxHxAbdzuiPkdEn \ No newline at end of file diff --git a/web/build.bat b/web/build.bat new file mode 100644 index 0000000..10da9ff --- /dev/null +++ b/web/build.bat @@ -0,0 +1 @@ +npm run build \ No newline at end of file diff --git a/web/dev.bat b/web/dev.bat new file mode 100644 index 0000000..b896a08 --- /dev/null +++ b/web/dev.bat @@ -0,0 +1 @@ +npm run dev \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..8948406 --- /dev/null +++ b/web/index.html @@ -0,0 +1,26 @@ + + + + + + + 喜玩酒店查询 + + + +
+
+ +
+
+ + + diff --git a/web/jsconfig.json b/web/jsconfig.json new file mode 100644 index 0000000..f9888a6 --- /dev/null +++ b/web/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..978f41e --- /dev/null +++ b/web/package.json @@ -0,0 +1,38 @@ +{ + "name": "heytrip-go", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "4test": "vite build --mode test", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "antd": "^5.17.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.10.0", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@types/react": "^18.0.28", + "@types/react-dom": "^18.0.11", + "@vitejs/plugin-legacy": "^4.0.2", + "@vitejs/plugin-react": "^3.1.0", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-config-react-app": "^7.0.1", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3", + "vite": "^4.2.0", + "vite-plugin-eslint": "^1.8.1", + "vite-plugin-windicss": "^1.9.3", + "windicss": "^3.5.6" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web/prerelease.bat b/web/prerelease.bat new file mode 100644 index 0000000..f418442 --- /dev/null +++ b/web/prerelease.bat @@ -0,0 +1 @@ +npm version prerelease \ No newline at end of file diff --git a/web/public/favicon-180x180.png b/web/public/favicon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..d02e0742e6d20843544810de9a8653cfb7a5f573 GIT binary patch literal 5626 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 VIxmP!#KBxVH6?AuYI(~y{{yx>nY{o2 literal 0 HcmV?d00001 diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..cace78c2e44d2e6e542095a37529f47e87df0057 GIT binary patch literal 1162 zcmV;51aPx$nn^@KR5(w~lgmp2Q543%nMNzM(g*}WdXQR}i#7#OL{yjrSrkDL5>`|YMEwW- z39V`!(JF`*7A{CEv{cQe~)qtfNXw)bF2ReU~F&zZ;=SJ8VzWg7BxnqF>E{b#PfoE z626N|xQ>p+J7S@^$q1uC567Vswvl0yJUREl<2}WY#SC3tEd-cKSpl9;;DhJEavYp) z52(@A<*d1@6IX!{ObrGUF-4$2A(=|z=Kg^cmX?%YYiAcrGgDv~2GRF-0ujDCAh**d)2} zAuD+Y0_a(>XRUo^Tm^#YZf`^4Gf9eyrBXaTKY^lXocaCeXl?mf!Jq%c!j`Gfb^ZeT z!?&LeN@Z&R0000Px%WJyFpR9Hu2WPk#VG_NQI#{cR685qnN7#P@L;#8)8GBPln zWcbfG^Tw=g%Rpu`f(W$?&mPAA48>Ho46kwj85w$S&DdVT2=e)V#{Wy{X*tX;#{Z1b zj2h{l2mdoL*bRsS7#I#Qs;7JYpkbU7^4Cw01O5|IMN9cn2MnGNNDL2SP*IR)sP61$ z0I}^X%!rEmLnlr#%wM?*L#>sGF+*%f5W~HPj~KeA&%|jKJOlzg-5G4nA(qZwx}4$q z?K@yLNZiTViedSNO$_SF$_%=i>L~K~TnW);`V_1kx1Rf-V>t>vW*A3lA8 zyP6mWkP>1rS3G<15**rK2Y`YrGQf{v-_c`Wm%@UNgB^dB05b&S08&B*IVj)`(9_an zFxJy$*tT~c!^_ui;0^*Q#uf!WF3t=Be0=aUgUtcNI1D8UU>bJpKgi(g>I@DM0X}}X z?<|ar;A~5--+l2#b<)m#;FI8S2ARGO~||bpWBLL2-blstQ9)P$0wUiL5Y~`C?Kllp@E{oQytU5b3_?A8Uh2~EFbWW$$)pwKuL8#JEyR` cGEm)f058^`$n!o}i~s-t07*qoM6N<$f~1l0YybcN literal 0 HcmV?d00001 diff --git a/web/src/assets/global.css b/web/src/assets/global.css new file mode 100644 index 0000000..cc5d96f --- /dev/null +++ b/web/src/assets/global.css @@ -0,0 +1,6 @@ +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; +.ant-table-wrapper.border-collapse table { + border-collapse: collapse; +} diff --git a/web/src/assets/logo-gh.png b/web/src/assets/logo-gh.png new file mode 100644 index 0000000000000000000000000000000000000000..3bd48d33563f7dd3b3a245046a3198182a1dcb8b GIT binary patch literal 13842 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 n6Qf5jm%RjD_~^4)Fan^Yk&q=U0O<11Us_6XYO + } + return this.props.children + } +} + +export default ErrorBoundary \ No newline at end of file diff --git a/web/src/components/ErrorPage.jsx b/web/src/components/ErrorPage.jsx new file mode 100644 index 0000000..b776876 --- /dev/null +++ b/web/src/components/ErrorPage.jsx @@ -0,0 +1,13 @@ +import { useRouteError } from 'react-router-dom' +import { Result } from 'antd' + +export default function ErrorPage() { + const errorResponse = useRouteError() + return ( + + ) +} diff --git a/web/src/config.js b/web/src/config.js new file mode 100644 index 0000000..f4dacc4 --- /dev/null +++ b/web/src/config.js @@ -0,0 +1,2 @@ +const __BUILD_VERSION__ = `__BUILD_VERSION__`.replace(/"/g, '') +export const BUILD_VERSION = import.meta.env.PROD ? __BUILD_VERSION__ : import.meta.env.MODE; diff --git a/web/src/hooks/useDatePresets.js b/web/src/hooks/useDatePresets.js new file mode 100644 index 0000000..779c244 --- /dev/null +++ b/web/src/hooks/useDatePresets.js @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import dayjs from "dayjs"; +import { useTranslation } from 'react-i18next'; +import i18n from '@/i18n'; + +export const useDatePresets = () => { + const [presets, setPresets] = useState([]); + const { t, i18n } = useTranslation(); + + useEffect(() => { + const newPresets = [ + { + label: t("datetime.thisWeek"), + value: [dayjs().startOf("w"), dayjs().endOf("w")], + }, + { + label: t("datetime.lastWeek"), + value: [dayjs().startOf("w").subtract(7, "days"), dayjs().endOf("w").subtract(7, "days")], + }, + { + label: t("datetime.thisMonth"), + value: [dayjs().startOf("M"), dayjs().endOf("M")], + }, + { + label: t("datetime.lastMonth"), + value: [dayjs().subtract(1, "M").startOf("M"), dayjs().subtract(1, "M").endOf("M")], + }, + { + label: t("datetime.lastThreeMonth"), + value: [dayjs().subtract(2, "M").startOf("M"), dayjs().endOf("M")], + }, + { + label: t("datetime.thisYear"), + value: [dayjs().startOf("y"), dayjs().endOf("y")], + }, + ]; + setPresets(newPresets); + }, [i18n.language]); + + return presets; +} + +export const useWeekdays = () => { + const [data, setData] = useState([]); + const { t, i18n } = useTranslation(); + useEffect(() => { + const newData = [ + { value: '1', label: t('weekdays.1') }, + { value: '2', label: t('weekdays.2') }, + { value: '3', label: t('weekdays.3') }, + { value: '4', label: t('weekdays.4') }, + { value: '5', label: t('weekdays.5') }, + { value: '6', label: t('weekdays.6') }, + { value: '7', label: t('weekdays.7') }, + ]; + setData(newData); + return () => {}; + }, [i18n.language]); + return data; +}; diff --git a/web/src/hooks/useHTLanguageSets.js b/web/src/hooks/useHTLanguageSets.js new file mode 100644 index 0000000..f01e46c --- /dev/null +++ b/web/src/hooks/useHTLanguageSets.js @@ -0,0 +1,20 @@ +export const useHTLanguageSets = () => { + const newData = [ + { key: '1', value: '1', label: 'English' }, + { key: '2', value: '2', label: 'Chinese (中文)' }, + { key: '3', value: '3', label: 'Japanese (日本語)' }, + { key: '4', value: '4', label: 'German (Deutsch)' }, + { key: '5', value: '5', label: 'French (Français)' }, + { key: '6', value: '6', label: 'Spanish (Español)' }, + { key: '7', value: '7', label: 'Russian (Русский)' }, + { key: '8', value: '8', label: 'Italian (Italiano)' }, + ]; + + return newData; +}; + +export const useHTLanguageSetsMapVal = () => { + const stateSets = useHTLanguageSets(); + const stateMapVal = stateSets.reduce((r, c) => ({ ...r, [`${c.value}`]: c }), {}); + return stateMapVal; +}; diff --git a/web/src/hooks/useProductsSets.js b/web/src/hooks/useProductsSets.js new file mode 100644 index 0000000..8ec4355 --- /dev/null +++ b/web/src/hooks/useProductsSets.js @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import useAuthStore from '@/stores/Auth'; +import { PERM_OVERSEA, PERM_AIR_TICKET, PERM_PRODUCTS_MANAGEMENT } from '@/config'; +import { isEmpty } from '@/utils/commons'; + +/** + * 产品管理 相关的预设数据 + * 项目类型 + * * 酒店预定 1 + * * 火车 2 + * * 飞机票务 3 + * * 游船 4 + * * 快巴 5 + * * 旅行社(综费) 6 + * * 景点 7 + * * 特殊项目 8 + * * 其他 9 + * * 酒店 A + * * 超公里 B + * * 餐费 C + * * 小包价 D // 包价线路 + * * 站 X + * * 购物 S + * * 餐 R (餐厅) + * * 娱乐 E + * * 精华线路 T + * * 客人testimonial F + * * 线路订单 O + * * 省 P + * * 信息 I + * * 国家 G + * * 城市 K + * * 图片 H + * * 地图 M + * * 包价线路 L (已废弃) + * * 节日节庆 V + * * 火车站 N + * * 手机租赁 Z + * * ---- webht 类型, 20240624 新增HT类型 ---- + * * 导游 Q + * * 车费 J + */ + +export const useProductsTypes = (showAll = false) => { + const [types, setTypes] = useState([]); + const { t, i18n } = useTranslation(); + + useEffect(() => { + const allItem = [{ label: t('All'), value: '', key: '' }]; + const newData = [ + { label: t('products:type.Experience'), value: '6', key: '6' }, + { label: t('products:type.UltraService'), value: 'B', key: 'B' }, + { label: t('products:type.Car'), value: 'J', key: 'J' }, + { label: t('products:type.Guide'), value: 'Q', key: 'Q' }, + { label: t('products:type.Attractions'), value: '7', key: '7' }, // landscape + { label: t('products:type.Meals'), value: 'R', key: 'R' }, + { label: t('products:type.Extras'), value: '8', key: '8' }, + { label: t('products:type.Package'), value: 'D', key: 'D' }, + ]; + const res = showAll ? [...allItem, ...newData] : newData; + setTypes(res); + }, [i18n.language]); + + return types; +}; +export const useProductsTypesMapVal = (value) => { + const stateSets = useProductsTypes(); + const stateMapVal = stateSets.reduce((r, c) => ({ ...r, [`${c.value}`]: c }), {}); + return stateMapVal; +}; + +export const useProductsAuditStates = () => { + const [types, setTypes] = useState([]); + const { t, i18n } = useTranslation(); + + useEffect(() => { + const newData = [ + { key: '-1', value: '-1', label: t('products:auditState.New'), color: 'muted' }, + { key: '0', value: '0', label: t('products:auditState.Pending'), color: '' }, + { key: '2', value: '2', label: t('products:auditState.Approved'), color: 'primary' }, + { key: '3', value: '3', label: t('products:auditState.Rejected'), color: 'danger' }, + { key: '1', value: '1', label: t('products:auditState.Published'), color: 'primary' }, + // ELSE 未知 + ]; + setTypes(newData); + }, [i18n.language]); + + return types; +}; + +export const useProductsAuditStatesMapVal = (value) => { + const stateSets = useProductsAuditStates(); + const stateMapVal = stateSets.reduce((r, c) => ({ ...r, [`${c.value}`]: c }), {}); + return stateMapVal; +}; + +/** + * @ignore + */ +export const useProductsTypesFieldsets = (type) => { + const [isPermitted] = useAuthStore((state) => [state.isPermitted]); + const infoDefault = [['city'], ['title']]; + const infoAdmin = ['title', 'product_title', 'code', 'remarks', 'dept']; // 'display_to_c' + const infoDisplay = isPermitted(PERM_PRODUCTS_MANAGEMENT) ? ['display_to_c'] : []; + const infoRecDisplay = isPermitted(PERM_PRODUCTS_MANAGEMENT) ? ['recommends_rate'] : []; + const infoTypesMap = { + '6': [[], []], + 'B': [['km'], []], + 'J': [[...infoRecDisplay, 'duration', ], ['description']], + 'Q': [[...infoRecDisplay, 'duration', ], ['description']], + 'D': [[...infoRecDisplay, 'duration', ...infoDisplay], ['description']], + '7': [[...infoRecDisplay, 'duration', 'open_weekdays'], ['description']], + 'R': [[], ['description']], + '8': [[], []], + }; + const thisTypeFieldset = (_type) => { + if (isEmpty(_type)) { + return infoDefault; + } + const adminSet = isPermitted(PERM_PRODUCTS_MANAGEMENT) ? infoAdmin : []; + return [ + [...infoDefault[0], ...infoTypesMap[_type][0], ...adminSet], + [...infoDefault[1], ...infoTypesMap[_type][1]], + ]; + }; + return thisTypeFieldset(type); +}; + +export const useNewProductRecord = () => { + return { + info: { + 'id': '', + 'htid': 0, + 'title': '', + 'code': '', + 'product_type_id': '', + 'product_type_name': '', + 'remarks': '', + 'duration': 0, + 'duration_unit': 'h', + 'open_weekdays': ['1', '2', '3', '4', '5', '6', '7'], + 'recommends_rate': 0, + 'dept_id': 0, + 'dept_name': '', + 'display_to_c': 0, + 'km': 0, + 'city_id': 0, + 'city_name': '', + 'open_hours': '', + 'lastedit_changed': '', + 'create_date': '', + 'created_by': '', + }, + lgc_details: [ + { + 'title': '', + 'descriptions': '', + 'lgc': 1, + 'id': '', + }, + ], + quotation: [ + { + 'id': '', + 'adult_cost': 0, + 'child_cost': 0, + 'currency': 'RMB', + 'unit_id': '1', + 'unit_name': '每团', + 'group_size_min': 1, + 'group_size_max': 2, + 'use_dates_start': '', + 'use_dates_end': '', + 'weekdays': '', + 'audit_state_id': -1, + 'audit_state_name': '', + 'lastedit_changed': '', + }, + ], + }; +}; diff --git a/web/src/hooks/usingStorage.js b/web/src/hooks/usingStorage.js new file mode 100644 index 0000000..1292d1a --- /dev/null +++ b/web/src/hooks/usingStorage.js @@ -0,0 +1,86 @@ +const persistObject = {} + +/** + * G-INT:USER_ID -> userId = 456 + * G-STR:LOGIN_TOKEN -> loginToken = 'E6779386E7D64DF0ADD0F97767E00D8B' + * G-JSON:LOGIN_USER -> loginUser = { username: 'test-username' } + */ +export function usingStorage() { + + const getStorage = () => { + if (import.meta.env.DEV && window.localStorage) { + return window.localStorage + } else if (window.sessionStorage) { + return window.sessionStorage + } else { + console.error('browser not support localStorage and sessionStorage.') + } + } + + const setProperty = (key, value) => { + const webStorage = getStorage() + const typeAndKey = key.split(':') + if (typeAndKey.length === 2) { + const propName = camelCasedWords(typeAndKey[1]) + persistObject[propName] = value + if (typeAndKey[0] === 'G-JSON') { + webStorage.setItem(key, JSON.stringify(value)) + } else { + webStorage.setItem(key, value) + } + } + } + + // USER_ID -> userId + const camelCasedWords = (string) => { + if (typeof string !== 'string' || string.length === 0) { + return string; + } + return string.split('_').map((word, index) => { + if (index === 0) { + return word.toLowerCase() + } else { + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + } + }).join('') + } + + if (Object.keys(persistObject).length == 0) { + + const webStorage = getStorage() + + for (let i = 0; i < webStorage.length; i++) { + const key = webStorage.key(i) + const typeAndKey = key.split(':') + + if (typeAndKey.length === 2) { + const value = webStorage.getItem(key) + const propName = camelCasedWords(typeAndKey[1]) + if (typeAndKey[0] === 'G-INT') { + persistObject[propName] = parseInt(value, 10) + } else if (typeAndKey[0] === 'G-JSON') { + try { + persistObject[propName] = JSON.parse(value) + } catch (e) { + // 如果解析失败,保留原始字符串值 + persistObject[propName] = value + console.error('解析 JSON 失败。') + } + } else { + persistObject[propName] = value + } + } + } + } + + return { + ...persistObject, + setStorage: (key, value) => { + setProperty(key, value) + }, + clearStorage: () => { + getStorage().clear() + Object.assign(persistObject, {}) + } + } +} diff --git a/web/src/main.jsx b/web/src/main.jsx new file mode 100644 index 0000000..123cee7 --- /dev/null +++ b/web/src/main.jsx @@ -0,0 +1,49 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { + createBrowserRouter, + RouterProvider, +} from 'react-router-dom' +import '@/assets/global.css' +import App from '@/views/App' +import HotelList from '@/views/hotel/List' +import HotelDetail from '@/views/hotel/Detail' +import ErrorPage from '@/components/ErrorPage' + +import { ThemeContext } from '@/stores/ThemeContext' +import { isNotEmpty } from '@/utils/commons' + +const { createRoot } = ReactDOM + +const initRouter = async () => { + return createBrowserRouter([ + { + path: '/', + element: , + errorElement: , + children: [ + { index: true, element: }, + { path: 'hotel/list', element: }, + { path: 'hotel/:hotelId/:checkin/:checkout', element: }, + ] + } + ]) +} + +const initAppliction = async () => { + + const router = await initRouter() + + createRoot(document.getElementById('root')).render( + // + +
Loading...
} + /> +
+ //
+ ) +} + +initAppliction() diff --git a/web/src/pageSpy/index.jsx b/web/src/pageSpy/index.jsx new file mode 100644 index 0000000..af67b26 --- /dev/null +++ b/web/src/pageSpy/index.jsx @@ -0,0 +1,41 @@ +import { loadScript } from '@/utils/commons'; +import { PROJECT_NAME } from '@/config'; + +export const loadPageSpy = (title) => { + + if (import.meta.env.DEV || window.$pageSpy) return + + const PageSpySrc = [ + 'https://page-spy.mycht.cn/page-spy/index.min.js', + 'https://page-spy.mycht.cn/plugin/data-harbor/index.min.js', + 'https://page-spy.mycht.cn/plugin/rrweb/index.min.js', + ]; + + Promise.all(PageSpySrc.map((src) => loadScript(src))).then(() => { + // 注册插件 + PageSpy.registerPlugin(new DataHarborPlugin({ maximum: 2 * 1024 * 1024 })); + // 实例化 PageSpy + window.$pageSpy = new PageSpy({ api: 'page-spy.mycht.cn', project: PROJECT_NAME, title: title, autoRender: false }); + }); +}; + +export const uploadPageSpyLog = () => { + window.$pageSpy.triggerPlugins('onOfflineLog', 'upload'); +} + +export const PageSpyLog = () => { + return ( + <> + {window.$pageSpy && ( + { + window.$pageSpy.triggerPlugins('onOfflineLog', 'download'); + window.$pageSpy.triggerPlugins('onOfflineLog', 'upload'); + }}> + 上传Debug日志 ({window.$pageSpy.address.substring(0, 4)}) + + )} + + ); +}; diff --git a/web/src/stores/Hotel.js b/web/src/stores/Hotel.js new file mode 100644 index 0000000..e08b3e2 --- /dev/null +++ b/web/src/stores/Hotel.js @@ -0,0 +1,66 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' +import { fetchJSON, postForm } from '@/utils/request' +import { usingStorage } from '@/hooks/usingStorage' + +export const fetchHotelList = async (hotelName, checkinDateString, checkoutDateString) => { + const { errcode, data } = await fetchJSON( + 'http://202.103.68.93:3002/search_hotel', + { keyword: hotelName, checkin: checkinDateString, checkout: checkoutDateString } + ) + return errcode !== 0 ? {} : data +} + +export const fetchAvailability = async (hotelId, checkinDateString, checkoutDateString) => { + const { errcode, data } = await fetchJSON( + 'http://202.103.68.93:3002/availability', + { hotel_id: hotelId, checkin: checkinDateString, checkout: checkoutDateString } + ) + return errcode !== 0 ? {} : data +} + + +const useHotelStore = create(devtools((set, get) => ({ + + selectedHotel: null, + hotelList: [], + roomList: [], + + + selectHotel: (hotel) => { + set(() => ({ + selectedHotel: hotel + })) + }, + + searchByCriteria: async(formValues) => { + const resultArray = await fetchHotelList( + formValues.hotelName, + formValues.dataRange[0].format('YYYY-MM-DD'), + formValues.dataRange[1].format('YYYY-MM-DD') + ) + + console.info(resultArray) + + set(() => ({ + hotelList: resultArray + })) + }, + + getRoomListByHotel: async(hotelId, checkin, checkout) => { + const resultArray = await fetchAvailability( + hotelId, + checkin, + checkout + ) + + console.info(resultArray) + + set(() => ({ + roomList: resultArray + })) + }, + +}), { name: 'hotelStore' })) + +export default useHotelStore diff --git a/web/src/stores/ThemeContext.js b/web/src/stores/ThemeContext.js new file mode 100644 index 0000000..0fd61a9 --- /dev/null +++ b/web/src/stores/ThemeContext.js @@ -0,0 +1,7 @@ +import { createContext, useContext } from 'react' + +export const ThemeContext = createContext({}) + +export function useThemeContext() { + return useContext(ThemeContext) +} \ No newline at end of file diff --git a/web/src/utils/commons.js b/web/src/utils/commons.js new file mode 100644 index 0000000..0e93cc5 --- /dev/null +++ b/web/src/utils/commons.js @@ -0,0 +1,629 @@ +export function copy(obj) { + return JSON.parse(JSON.stringify(obj)); +} + +export function formatDate(date) { + if (isEmpty(date)) { + return "NaN"; + } + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const monthStr = ("" + month).padStart(2, 0); + const dayStr = ("" + day).padStart(2, 0); + const formatted = year + "-" + monthStr + "-" + dayStr; + + return formatted; +} + +export function formatTime(date) { + const hours = date.getHours(); + const minutes = date.getMinutes(); + + const hoursStr = ("" + hours).padStart(2, 0); + const minutesStr = ("" + minutes).padStart(2, 0); + const formatted = hoursStr + ":" + minutesStr; + + return formatted; +} + +export function formatDatetime(date) { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const monthStr = ("" + month).padStart(2, 0); + const dayStr = ("" + day).padStart(2, 0); + + const hours = date.getHours(); + const minutes = date.getMinutes(); + + const hoursStr = ("" + hours).padStart(2, 0); + const minutesStr = ("" + minutes).padStart(2, 0); + + const formatted = year + "-" + monthStr + "-" + dayStr + " " + hoursStr + ":" + minutesStr; + + return formatted; +} + +export function camelCase(name) { + return name.substr(0, 1).toLowerCase() + name.substr(1); +} + +export class UrlBuilder { + constructor(url) { + this.url = url; + this.paramList = []; + } + + append(name, value) { + if (isNotEmpty(value)) { + this.paramList.push({ name: name, value: value }); + } + return this; + } + + build() { + this.paramList.forEach((e, i, a) => { + if (i === 0) { + this.url += "?"; + } else { + this.url += "&"; + } + this.url += e.name + "=" + e.value; + }); + return this.url; + } +} + +export function isNotEmpty(val) { + return val !== undefined && val !== null && val !== ""; +} + +export function prepareUrl(url) { + return new UrlBuilder(url); +} + +export function throttle(fn, delay, atleast) { + let timeout = null, + startTime = new Date(); + return function () { + let curTime = new Date(); + clearTimeout(timeout); + if (curTime - startTime >= atleast) { + fn(); + startTime = curTime; + } else { + timeout = setTimeout(fn, delay); + } + }; +} + +export function clickUrl(url) { + const httpLink = document.createElement("a"); + httpLink.href = url; + httpLink.target = "_blank"; + httpLink.click(); +} + +export function escape2Html(str) { + var temp = document.createElement("div"); + temp.innerHTML = str; + var output = temp.innerText || temp.textContent; + temp = null; + return output; +} + +export function formatPrice(price) { + return Math.ceil(price).toLocaleString(); +} + +export function formatPercent(number) { + return Math.round(number * 100) + "%"; +} + +/** + * ! 不支持计算 Set 或 Map + * @param {*} val + * @example + * true if: 0, [], {}, null, '', undefined + * false if: 'false', 'undefined' + */ +export function isEmpty(val) { + // return val === undefined || val === null || val === ""; + return [Object, Array].includes((val || {}).constructor) && !Object.entries(val || {}).length; +} +/** + * 数组排序 + */ +export const sortBy = key => { + return (a, b) => (getNestedValue(a, key) > getNestedValue(b, key) ? 1 : getNestedValue(b, key) > getNestedValue(a, key) ? -1 : 0); +}; + +/** + * Object排序keys + */ +export const sortKeys = obj => + Object.keys(obj) + .sort() + .reduce((a, k2) => ({ ...a, [k2]: obj[k2] }), {}); + +/** + * 数组排序, 给定排序数组 + * @param {array} items 需要排序的数组 + * @param {array} keyName 排序的key + * @param {array} keyOrder 给定排序 + * @returns + */ +export const sortArrayByOrder = (items, keyName, keyOrder) => { + return items.sort((a, b) => { + return keyOrder.indexOf(a[keyName]) - keyOrder.indexOf(b[keyName]); + }); +}; +/** + * 合并Object, 递归地 + */ +export function merge(...objects) { + const isDeep = objects.some(obj => obj !== null && typeof obj === "object"); + + const result = objects[0] || (isDeep ? {} : objects[0]); + + for (let i = 1; i < objects.length; i++) { + const obj = objects[i]; + + if (!obj) continue; + + Object.keys(obj).forEach(key => { + const val = obj[key]; + + if (isDeep) { + if (Array.isArray(val)) { + result[key] = [].concat(Array.isArray(result[key]) ? result[key] : [result[key]], val); + } else if (typeof val === "object") { + result[key] = merge(result[key], val); + } else { + result[key] = val; + } + } else { + result[key] = typeof val === "boolean" ? val : result[key]; + } + }); + } + + return result; +} + +/** + * 数组分组 + * - 相当于 lodash 的 _.groupBy + * @see https://www.lodashjs.com/docs/lodash.groupBy#_groupbycollection-iteratee_identity + */ +export function groupBy(array = [], callback) { + return array.reduce((groups, item) => { + const key = typeof callback === "function" ? callback(item) : item[callback]; + + if (!groups[key]) { + groups[key] = []; + } + + groups[key].push(item); + return groups; + }, {}); +} + +/** + * 创建一个从 object 中选中的属性的对象。 + * @param {*} object + * @param {array} keys + */ +export function pick(object, keys) { + return keys.reduce((obj, key) => { + if (object && Object.prototype.hasOwnProperty.call(object, key)) { + obj[key] = object[key]; + } + return obj; + }, {}); +} + +/** + * 返回对象的副本,经过筛选以省略指定的键。 + * @param {*} object + * @param {string[]} keysToOmit + * @returns + */ +export function omit(object, keysToOmit) { + return Object.fromEntries(Object.entries(object).filter(([key]) => !keysToOmit.includes(key))); +} + +/** + * 深拷贝 + */ +export function cloneDeep(value, visited = new WeakMap()) { + // 处理循环引用 + if (visited.has(value)) { + return visited.get(value); + } + + // 特殊对象和基本类型处理 + if (value instanceof Date) { + return new Date(value); + } + if (value instanceof RegExp) { + return new RegExp(value.source, value.flags); + } + if (value === null || typeof value !== 'object') { + return value; + } + + // 创建一个新的WeakMap项以避免内存泄漏 + let result; + if (Array.isArray(value)) { + result = []; + visited.set(value, result); + } else { + result = {}; + visited.set(value, result); + } + + for (const key of Object.getOwnPropertySymbols(value)) { + // 处理Symbol属性 + result[key] = cloneDeep(value[key], visited); + } + + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + // 处理普通属性 + result[key] = cloneDeep(value[key], visited); + } + } + + return result; +} +/** + * 向零四舍五入, 固定精度设置 + */ +function curriedFix(precision = 0) { + return function (number) { + // Shift number by precision places + const shift = Math.pow(10, precision); + const shiftedNumber = number * shift; + + // Round to nearest integer + const roundedNumber = Math.round(shiftedNumber); + + // Shift back decimal place + return roundedNumber / shift; + }; +} +/** + * 向零四舍五入, 保留2位小数 + */ +export const fixTo2Decimals = curriedFix(2); +/** + * 向零四舍五入, 保留4位小数 + */ +export const fixTo4Decimals = curriedFix(4); + +export const fixTo1Decimals = curriedFix(1); +export const fixToInt = curriedFix(0); + +/** + * 映射 + * @example + * const keyMap = { + a: [{key: 'a1'}, {key: 'a2', transform: v => v * 2}], + b: {key: 'b1'} + }; + const result = objectMapper({a: 1, b: 3}, keyMap); + // result = {a1: 1, a2: 2, b1: 3} + * + */ +export function objectMapper(input, keyMap) { + // Loop through array mapping + if (Array.isArray(input)) { + return input.map(obj => objectMapper(obj, keyMap)); + } + + if (typeof input === "object") { + const mappedObj = {}; + + Object.keys(input).forEach(key => { + // Keep original keys not in keyMap + if (!keyMap[key]) { + mappedObj[key] = input[key]; + } + // Handle array of maps + if (Array.isArray(keyMap[key])) { + keyMap[key].forEach(map => { + let value = input[key]; + if (map.transform) value = map.transform(value); + mappedObj[map.key] = value; + }); + + // Handle single map + } else { + const map = keyMap[key]; + if (map) { + let value = input[key]; + if (map.transform) value = map.transform(value); + mappedObj[map.key || map] = value; + } + } + }); + + return mappedObj; + } + + return input; +} + +/** + * 创建一个对应于对象路径的值数组 + */ +export function at(obj, path) { + let result; + if (Array.isArray(obj)) { + // array case + const indexes = path.split(".").map(i => parseInt(i)); + result = []; + for (let i = 0; i < indexes.length; i++) { + result.push(obj[indexes[i]]); + } + } else { + // object case + const indexes = path.split(".").map(i => i); + result = [obj]; + for (let i = 0; i < indexes.length; i++) { + result = [result[0]?.[indexes[i]] || undefined]; + } + } + return result; +} +/** + * 删除 null/undefined + */ +export function flush(collection) { + let result, len, i; + if (!collection) { + return undefined; + } + if (Array.isArray(collection)) { + result = []; + len = collection.length; + for (i = 0; i < len; i++) { + const elem = collection[i]; + if (elem != null) { + result.push(elem); + } + } + return result; + } + if (typeof collection === "object") { + result = {}; + const keys = Object.keys(collection); + len = keys.length; + for (i = 0; i < len; i++) { + const key = keys[i]; + const value = collection[key]; + if (value != null) { + result[key] = value; + } + } + return result; + } + return undefined; +} + +/** + * 千分位 格式化数字 + */ +export const numberFormatter = number => { + return new Intl.NumberFormat().format(number); +}; + +/** + * @example + * const obj = { a: { b: 'c' } }; + * const keyArr = ['a', 'b']; + * getNestedValue(obj, keyArr); // Returns: 'c' + */ +export const getNestedValue = (obj, keyArr) => { + return keyArr.reduce((acc, curr) => { + return acc && Object.prototype.hasOwnProperty.call(acc, curr) ? acc[curr] : undefined; + // return acc && acc[curr]; + }, obj); +}; + +/** + * 计算笛卡尔积 + */ +export const cartesianProductArray = (arr, sep = "_", index = 0, prefix = "") => { + let result = []; + if (index === arr.length) { + return [prefix]; + } + arr[index].forEach(item => { + result = result.concat(cartesianProductArray(arr, sep, index + 1, prefix ? `${prefix}${sep}${item}` : `${item}`)); + }); + return result; +}; + +export const stringToColour = str => { + var hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + var colour = "#"; + for (let i = 0; i < 3; i++) { + var value = (hash >> (i * 8)) & 0xff; + value = (value % 150) + 50; + colour += ("00" + value.toString(16)).substr(-2); + } + return colour; +}; + +export const debounce = (func, wait, immediate) => { + var timeout; + return function () { + var context = this, + args = arguments; + clearTimeout(timeout); + if (immediate && !timeout) func.apply(context, args); + timeout = setTimeout(function () { + timeout = null; + if (!immediate) func.apply(context, args); + }, wait); + }; +}; + +export const removeFormattingChars = str => { + const regex = /[\r\n\t\v\f]/g; + str = str.replace(regex, " "); + // Replace more than four consecutive spaces with a single space + str = str.replace(/\s{4,}/g, " "); + return str; +}; + +export const olog = (text, ...args) => { + console.log(`%c ${text} `, "background:#fb923c ; padding: 1px; border-radius: 3px; color: #fff", ...args); +}; + +export const sanitizeFilename = str => { + // Remove whitespace and replace with hyphens + str = str.replace(/\s+/g, "-"); + // Remove invalid characters and replace with hyphens + str = str.replace(/[^a-zA-Z0-9.-]/g, "-"); + // Replace consecutive hyphens with a single hyphen + str = str.replace(/-+/g, "-"); + // Trim leading and trailing hyphens + str = str.replace(/^-+|-+$/g, ""); + return str; +}; + +export const formatBytes = (bytes, decimals = 2) => { + if (bytes === 0) return ""; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; +}; +export const calcCacheSizes = async () => { + try { + let swCacheSize = 0; + let diskCacheSize = 0; + let indexedDBSize = 0; + + // 1. Get the service worker cache size + if ("caches" in window) { + const cacheNames = await caches.keys(); + for (const name of cacheNames) { + const cache = await caches.open(name); + const requests = await cache.keys(); + for (const request of requests) { + const response = await cache.match(request); + swCacheSize += Number(response.headers.get("Content-Length")) || 0; + } + } + } + + // 2. Get the disk cache size + // const diskCacheName = 'disk-cache'; + // const diskCache = await caches.open(diskCacheName); + // const diskCacheKeys = await diskCache.keys(); + // for (const request of diskCacheKeys) { + // const response = await diskCache.match(request); + // diskCacheSize += Number(response.headers.get('Content-Length')) || 0; + // } + + // 3. Get the IndexedDB cache size + // const indexedDBNames = await window.indexedDB.databases(); + // for (const dbName of indexedDBNames) { + // const db = await window.indexedDB.open(dbName.name); + // const objectStoreNames = db.objectStoreNames; + + // if (objectStoreNames !== undefined) { + // const objectStores = Array.from(objectStoreNames).map((storeName) => db.transaction([storeName], 'readonly').objectStore(storeName)); + + // for (const objectStore of objectStores) { + // const request = objectStore.count(); + // request.onsuccess = () => { + // indexedDBSize += request.result; + // }; + // } + // } + // } + + return { swCacheSize, diskCacheSize, indexedDBSize, totalSize: Number(swCacheSize) + Number(diskCacheSize) + indexedDBSize }; + } catch (error) { + console.error("Error getting cache sizes:", error); + } +}; + +export const clearAllCaches = async cb => { + try { + // 1. Clear the service worker cache + if ("caches" in window) { + // if (navigator.serviceWorker) { + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map(name => caches.delete(name))); + } + + // 2. Clear the disk cache (HTTP cache) + // const diskCacheName = 'disk-cache'; + // await window.caches.delete(diskCacheName); + // const diskCache = await window.caches.open(diskCacheName); + // const diskCacheKeys = await diskCache.keys(); + // await Promise.all(diskCacheKeys.map((request) => diskCache.delete(request))); + + // 3. Clear the IndexedDB cache + const indexedDBNames = await window.indexedDB.databases(); + await Promise.all(indexedDBNames.map(dbName => window.indexedDB.deleteDatabase(dbName.name))); + + // Unregister the service worker + const registration = await navigator.serviceWorker.getRegistration(); + if (registration) { + await registration.unregister(); + console.log("Service worker unregistered"); + } else { + console.log("No service worker registered"); + } + if (typeof cb === "function") { + cb(); + } + } catch (error) { + console.error("Error clearing caches or unregistering service worker:", error); + } +}; + +export const loadScript = src => { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.type = "text/javascript"; + script.onload = resolve; + script.onerror = reject; + script.crossOrigin = "anonymous"; + script.src = src; + if (document.head.append) { + document.head.append(script); + } else { + document.getElementsByTagName("head")[0].appendChild(script); + } + }); +}; + +//格式化为冒号时间,2010转为20:10 +export const formatColonTime = text => { + const hours = text.substring(0, 2); + const minutes = text.substring(2); + return `${hours}:${minutes}`; +}; + +// 生成唯一 36 位数字,用于新增记录 ID 赋值,React key 属性等 +export const generateId = () => ( + new Date().getTime().toString(36) + Math.random().toString(36).substring(2, 9) +) diff --git a/web/src/utils/request.js b/web/src/utils/request.js new file mode 100644 index 0000000..b99451d --- /dev/null +++ b/web/src/utils/request.js @@ -0,0 +1,154 @@ + +import { BUILD_VERSION } from '@/config' + +const customHeaders = [] + +// 添加 HTTP Reuqest 自定义头部 +export function appendRequestHeader(n, v) { + customHeaders.push({ + name: n, + value: v + }) +} + +function getRequestHeader() { + return customHeaders.reduce((acc, item) => { + acc[item.name] = item.value; + return acc; + }, {}); +} + +const initParams = []; +export function appendRequestParams(n, v) { + initParams.push({ + name: n, + value: v + }) +} +function getRequestInitParams() { + return initParams.reduce((acc, item) => { + acc[item.name] = item.value; + return acc; + }, {}); +} + +function checkStatus(response) { + if (response.status >= 200 && response.status < 300) { + return response + } else { + const message = + 'Fetch error: ' + response.url + ' ' + response.status + ' (' + + response.statusText + ')' + const error = new Error(message) + error.response = response + throw error + } +} + +function checkBizCode(responseJson) { + if (responseJson.errcode === 0) { + return responseJson; + } else { + throw new Error(responseJson.errmsg + ': ' + responseJson.errcode); + } +} + +export function fetchText(url) { + const headerObj = getRequestHeader() + return fetch(url, { + method: 'GET', + headers: { + 'X-Web-Version': BUILD_VERSION, + ...headerObj + } + }).then(checkStatus) + .then(response => response.text()) + .catch(error => { + throw error + }) +} + +export function fetchJSON(url, data = {}) { + const initParams = getRequestInitParams(); + const params4get = Object.assign({}, initParams, data); + const params = params4get ? new URLSearchParams(params4get).toString() : ''; + const ifp = url.includes('?') ? '&' : '?'; + const headerObj = getRequestHeader(); + const fUrl = params !== '' ? `${url}${ifp}${params}` : url; + return fetch(fUrl, { + method: 'GET', + headers: { + 'X-Web-Version': BUILD_VERSION, + ...headerObj + } + }).then(checkStatus) + .then(response => response.json()) + .then(checkBizCode) + .catch(error => { + throw error; + }); +} + +export function postForm(url, data) { + const initParams = getRequestInitParams(); + Object.keys(initParams).forEach(key => { + if (! data.has(key)) { + data.append(key, initParams[key]); + } + }); + const headerObj = getRequestHeader() + return fetch(url, { + method: 'POST', + body: data, + headers: { + 'X-Web-Version': BUILD_VERSION, + ...headerObj + } + }).then(checkStatus) + .then(response => response.json()) + .then(checkBizCode) + .catch(error => { + throw error + }) +} + +export function postJSON(url, obj) { + const initParams = getRequestInitParams(); + const params4get = Object.assign({}, initParams); + const params = new URLSearchParams(params4get).toString(); + const ifp = url.includes('?') ? '&' : '?'; + const fUrl = params !== '' ? `${url}${ifp}${params}` : url; + + const headerObj = getRequestHeader() + return fetch(fUrl, { + method: 'POST', + body: JSON.stringify(obj), + headers: { + 'Content-type': 'application/json; charset=UTF-8', + 'X-Web-Version': BUILD_VERSION, + ...headerObj + } + }).then(checkStatus) + .then(response => response.json()) + .then(checkBizCode) + .catch(error => { + throw error + }) +} + +export function postStream(url, obj) { + const headerObj = getRequestHeader() + return fetch(url, { + method: 'POST', + body: JSON.stringify(obj), + headers: { + 'Content-type': 'application/octet-stream', + 'X-Web-Version': BUILD_VERSION, + ...headerObj + } + }).then(checkStatus) + .then(response => response.json()) + .catch(error => { + throw error + }) +} diff --git a/web/src/views/App.jsx b/web/src/views/App.jsx new file mode 100644 index 0000000..c2ebf3c --- /dev/null +++ b/web/src/views/App.jsx @@ -0,0 +1,61 @@ +import { Outlet, Link, NavLink } from 'react-router-dom'; +import { Layout, Menu, ConfigProvider, theme, Row, Col, Typography, Flex, App as AntApp } from 'antd'; +import 'antd/dist/reset.css'; +import AppLogo from '@/assets/logo-gh.png'; +import 'dayjs/locale/zh-cn'; +import ErrorBoundary from '@/components/ErrorBoundary'; +import { BUILD_VERSION, } from '@/config'; +import { useThemeContext } from '@/stores/ThemeContext'; + + +const { Header, Content, Footer } = Layout; +const { Title } = Typography; + +function App() { + + const { colorPrimary } = useThemeContext() + + console.info('theme: ', theme) + + return ( + + + + +
+ + + + App logo + + 喜玩酒店 } + ]} + /> + + +
+ +
+ +
+
+
China Highlights International Travel Service Co., LTD, Version: {BUILD_VERSION}
+
+
+
+
+ ) +} + +export default App diff --git a/web/src/views/hotel/Detail.jsx b/web/src/views/hotel/Detail.jsx new file mode 100644 index 0000000..c553af0 --- /dev/null +++ b/web/src/views/hotel/Detail.jsx @@ -0,0 +1,85 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { Row, Col, Modal, Space, Form, Typography, DatePicker, Input, Button, App } from 'antd' +import useHotelStore from '@/stores/Hotel' +import { HotelList, RoomList } from "./HotelComponents"; +import { ExclamationCircleFilled } from '@ant-design/icons' +const { Title } = Typography + +function Detail() { + + const { hotelId, checkin, checkout } = useParams() + + const [loading, setLoading] = useState(false) + const [hotelQuotation, setHotelQuotation] = useState({ + hotelName: '', + roomName: '', + price: '' + }) + + const navigate = useNavigate() + const { notification, modal } = App.useApp() + const [selectedHotel, getRoomListByHotel, roomList] = + useHotelStore(state => + [state.selectedHotel, state.getRoomListByHotel, state.roomList]) + + useEffect (() => { + + + setLoading(true) + getRoomListByHotel(hotelId, checkin, checkout) + .finally(() => setLoading(false)) + }, []) + + const handleRoomChange = (room, plan) => { + console.info('room: ', room) + console.info('plan: ', plan) + console.info('hotel: ', selectedHotel) + const forHtJson = { + hotelName: selectedHotel.hotel_name, + roomName: room.RoomName, + price: plan.Price + } + setHotelQuotation(forHtJson) + + showModal() + + console.info('stringify: ' + JSON.stringify(forHtJson)) + document.getElementById('forHtJson').value = JSON.stringify(forHtJson) + } + + const [isModalOpen, setIsModalOpen] = useState(false); + const showModal = () => { + setIsModalOpen(true); + }; + const handleOk = () => { + setIsModalOpen(false); + }; + const handleCancel = () => { + setIsModalOpen(false); + } + + return ( + <> + + +

酒店:{hotelQuotation.hotelName}

+

房型:{hotelQuotation.roomName}

+

价格:{hotelQuotation.price}

+
+ + + + {selectedHotel.hotel_name} + + + + + + + {handleRoomChange(room, plan)}} dataSource={roomList}> + + ); +} + +export default Detail; diff --git a/web/src/views/hotel/HotelComponents.jsx b/web/src/views/hotel/HotelComponents.jsx new file mode 100644 index 0000000..de40506 --- /dev/null +++ b/web/src/views/hotel/HotelComponents.jsx @@ -0,0 +1,144 @@ +import { createContext, useContext, useState } from 'react' +import { Flex, Button, Image, Typography, Empty, Skeleton, Row, Col } from 'antd' + +const HotelContext = createContext() + +export function HotelList({ dataSource, loading, onChange }) { + + if (dataSource && dataSource.length > 0) { + const itemList = dataSource.map((data, index) => { + return ( + <> + + + + + + {data.hotel_name} + + {data.address} + {data.base_price.Price??0} 起 + + + + + + + ) + }) + + return ( + + + {itemList} + + + ) + } else { + return + } +} + +export function RoomList({ dataSource, loading, onChange }) { + + const triggerChange = (changedValue) => { + onChange?.( + changedValue + ) + } + + if (dataSource && dataSource.length > 0) { + const itemList = dataSource.map((room, index) => { + let roomImage = + if (room?.Images && room?.Images.length > 0) { + roomImage = + } + + return ( + +
+ + {room.RoomName}, {room.BedTypeDesc} + + + + + {roomImage} + 大小: {room.Area??0}m² + + + + { + room.RatePlans.map((plan, index) => { + return ( + + ) + }) + } + + +
+
+ ) + }) + + return ( + + + {itemList} + + + ) + } else { + return + } +} + +const getDeductDesc = (type) => { + let desc = '未知' + if (type === 1) desc = '扣首日' + else if (type === 2) desc = '扣全额' + else if (type === 3) desc = '按价格多少百分比扣' + else if (type === 4) desc = '免费取消' + else if (type === 5) desc = '扣几晚' + else if (type === 6) desc = '扣多少钱' + + return desc +} + +const getMealDesc = (plan) => { + const type = plan.MealType + let desc = '未知' + if (type === 1) desc = '早' + plan.Breakfast + '中' + plan.Lunch + '晚' + plan.Dinner + else if (type === 2) desc = '半包' + else if (type === 3) desc = '全包' + else if (type === 4) desc = '午/晚二选一' + else if (type === 5) desc = '早+午/晚二选一' + + return desc +} + +const PlanItem = ({room, plan}) => { + const { triggerChange } = useContext(HotelContext) + return ( +
+ + 餐: {getMealDesc(plan)} + 取消: {plan.Cancelable ? plan.CancelRules.map(r => getDeductDesc(r.DeductType)).join(',') : '不'} + + {plan.Price} + {plan.Currency} + + + +
+ ) +} diff --git a/web/src/views/hotel/List.jsx b/web/src/views/hotel/List.jsx new file mode 100644 index 0000000..8048dd5 --- /dev/null +++ b/web/src/views/hotel/List.jsx @@ -0,0 +1,106 @@ +import { useState, useEffect } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { Row, Col, Modal, Space, Form, Typography, DatePicker, Input, Button, App } from 'antd' +import useHotelStore from '@/stores/Hotel' +import dayjs from 'dayjs' +import { HotelList, RoomList } from "./HotelComponents"; +import { isEmpty } from '@/utils/commons' +const { Title } = Typography + +const List = () => { + + const [loading, setLoading] = useState(false) + const [searchForm] = Form.useForm() + const [searchByCriteria, hotelList, selectHotel] = + useHotelStore(state => + [state.searchByCriteria, state.hotelList, state.selectHotel]) + + const { notification } = App.useApp() + + const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + + const hotelName = searchParams.get('hotel') + const checkinDateString = searchParams.get('checkin') + const checkoutDateString = searchParams.get('checkout') + + useEffect(() => { + // http://localhost:5175/hotel/list?hotel=s&checkin=2024-8-20&checkout=2024-8-21 + if (isEmpty(hotelName) || isEmpty(checkinDateString) || isEmpty(checkoutDateString)) { + console.info('criteria is null') + } else { + searchForm.setFieldsValue({ + dataRange: [dayjs(checkinDateString), dayjs(checkoutDateString)], + hotelName: hotelName + }) + setLoading(true) + searchByCriteria(searchForm.getFieldValue()) + .finally(() => setLoading(false)) + } + }, []) + + const onSearchFinish = () => { + const formValue = searchForm.getFieldValue() + setSearchParams({ + hotel: formValue.hotelName, + checkin: formValue.dataRange[0].format('YYYY-MM-DD'), + checkout: formValue.dataRange[1].format('YYYY-MM-DD') + }) + + setLoading(true) + searchByCriteria(formValue) + .finally(() => setLoading(false)) + } + + const handleHotelChange = (hotel) => { + selectHotel(hotel) + navigate(`/hotel/${hotel.hotel_id}/${searchForm.getFieldValue().dataRange[0].format('YYYY-MM-DD')}/${searchForm.getFieldValue().dataRange[1].format('YYYY-MM-DD')}`) + } + + return ( + <> + + 酒店列表 +
console.info('onFinishFailed')} + autoComplete='off' + > + + + + + + + + + +
+ handleHotelChange(h)}> +
+ + ) +} + +export default List diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 0000000..14d963b --- /dev/null +++ b/web/tailwind.config.js @@ -0,0 +1,26 @@ +/** @type {import('tailwindcss').Config} */ +import colors from 'tailwindcss/colors' +export default { + content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], + safelist: [ + 'text-primary', + 'text-danger', + 'text-muted', + ], + darkMode: 'media', + theme: { + colors: { + ...colors, + 'primary': '#00b96b', + 'danger': '#ef4444', + 'muted': '#6b7280', + }, + extend: {}, + }, + plugins: [], + corePlugins: { + preflight: false, + divideColor: true, + }, +} + diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..67698ee --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,44 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import legacy from '@vitejs/plugin-legacy' +import WindiCSS from 'vite-plugin-windicss' +import packageJson from './package.json' +import dayjs from 'dayjs' + +const today = new dayjs().format('YYYY-MM-DD HH:mm:ss') + +// https://vitejs.dev/config/ +export default defineConfig({ + define: { + __BUILD_DATE__: JSON.stringify(`${today}`), + __BUILD_VERSION__: JSON.stringify(`${packageJson.version}`), + }, + plugins: [ + react(), WindiCSS(), + legacy({ + targets: ['defaults', 'not IE 11'], + }), + ], + server: { + host: '0.0.0.0', + port: '5175' + }, + resolve: { + alias: { + '@': '/src', + }, + }, + build: { + emptyOutDir: true, + chunkSizeWarningLimit: 555, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules')) { + return 'vendor' + } + }, + } + } + }, +})