初始化导入 Web 项目

main
Jimmy Liow 10 months ago
parent 4c14aadbb2
commit 968078c6db

@ -1,2 +0,0 @@
# heytripgo.mycht.cn
喜玩酒店接口

@ -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',
},
};

26
web/.gitignore vendored

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

@ -0,0 +1,26 @@
# 喜玩酒店分销
## 开发设置
1. 安装组件npm install
2. 运行开发环境npm run dev 或者 dev.bat
3. 打包代码npm run build 或者 build.bat
## 版本设置
npm version [<newversion> | 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

@ -0,0 +1 @@
npm run build

@ -0,0 +1 @@
npm run dev

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>喜玩酒店查询</title>
<style>
.loading{width:150px;height:8px;border-radius:4px;margin:0 auto;margin-top:200px;position:relative;background:#777;overflow:hidden}
.loading span{display:block;width:100%;height:100%;border-radius:3px;background:#00b96b;animation:changePosition 4s linear infinite}
@keyframes changePosition{
0%{-webkit-transform:translate(-150px)}
50%{-webkit-transform:translate(0)}
100%{-webkit-transform:translate(150px)}
}
</style>
</head>
<body>
<div id="root">
<div class="loading">
<span></span>
</div>
</div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

@ -0,0 +1,9 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
}
}

@ -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"
}
}

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

@ -0,0 +1 @@
npm version prerelease

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -0,0 +1,6 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
.ant-table-wrapper.border-collapse table {
border-collapse: collapse;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@ -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 <Result
status='500'
title='Sorry, Something went wrong.'
subTitle={this.state.info}
/>
}
return this.props.children
}
}
export default ErrorBoundary

@ -0,0 +1,13 @@
import { useRouteError } from 'react-router-dom'
import { Result } from 'antd'
export default function ErrorPage() {
const errorResponse = useRouteError()
return (
<Result
status='404'
title='Sorry, an unexpected error has occurred.'
subTitle={errorResponse?.message || errorResponse.error?.message}
/>
)
}

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

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

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

@ -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': '',
},
],
};
};

@ -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, {})
}
}
}

@ -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: <App />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <HotelList /> },
{ path: 'hotel/list', element: <HotelList /> },
{ path: 'hotel/:hotelId/:checkin/:checkout', element: <HotelDetail /> },
]
}
])
}
const initAppliction = async () => {
const router = await initRouter()
createRoot(document.getElementById('root')).render(
//<React.StrictMode>
<ThemeContext.Provider value={{ colorPrimary: '#00b96b', borderRadius: 4 }}>
<RouterProvider
router={router}
fallbackElement={() => <div>Loading...</div>}
/>
</ThemeContext.Provider>
//</React.StrictMode>
)
}
initAppliction()

@ -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 && (
<a
className='text-primary'
onClick={() => {
window.$pageSpy.triggerPlugins('onOfflineLog', 'download');
window.$pageSpy.triggerPlugins('onOfflineLog', 'upload');
}}>
上传Debug日志 ({window.$pageSpy.address.substring(0, 4)})
</a>
)}
</>
);
};

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

@ -0,0 +1,7 @@
import { createContext, useContext } from 'react'
export const ThemeContext = createContext({})
export function useThemeContext() {
return useContext(ThemeContext)
}

@ -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)
)

@ -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
})
}

@ -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 (
<ConfigProvider
theme={{
token: {
colorPrimary: colorPrimary,
},
algorithm: theme.defaultAlgorithm,
}}>
<AntApp>
<ErrorBoundary>
<Layout className='min-h-screen'>
<Header className='sticky top-0 z-10 w-full'>
<Row gutter={{ md: 24 }} justify='start' align='middle'>
<Col span={15}>
<NavLink to='/'>
<img src={AppLogo} className='float-left h-9 my-4 mr-6 ml-0 bg-white/30' alt='App logo' />
</NavLink>
<Menu
theme='dark'
mode='horizontal'
selectedKeys={['hotel']}
items={[
{ key: 'hotel', label: <Link to='/hotel/list'>喜玩酒店</Link> }
]}
/>
</Col>
</Row>
</Header>
<Content className='p-6 m-0 min-h-72 bg-white flex justify-center'>
<div className='max-w-3xl'>
<Outlet />
</div>
</Content>
<Footer>China Highlights International Travel Service Co., LTD, Version: {BUILD_VERSION}</Footer>
</Layout>
</ErrorBoundary>
</AntApp>
</ConfigProvider>
)
}
export default App

@ -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 (
<>
<input type='hidden' id='forHtJson' />
<Modal title='你选择了' open={isModalOpen} onOk={handleOk} onCancel={handleCancel}>
<p>酒店{hotelQuotation.hotelName}</p>
<p>房型{hotelQuotation.roomName}</p>
<p>价格{hotelQuotation.price}</p>
</Modal>
<Row gutter={16}>
<Col span={20}>
<Typography.Title level={4}>
{selectedHotel.hotel_name}
</Typography.Title>
</Col>
<Col span={4}>
<Button onClick={() => navigate(-1)}>Back</Button>
</Col>
</Row>
<RoomList loading={loading} onChange={({room, plan}) => {handleRoomChange(room, plan)}} dataSource={roomList}></RoomList>
</>
);
}
export default Detail;

@ -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 (
<>
<Skeleton active loading={loading} key={index}>
<Row gutter={16} className='rounded shadow-md hover:shadow-lg hover:shadow-gray-400 p-2 border-solid border border-gray-100'>
<Col span={24}>
<Flex
vertical gap='small'
align='flex-start'
justify='flex-start'
>
<Typography.Title level={5}>
{data.hotel_name}
</Typography.Title>
<Typography.Text type='secondary'>{data.address}</Typography.Text>
<span>{data.base_price.Price??0} </span>
<Button size='small' type='primary' onClick={() => onChange(data)}>
查看房型
</Button>
</Flex>
</Col>
</Row>
</Skeleton>
</>
)
})
return (
<HotelContext.Provider value={{ onChange }}>
<Flex vertical gap='small'>
{itemList}
</Flex>
</HotelContext.Provider>
)
} else {
return <Empty />
}
}
export function RoomList({ dataSource, loading, onChange }) {
const triggerChange = (changedValue) => {
onChange?.(
changedValue
)
}
if (dataSource && dataSource.length > 0) {
const itemList = dataSource.map((room, index) => {
let roomImage = <Empty description={false}/>
if (room?.Images && room?.Images.length > 0) {
roomImage = <Image src={room?.Images[0].Url} />
}
return (
<Skeleton active loading={loading} key={index}>
<div className='rounded shadow-md hover:shadow-lg hover:shadow-gray-400 p-2 border-solid border border-gray-100'>
<Typography.Title level={5}>
{room.RoomName}, {room.BedTypeDesc}
</Typography.Title>
<Row gutter={[16, 16]}>
<Col span={4}>
<Flex vertical>
{roomImage}
<span>大小: {room.Area??0}</span>
</Flex>
</Col>
<Col span={20}>
{
room.RatePlans.map((plan, index) => {
return (
<PlanItem key={index} room={room} plan={plan}></PlanItem>
)
})
}
</Col>
</Row>
</div>
</Skeleton>
)
})
return (
<HotelContext.Provider value={{ triggerChange }}>
<Flex gap='middle' vertical>
{itemList}
</Flex>
</HotelContext.Provider>
)
} else {
return <Empty />
}
}
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 (
<div className='p-2'>
<Row gutter={[16, 16]} className=' divide-y divide-slate-700'>
<Col span={6}><span>: {getMealDesc(plan)}</span></Col>
<Col span={6}><span>取消: {plan.Cancelable ? plan.CancelRules.map(r => getDeductDesc(r.DeductType)).join(',') : '不'}</span></Col>
<Col span={6}>
<b>{plan.Price}</b>
<small>{plan.Currency}</small>
</Col>
<Col span={4}><Button size='small' onClick={() => triggerChange({room, plan})}>
选他
</Button></Col>
</Row>
</div>
)
}

@ -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 (
<>
<Space direction='vertical' className='w-full'>
<Title level={3}>酒店列表</Title>
<Form
name='searchForm'
form={searchForm}
layout='inline'
onFinish={onSearchFinish}
onFinishFailed={() => console.info('onFinishFailed')}
autoComplete='off'
>
<Form.Item
label={'入住时间'}
name='dataRange'
rules={[
{
required: true,
message: '必填',
},
]}
>
<DatePicker.RangePicker placeholder={['入住日期', '退房日期']} />
</Form.Item>
<Form.Item
label={'酒店名称'}
name='hotelName'
rules={[
{
required: true,
message: '必填',
},
]}
>
<Input placeholder={'不能为空'} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">搜索</Button>
</Form.Item>
</Form>
<HotelList loading={loading} dataSource={hotelList} onChange={h => handleHotelChange(h)}></HotelList>
</Space>
</>
)
}
export default List

@ -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,
},
}

@ -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'
}
},
}
}
},
})
Loading…
Cancel
Save