初始化导入 Web 项目
parent
4c14aadbb2
commit
968078c6db
@ -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',
|
||||
},
|
||||
};
|
@ -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 @@
|
||||
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,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,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,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}m²</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…
Reference in New Issue