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 0000000..d02e074 Binary files /dev/null and b/web/public/favicon-180x180.png differ diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000..cace78c Binary files /dev/null and b/web/public/favicon.ico differ 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 0000000..3bd48d3 Binary files /dev/null and b/web/src/assets/logo-gh.png differ diff --git a/web/src/components/ErrorBoundary.jsx b/web/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..a5e17e1 --- /dev/null +++ b/web/src/components/ErrorBoundary.jsx @@ -0,0 +1,33 @@ +import React, { PureComponent } from 'react' +import { Result } from 'antd' + +// 参考文档: +// https://zh-hans.react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary +// https://juejin.cn/post/7168720873006825503 +// https://github.com/bvaughn/react-error-boundary/tree/master + +class ErrorBoundary extends PureComponent { + constructor(props) { + super(props); + this.state = { hasError: false, info: '' } + } + + componentDidCatch(error, info) { + console.error('Sorry, Something went wrong.') + console.error(error) + this.setState({ hasError: true, info: error.message }) + } + + render() { + if (this.state.hasError) { + return + } + 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' + } + }, + } + } + }, +})