feat: 迭代
This commit is contained in:
@@ -51,7 +51,8 @@
|
||||
"Bash(curl -X GET \"http://localhost:3000/api/admin/finance/reports/weekly\" -H \"Content-Type: application/json\")",
|
||||
"Bash(xargs grep -l \"room\" -i)",
|
||||
"Bash(mysql -h localhost -u root -p123456 -D rent -e \"DESCRIBE user_coupons;\")",
|
||||
"Bash(docker exec *)"
|
||||
"Bash(docker exec *)",
|
||||
"Bash(mysql -h localhost -P 3306 -u root -pquan131735 rent_platform -e \"DESC guests;\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/* 商家后台 - 居中卡片布局 */
|
||||
.merchant-login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #fff5f0 0%, #ffe8dc 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
/* 背景装饰 */
|
||||
.merchant-login-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bg-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(254, 202, 87, 0.1) 100%);
|
||||
animation: bgFloat 25s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.bg-circle.circle-1 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
top: -200px;
|
||||
right: -200px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.bg-circle.circle-2 {
|
||||
width: 450px;
|
||||
height: 450px;
|
||||
bottom: -150px;
|
||||
left: -150px;
|
||||
animation-delay: 8s;
|
||||
}
|
||||
|
||||
.bg-circle.circle-3 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
animation-delay: 16s;
|
||||
}
|
||||
|
||||
@keyframes bgFloat {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -30px) scale(1.05);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-30px, 30px) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
/* 中央登录卡片 */
|
||||
.merchant-login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: #fff;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 60px rgba(255, 107, 107, 0.15);
|
||||
padding: 48px 40px;
|
||||
animation: cardSlideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes cardSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 品牌区域 */
|
||||
.merchant-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.merchant-brand-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(254, 202, 87, 0.1) 100%);
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
border: 2px solid rgba(255, 107, 107, 0.2);
|
||||
}
|
||||
|
||||
.merchant-brand-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #2d3436;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.merchant-brand-subtitle {
|
||||
font-size: 15px;
|
||||
color: #636e72;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 表单区域 */
|
||||
.merchant-form-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.merchant-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.merchant-input {
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
border: 2px solid #f0f0f0;
|
||||
transition: all 0.3s;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.merchant-input:hover {
|
||||
border-color: #ff6b6b;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.merchant-input:focus,
|
||||
.merchant-input.ant-input-focused {
|
||||
border-color: #ff6b6b;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(255, 107, 107, 0.1);
|
||||
}
|
||||
|
||||
.merchant-input input {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.merchant-input-icon {
|
||||
color: #ff6b6b;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.merchant-code-wrapper {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.merchant-code-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.merchant-code-button {
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 0 24px;
|
||||
white-space: nowrap;
|
||||
border: 2px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
transition: all 0.3s;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.merchant-code-button:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: #ff6b6b;
|
||||
border-color: #ff6b6b;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
|
||||
.merchant-code-button:disabled {
|
||||
color: #bbb;
|
||||
background: #f5f5f5;
|
||||
border-color: #e8e8e8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.merchant-login-button {
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #feca57 100%);
|
||||
border: none;
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 107, 0.35);
|
||||
transition: all 0.3s;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.merchant-login-button:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px rgba(255, 107, 107, 0.45);
|
||||
}
|
||||
|
||||
.merchant-login-button:active {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.merchant-dev-tip {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.merchant-dev-tip p {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
padding: 10px 18px;
|
||||
background: #fff5f0;
|
||||
border-radius: 10px;
|
||||
display: inline-block;
|
||||
border: 1px solid #ffe8dc;
|
||||
}
|
||||
|
||||
/* 特性展示 */
|
||||
.merchant-features {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 24px 0;
|
||||
margin-bottom: 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.merchant-feature-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.merchant-feature-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.merchant-feature-text {
|
||||
font-size: 13px;
|
||||
color: #636e72;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.merchant-footer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.merchant-footer p {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 640px) {
|
||||
.merchant-login-card {
|
||||
padding: 36px 28px;
|
||||
}
|
||||
|
||||
.merchant-brand-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.merchant-code-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.merchant-code-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.merchant-features {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bg-circle.circle-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.bg-circle.circle-2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.bg-circle.circle-3 {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Form, Input, Button, Card, message } from 'antd';
|
||||
import { MobileOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
import { Form, Input, Button, message } from 'antd';
|
||||
import { MobileOutlined, SafetyOutlined, ShopOutlined } from '@ant-design/icons';
|
||||
import { loginByCode, sendSellerCode } from '@/api/auth';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
import './Login.css';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -55,34 +56,106 @@ const Login: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#f0f2f5' }}>
|
||||
<Card style={{ width: 400 }} title={<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 600 }}>商家管理后台</div>}>
|
||||
<Form form={form} onFinish={onFinish} size="large">
|
||||
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '手机号格式不正确' }]}>
|
||||
<Input prefix={<MobileOutlined />} placeholder="手机号" maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" rules={[{ required: true, message: '请输入验证码' }]}>
|
||||
<Input
|
||||
prefix={<SafetyOutlined />}
|
||||
placeholder="验证码"
|
||||
maxLength={6}
|
||||
addonAfter={
|
||||
<Button type="link" disabled={countdown > 0} onClick={handleSendCode} style={{ padding: 0, height: 'auto' }}>
|
||||
<div className="merchant-login-container">
|
||||
{/* 顶部装饰背景 */}
|
||||
<div className="merchant-login-bg">
|
||||
<div className="bg-decoration">
|
||||
<div className="bg-circle circle-1"></div>
|
||||
<div className="bg-circle circle-2"></div>
|
||||
<div className="bg-circle circle-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中央登录卡片 */}
|
||||
<div className="merchant-login-card">
|
||||
{/* 品牌区域 */}
|
||||
<div className="merchant-brand">
|
||||
<div className="merchant-brand-icon">
|
||||
<ShopOutlined style={{ fontSize: 56, color: '#ff6b6b' }} />
|
||||
</div>
|
||||
<h1 className="merchant-brand-title">品住会商家后台</h1>
|
||||
<p className="merchant-brand-subtitle">轻松管理您的房源和订单</p>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<div className="merchant-form-section">
|
||||
<Form form={form} onFinish={onFinish} size="large" className="merchant-form">
|
||||
<Form.Item
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1\d{10}$/, message: '手机号格式不正确' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MobileOutlined className="merchant-input-icon" />}
|
||||
placeholder="手机号"
|
||||
maxLength={11}
|
||||
className="merchant-input"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="code"
|
||||
rules={[{ required: true, message: '请输入验证码' }]}
|
||||
>
|
||||
<div className="merchant-code-wrapper">
|
||||
<Input
|
||||
prefix={<SafetyOutlined className="merchant-input-icon" />}
|
||||
placeholder="验证码"
|
||||
maxLength={6}
|
||||
className="merchant-input merchant-code-input"
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={countdown > 0}
|
||||
onClick={handleSendCode}
|
||||
className="merchant-code-button"
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : '获取验证码'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<div style={{ textAlign: 'center', color: '#999', fontSize: 12 }}>
|
||||
开发环境验证码: 123456
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
className="merchant-login-button"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div className="merchant-dev-tip">
|
||||
<p>开发环境验证码: 123456</p>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* 特性展示 */}
|
||||
<div className="merchant-features">
|
||||
<div className="merchant-feature-item">
|
||||
<div className="merchant-feature-icon">📦</div>
|
||||
<div className="merchant-feature-text">房源管理</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
<div className="merchant-feature-item">
|
||||
<div className="merchant-feature-icon">📋</div>
|
||||
<div className="merchant-feature-text">订单处理</div>
|
||||
</div>
|
||||
<div className="merchant-feature-item">
|
||||
<div className="merchant-feature-icon">💰</div>
|
||||
<div className="merchant-feature-text">财务结算</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="merchant-footer">
|
||||
<p>© 2026 品住会. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { request } from '@/utils/request';
|
||||
|
||||
export function getRoomCalendar(roomId: number, startDate: string, endDate: string) {
|
||||
return request({
|
||||
url: `/seller/room-calendar/${roomId}`,
|
||||
url: `/api/merchant/room-calendar/${roomId}`,
|
||||
method: 'GET',
|
||||
data: { startDate, endDate },
|
||||
useSellerToken: true,
|
||||
|
||||
@@ -16,6 +16,10 @@ export function loginByWechat(code: string, nickname?: string, avatar?: string)
|
||||
return post('/api/app/auth/login/wechat', { code, nickname, avatar });
|
||||
}
|
||||
|
||||
export function loginByWechatPhone(code: string, encryptedData: string, iv: string) {
|
||||
return post('/api/app/auth/login/wechat-phone', { code, encryptedData, iv });
|
||||
}
|
||||
|
||||
export function register(data: { phone: string; code: string; password?: string; nickname?: string }) {
|
||||
return post('/api/app/auth/register', data);
|
||||
}
|
||||
|
||||
@@ -21,15 +21,15 @@ export function getOrderList(params: { page?: number; pageSize?: number; status?
|
||||
}
|
||||
|
||||
export function getOrderDetail(id: number) {
|
||||
return get(`/api/user/orders/${id}`);
|
||||
return get(`/api/app/orders/${id}`);
|
||||
}
|
||||
|
||||
export function cancelOrder(id: number, reason: string) {
|
||||
return put(`/api/user/orders/${id}/cancel`, { reason });
|
||||
return put(`/api/app/orders/${id}/cancel`, { reason });
|
||||
}
|
||||
|
||||
export function refundOrder(id: number, reason: string) {
|
||||
return put(`/api/user/orders/${id}/refund`, { reason });
|
||||
return put(`/api/app/orders/${id}/refund`, { reason });
|
||||
}
|
||||
|
||||
export function payOrder(orderId: number, paymentMethod: 'wechat' | 'alipay' | 'balance' = 'wechat') {
|
||||
|
||||
@@ -20,9 +20,9 @@ export function getRoomList(params: {
|
||||
}
|
||||
|
||||
export function getRoomDetail(id: number) {
|
||||
return get(`/api/public/rooms/${id}`);
|
||||
return get(`/api/app/rooms/${id}`);
|
||||
}
|
||||
|
||||
export function getRoomCalendar(id: number, startDate: string, endDate: string) {
|
||||
return get(`/api/public/rooms/${id}/calendar`, { startDate, endDate });
|
||||
return get(`/api/app/rooms/${id}/calendar`, { startDate, endDate });
|
||||
}
|
||||
|
||||
@@ -15,18 +15,34 @@
|
||||
<text class="app-slogan">舒适住宿,温馨服务</text>
|
||||
</view>
|
||||
|
||||
<!-- 微信小程序模式:微信授权登录 -->
|
||||
<view v-if="isWechatMiniProgram" class="form-section">
|
||||
<!-- 登录选项区域 -->
|
||||
<view class="form-section">
|
||||
<text class="form-title">欢迎使用</text>
|
||||
<text class="form-subtitle">使用微信授权快速登录</text>
|
||||
<text class="form-subtitle">选择登录方式</text>
|
||||
|
||||
<!-- 微信授权登录按钮 -->
|
||||
<button class="wechat-login-button" @tap="handleWechatLogin">
|
||||
<u-icon name="weixin-fill" :size="24" color="#fff" style="margin-right: 12rpx;" />
|
||||
微信授权登录
|
||||
<!-- 一键登录按钮(微信获取手机号) -->
|
||||
<button
|
||||
v-if="isWechatMiniProgram"
|
||||
class="quick-login-button"
|
||||
open-type="getPhoneNumber"
|
||||
@getphonenumber="handleQuickLogin"
|
||||
>
|
||||
<u-icon name="phone-fill" :size="24" color="#fff" style="margin-right: 12rpx;" />
|
||||
一键登录
|
||||
</button>
|
||||
|
||||
<!-- 协议 -->
|
||||
<!-- 手机号安全登录按钮 -->
|
||||
<button class="phone-login-button" @tap="handleShowPhoneModal">
|
||||
<u-icon name="lock-fill" :size="24" color="#fff" style="margin-right: 12rpx;" />
|
||||
手机号安全登录
|
||||
</button>
|
||||
|
||||
<!-- 暂时不登录文字按钮 -->
|
||||
<view class="skip-login-text" @tap="handleSkipLogin">
|
||||
暂时不登录
|
||||
</view>
|
||||
|
||||
<!-- 协议勾选 -->
|
||||
<view class="agreement-section">
|
||||
<view class="checkbox-wrapper" @tap="toggleAgreement">
|
||||
<view class="checkbox" :class="{ checked: agreed }">
|
||||
@@ -35,86 +51,93 @@
|
||||
</view>
|
||||
</view>
|
||||
<text class="agreement-text">
|
||||
登录即表示同意
|
||||
<text class="link">《用户协议》</text>
|
||||
我已阅读并同意
|
||||
<text class="link" @tap.stop="handleViewAgreement('user')">《用户协议》</text>
|
||||
和
|
||||
<text class="link">《隐私政策》</text>
|
||||
<text class="link" @tap.stop="handleViewAgreement('privacy')">《隐私政策》</text>
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="tip-text">首次登录将自动创建账号</text>
|
||||
</view>
|
||||
|
||||
<!-- 非小程序模式:手机号登录 -->
|
||||
<view v-else class="form-section">
|
||||
<text class="form-title">欢迎登录</text>
|
||||
|
||||
<!-- 手机号输入 -->
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<text class="input-prefix">+86</text>
|
||||
<input
|
||||
v-model="phone"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
class="input"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
<!-- 协议确认弹窗 -->
|
||||
<view v-if="showAgreementModal" class="modal-overlay" @tap="closeAgreementModal">
|
||||
<view class="modal-content" @tap.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">服务协议及隐私保护</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<input
|
||||
v-model="code"
|
||||
type="number"
|
||||
maxlength="6"
|
||||
placeholder="请输入验证码"
|
||||
class="input"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
<text
|
||||
class="code-btn"
|
||||
:class="{ disabled: countdown > 0 }"
|
||||
@tap="handleSendCode"
|
||||
>
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
<view class="modal-body">
|
||||
<text class="modal-text">
|
||||
为了更好的保障您的合法权益,请您阅读并同意以下协议
|
||||
<text class="link" @tap.stop="handleViewAgreement('user')">《用户协议》</text>
|
||||
和
|
||||
<text class="link" @tap.stop="handleViewAgreement('privacy')">《隐私政策》</text>
|
||||
</text>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<button class="modal-button cancel" @tap="closeAgreementModal">取消</button>
|
||||
<button class="modal-button confirm" @tap="confirmAgreement">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<button class="login-button" @tap="handlePhoneLogin">登录</button>
|
||||
|
||||
<!-- 协议 -->
|
||||
<view class="agreement-section">
|
||||
<view class="checkbox-wrapper" @tap="toggleAgreement">
|
||||
<view class="checkbox" :class="{ checked: agreed }">
|
||||
<view v-if="agreed" class="check-icon">
|
||||
<u-icon name="checkmark" :size="16" color="#fff" />
|
||||
<!-- 手机号登录弹窗 -->
|
||||
<view v-if="showPhoneModal" class="modal-overlay" @tap="closePhoneModal">
|
||||
<view class="modal-content phone-modal" @tap.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">手机号安全登录</text>
|
||||
<view class="modal-close" @tap="closePhoneModal">
|
||||
<u-icon name="close" :size="20" color="#999" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<!-- 手机号输入 -->
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<text class="input-prefix">+86</text>
|
||||
<input
|
||||
v-model="phone"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
class="input"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<text class="agreement-text">
|
||||
登录即表示同意
|
||||
<text class="link">《用户协议》</text>
|
||||
和
|
||||
<text class="link">《隐私政策》</text>
|
||||
</text>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<input
|
||||
v-model="code"
|
||||
type="number"
|
||||
maxlength="6"
|
||||
placeholder="请输入验证码"
|
||||
class="input"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
<text
|
||||
class="code-btn"
|
||||
:class="{ disabled: countdown > 0 }"
|
||||
@tap="handleSendCode"
|
||||
>
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<button class="modal-login-button" @tap="handlePhoneLogin">登录</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 提示 -->
|
||||
<text class="tip-text">未注册手机号验证后将自动注册</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { sendCode, loginByPhone, loginByWechat } from '@/api/user/auth';
|
||||
import { sendCode, loginByPhone, loginByWechatPhone } from '@/api/user/auth';
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
const phone = ref('');
|
||||
@@ -122,9 +145,12 @@ const code = ref('');
|
||||
const countdown = ref(0);
|
||||
const agreed = ref(false);
|
||||
const isWechatMiniProgram = ref(false);
|
||||
const showAgreementModal = ref(false);
|
||||
const showPhoneModal = ref(false);
|
||||
const userStore = useUserStore();
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let pendingAction: (() => void) | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
// 判断运行环境
|
||||
@@ -136,66 +162,107 @@ function toggleAgreement() {
|
||||
agreed.value = !agreed.value;
|
||||
}
|
||||
|
||||
// 微信授权登录
|
||||
async function handleWechatLogin() {
|
||||
// 检查协议是否同意
|
||||
function checkAgreement(action: () => void) {
|
||||
if (!agreed.value) {
|
||||
uni.showToast({ title: '请先同意用户协议', icon: 'none' });
|
||||
pendingAction = action;
|
||||
showAgreementModal.value = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 关闭协议弹窗
|
||||
function closeAgreementModal() {
|
||||
showAgreementModal.value = false;
|
||||
pendingAction = null;
|
||||
}
|
||||
|
||||
// 确认协议
|
||||
function confirmAgreement() {
|
||||
agreed.value = true;
|
||||
showAgreementModal.value = false;
|
||||
if (pendingAction) {
|
||||
pendingAction();
|
||||
pendingAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 一键登录(微信获取手机号)
|
||||
async function handleQuickLogin(e: any) {
|
||||
const action = async () => {
|
||||
try {
|
||||
const { code, encryptedData, iv } = e.detail;
|
||||
|
||||
if (!code) {
|
||||
uni.showToast({ title: '获取手机号失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showLoading({ title: '登录中...' });
|
||||
|
||||
// 调用后端接口,使用微信手机号登录
|
||||
const res = await loginByWechatPhone(code, encryptedData, iv);
|
||||
|
||||
userStore.setLogin(res.data);
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '登录成功', icon: 'success' });
|
||||
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' });
|
||||
}, 500);
|
||||
} catch (error: any) {
|
||||
uni.hideLoading();
|
||||
console.error('一键登录失败', error);
|
||||
uni.showToast({
|
||||
title: error.message || '登录失败,请重试',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!checkAgreement(action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uni.showLoading({ title: '登录中...' });
|
||||
await action();
|
||||
}
|
||||
|
||||
// 1. 调用 wx.login 获取 code
|
||||
const loginRes = await new Promise<any>((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
// 显示手机号登录弹窗
|
||||
function handleShowPhoneModal() {
|
||||
const action = () => {
|
||||
showPhoneModal.value = true;
|
||||
};
|
||||
|
||||
if (!loginRes.code) {
|
||||
throw new Error('获取微信code失败');
|
||||
}
|
||||
|
||||
// 2. 获取用户信息(可选)
|
||||
let userInfo: any = {};
|
||||
try {
|
||||
const userInfoRes = await new Promise<any>((resolve, reject) => {
|
||||
uni.getUserProfile({
|
||||
desc: '用于完善用户资料',
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
userInfo = userInfoRes.userInfo || {};
|
||||
} catch (e) {
|
||||
console.log('用户取消授权或获取用户信息失败', e);
|
||||
}
|
||||
|
||||
// 3. 调用后端接口登录
|
||||
const res = await loginByWechat(
|
||||
loginRes.code,
|
||||
userInfo.nickName,
|
||||
userInfo.avatarUrl
|
||||
);
|
||||
|
||||
userStore.setLogin(res.data);
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '登录成功', icon: 'success' });
|
||||
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' });
|
||||
}, 500);
|
||||
} catch (e: any) {
|
||||
uni.hideLoading();
|
||||
console.error('微信登录失败', e);
|
||||
uni.showToast({
|
||||
title: e.message || '登录失败,请重试',
|
||||
icon: 'none'
|
||||
});
|
||||
if (!checkAgreement(action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
action();
|
||||
}
|
||||
|
||||
// 关闭手机号登录弹窗
|
||||
function closePhoneModal() {
|
||||
showPhoneModal.value = false;
|
||||
phone.value = '';
|
||||
code.value = '';
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
countdown.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 暂时不登录
|
||||
function handleSkipLogin() {
|
||||
uni.switchTab({ url: '/pages/index/index' });
|
||||
}
|
||||
|
||||
// 查看协议
|
||||
function handleViewAgreement(type: 'user' | 'privacy') {
|
||||
uni.navigateTo({
|
||||
url: `/pages/agreement/index?type=${type}`
|
||||
});
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
@@ -225,11 +292,6 @@ async function handleSendCode() {
|
||||
|
||||
// 手机号登录
|
||||
async function handlePhoneLogin() {
|
||||
if (!agreed.value) {
|
||||
uni.showToast({ title: '请先同意用户协议', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!phone.value || !code.value) {
|
||||
uni.showToast({ title: '请填写完整信息', icon: 'none' });
|
||||
return;
|
||||
@@ -246,13 +308,17 @@ async function handlePhoneLogin() {
|
||||
}
|
||||
|
||||
try {
|
||||
uni.showLoading({ title: '登录中...' });
|
||||
const res = await loginByPhone(phone.value, code.value);
|
||||
userStore.setLogin(res.data);
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '登录成功', icon: 'success' });
|
||||
closePhoneModal();
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' });
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
uni.hideLoading();
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
@@ -359,9 +425,229 @@ async function handlePhoneLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
// 登录按钮样式
|
||||
.quick-login-button,
|
||||
.phone-login-button {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
border-radius: 16rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
margin-bottom: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:active {
|
||||
transform: translateY(2rpx);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-login-button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.phone-login-button {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 8rpx 24rpx rgba(245, 87, 108, 0.3);
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 4rpx 16rpx rgba(245, 87, 108, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
// 暂时不登录文字按钮
|
||||
.skip-login-text {
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
padding: 24rpx 0;
|
||||
margin-bottom: 16rpx;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:active {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
// 协议
|
||||
.agreement-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12rpx;
|
||||
|
||||
.checkbox {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border: 2rpx solid #ddd;
|
||||
border-radius: 6rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2rpx;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.checked {
|
||||
background: #667eea;
|
||||
border-color: #667eea;
|
||||
|
||||
.check-icon {
|
||||
font-size: 20rpx;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
|
||||
.link {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗遮罩
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: 0 48rpx;
|
||||
}
|
||||
|
||||
// 弹窗内容
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
width: 100%;
|
||||
max-width: 600rpx;
|
||||
overflow: hidden;
|
||||
animation: modalSlideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(50rpx);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 40rpx 32rpx 24rpx;
|
||||
position: relative;
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 32rpx;
|
||||
right: 32rpx;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 0 32rpx 32rpx;
|
||||
|
||||
.modal-text {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
line-height: 1.8;
|
||||
display: block;
|
||||
|
||||
.link {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
|
||||
.modal-button {
|
||||
flex: 1;
|
||||
height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
background: transparent;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.cancel {
|
||||
color: #999;
|
||||
border-right: 1rpx solid #f0f0f0;
|
||||
|
||||
&:active {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
}
|
||||
|
||||
&.confirm {
|
||||
color: #667eea;
|
||||
|
||||
&:active {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手机号登录弹窗
|
||||
.phone-modal {
|
||||
.modal-body {
|
||||
padding: 24rpx 32rpx 40rpx;
|
||||
}
|
||||
}
|
||||
|
||||
// 输入框组
|
||||
.input-group {
|
||||
margin-bottom: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
@@ -420,9 +706,8 @@ async function handlePhoneLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
// 登录按钮
|
||||
.login-button,
|
||||
.wechat-login-button {
|
||||
// 弹窗内登录按钮
|
||||
.modal-login-button {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
@@ -432,84 +717,13 @@ async function handlePhoneLogin() {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
margin: 48rpx 0 32rpx;
|
||||
margin-top: 32rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
transform: translateY(2rpx);
|
||||
box-shadow: 0 4rpx 16rpx rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.wechat-login-button {
|
||||
background: linear-gradient(135deg, #09bb07 0%, #07c160 100%);
|
||||
box-shadow: 0 8rpx 24rpx rgba(7, 193, 96, 0.3);
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 4rpx 16rpx rgba(7, 193, 96, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
// 协议
|
||||
.agreement-section {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12rpx;
|
||||
|
||||
.checkbox {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border: 2rpx solid #ddd;
|
||||
border-radius: 6rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2rpx;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.checked {
|
||||
background: #667eea;
|
||||
border-color: #667eea;
|
||||
|
||||
.check-icon {
|
||||
font-size: 20rpx;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
|
||||
.link {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提示文字
|
||||
.tip-text {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
:color="i <= Math.floor(merchantData.rating || 0) ? '#FFB800' : '#E5E5E5'"
|
||||
/>
|
||||
</view>
|
||||
<text class="rating-score">{{ merchantData.rating?.toFixed(1) || '暂无' }}</text>
|
||||
<text class="rating-score">{{ formatRating(merchantData.rating) }}</text>
|
||||
<text class="rating-count">({{ merchantData.reviewCount || 0 }}条评价)</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -430,6 +430,13 @@ function transformReviewData(review: Review): ReviewCardData {
|
||||
};
|
||||
}
|
||||
|
||||
// 格式化评分
|
||||
function formatRating(rating: any): string {
|
||||
const num = Number(rating);
|
||||
if (isNaN(num) || num === 0) return '暂无';
|
||||
return num.toFixed(1);
|
||||
}
|
||||
|
||||
// 格式化评价日期
|
||||
function formatReviewDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
@@ -563,7 +570,12 @@ function handleOpenMap() {
|
||||
}
|
||||
|
||||
function handleRoomClick(roomId: number) {
|
||||
uni.navigateTo({ url: `/pages/room-detail/index?id=${roomId}` });
|
||||
const queryParams = [
|
||||
`id=${roomId}`,
|
||||
`checkIn=${bookingParams.checkIn}`,
|
||||
`checkOut=${bookingParams.checkOut}`,
|
||||
].join('&');
|
||||
uni.navigateTo({ url: `/pages/room-detail/index?${queryParams}` });
|
||||
}
|
||||
|
||||
function handleBookRoom(room: RoomCardData) {
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<text class="room-name">{{ room.name }}</text>
|
||||
<tag-list v-if="roomTags.length" :tags="roomTags" type="primary" size="small" />
|
||||
<view class="room-price-row">
|
||||
<price-tag :price="room.price" size="medium" unit="/晚" />
|
||||
<text class="room-stock-tip" v-if="maxRoomCount <= 3">仅剩{{ maxRoomCount }}套</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -96,7 +95,7 @@
|
||||
<!-- 价格明细 -->
|
||||
<view class="price-detail">
|
||||
<view class="price-row">
|
||||
<text class="price-label">房费 (¥{{ room?.price }} × {{ nightCount }}晚 × {{ roomCount }}套)</text>
|
||||
<text class="price-label">房费 ({{ nightCount }}晚 × {{ roomCount }}套)</text>
|
||||
<text class="price-value">¥{{ roomAmount }}</text>
|
||||
</view>
|
||||
|
||||
@@ -278,7 +277,6 @@ import { createOrder } from '@/api/user/order';
|
||||
import { getAvailableCoupons } from '@/api/coupon';
|
||||
import { getGuestList } from '@/api/guest';
|
||||
import RoomCalendarPicker from '@/components/RoomCalendarPicker.vue';
|
||||
import PriceTag from '@/components/business/PriceTag.vue';
|
||||
import TagList from '@/components/business/TagList.vue';
|
||||
|
||||
const roomId = ref(0);
|
||||
@@ -305,6 +303,7 @@ const showGuestPicker = ref(false);
|
||||
|
||||
// 房态数据:记录每日剩余库存
|
||||
const calendarStock = ref<Map<string, number>>(new Map());
|
||||
const calendarPrice = ref<Map<string, number>>(new Map());
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
hotel: '酒店',
|
||||
@@ -380,13 +379,43 @@ const maxGuestCount = computed(() => {
|
||||
|
||||
// 房费总额
|
||||
const roomAmount = computed(() => {
|
||||
if (!room.value) return 0;
|
||||
return (room.value.price * nightCount.value * roomCount.value).toFixed(2);
|
||||
if (!room.value || !checkInDate.value || !checkOutDate.value) return 0;
|
||||
|
||||
// 如果有日历价格数据,使用日历价格计算
|
||||
if (calendarPrice.value.size > 0) {
|
||||
const checkIn = new Date(checkInDate.value);
|
||||
const checkOut = new Date(checkOutDate.value);
|
||||
let totalPrice = 0;
|
||||
|
||||
for (let d = new Date(checkIn); d < checkOut; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const price = Number(calendarPrice.value.get(dateStr) || room.value.price || 0);
|
||||
totalPrice += price;
|
||||
}
|
||||
|
||||
return (totalPrice * roomCount.value).toFixed(2);
|
||||
}
|
||||
|
||||
// 否则使用基础价格
|
||||
return (Number(room.value.price || 0) * nightCount.value * roomCount.value).toFixed(2);
|
||||
});
|
||||
|
||||
// 平台服务费(5%)
|
||||
const serviceFee = computed(() => {
|
||||
const amount = parseFloat(roomAmount.value);
|
||||
return (amount * 0.05).toFixed(2);
|
||||
});
|
||||
|
||||
// 订单总额(房费 + 服务费)
|
||||
const totalAmount = computed(() => {
|
||||
const amount = parseFloat(roomAmount.value);
|
||||
const fee = parseFloat(serviceFee.value);
|
||||
return (amount + fee).toFixed(2);
|
||||
});
|
||||
|
||||
// 优惠券折扣金额
|
||||
const couponDiscount = computed(() => {
|
||||
if (!selectedCoupon.value) return 0;
|
||||
if (!selectedCoupon.value) return '0';
|
||||
const coupon = selectedCoupon.value.coupon;
|
||||
const amount = parseFloat(roomAmount.value);
|
||||
|
||||
@@ -395,14 +424,14 @@ const couponDiscount = computed(() => {
|
||||
} else if (coupon.type === 'percent') {
|
||||
return (amount * (1 - coupon.value / 100)).toFixed(2);
|
||||
}
|
||||
return 0;
|
||||
return '0';
|
||||
});
|
||||
|
||||
// 最终支付金额
|
||||
// 最终支付金额(订单总额 - 优惠券)
|
||||
const finalPrice = computed(() => {
|
||||
const amount = parseFloat(roomAmount.value);
|
||||
const total = parseFloat(totalAmount.value);
|
||||
const discount = parseFloat(couponDiscount.value);
|
||||
return Math.max(0, amount - discount).toFixed(2);
|
||||
return Math.max(0, total - discount).toFixed(2);
|
||||
});
|
||||
|
||||
// 可用优惠券数量
|
||||
@@ -461,11 +490,14 @@ async function fetchCalendarStock() {
|
||||
try {
|
||||
const res = await getRoomCalendar(roomId.value, checkInDate.value, checkOutDate.value);
|
||||
const data = res.data || [];
|
||||
const map = new Map<string, number>();
|
||||
const stockMap = new Map<string, number>();
|
||||
const priceMap = new Map<string, number>();
|
||||
for (const item of data) {
|
||||
map.set(item.date, (item.stock || 0) - (item.sold || 0));
|
||||
stockMap.set(item.date, (item.stock || 0) - (item.sold || 0));
|
||||
priceMap.set(item.date, Number(item.price || room.value?.price || 0));
|
||||
}
|
||||
calendarStock.value = map;
|
||||
calendarStock.value = stockMap;
|
||||
calendarPrice.value = priceMap;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -486,10 +518,12 @@ async function fetchAvailableCoupons() {
|
||||
async function fetchGuestList() {
|
||||
try {
|
||||
const res = await getGuestList();
|
||||
guestList.value = res.data || [];
|
||||
console.log('常住人列表数据:', res.data);
|
||||
guestList.value = res.data?.data || [];
|
||||
// 如果有默认常住人,自动填充
|
||||
const defaultGuest = guestList.value.find((g: any) => g.isDefault);
|
||||
if (defaultGuest && !contactName.value) {
|
||||
console.log('默认常住人:', defaultGuest);
|
||||
fillGuestInfo(defaultGuest);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -717,7 +751,7 @@ function manageGuests() {
|
||||
.room-price-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.room-stock-tip {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<view class="title-row">
|
||||
<text class="room-title">{{ roomData.name }}</text>
|
||||
<view class="price-badge">
|
||||
<text class="price-amount">¥{{ roomData.price }}</text>
|
||||
<text class="price-amount">¥{{ calculatedPrice > 0 ? calculatedPrice.toFixed(2) : roomData.price }}</text>
|
||||
<text class="price-label">/晚</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -77,9 +77,6 @@
|
||||
:key="index"
|
||||
class="highlight-item"
|
||||
>
|
||||
<view class="highlight-icon-wrapper">
|
||||
<u-icon :name="highlight.icon" :size="32" color="#667eea" />
|
||||
</view>
|
||||
<text class="highlight-text">{{ highlight.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -94,9 +91,6 @@
|
||||
:key="index"
|
||||
class="facility-chip"
|
||||
>
|
||||
<view class="facility-icon">
|
||||
<u-icon :name="getFacilityIcon(facility)" :size="20" color="#667eea" />
|
||||
</view>
|
||||
<text class="facility-text">{{ facility }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -185,7 +179,7 @@
|
||||
<view class="booking-bar">
|
||||
<view class="booking-left">
|
||||
<view class="booking-price">
|
||||
<text class="booking-amount">¥{{ roomData.price }}</text>
|
||||
<text class="booking-amount">¥{{ calculatedPrice > 0 ? calculatedPrice.toFixed(2) : roomData.price }}</text>
|
||||
<text class="booking-unit">/晚</text>
|
||||
</view>
|
||||
<text v-if="stockText" class="booking-stock">{{ stockText }}</text>
|
||||
@@ -223,32 +217,14 @@ const ROOM_TYPE_LABELS: Record<string, string> = {
|
||||
hostel: '青旅床位',
|
||||
};
|
||||
|
||||
// 设施图标映射
|
||||
const FACILITY_ICONS: Record<string, string> = {
|
||||
'WiFi': 'wifi',
|
||||
'空调': 'snow',
|
||||
'热水': 'droplet',
|
||||
'电视': 'play-circle',
|
||||
'冰箱': 'grid',
|
||||
'洗衣机': 'reload',
|
||||
'停车场': 'car',
|
||||
'电梯': 'arrow-up-circle',
|
||||
'厨房': 'cut',
|
||||
'阳台': 'flower',
|
||||
'泳池': 'water',
|
||||
'健身房': 'heart',
|
||||
'暖气': 'fire',
|
||||
'独立卫浴': 'droplet',
|
||||
'吹风机': 'wind',
|
||||
'衣柜': 'grid',
|
||||
'书桌': 'edit-pen',
|
||||
};
|
||||
|
||||
// 房间数据
|
||||
const roomId = ref(0);
|
||||
const roomData = ref<Partial<Room>>({});
|
||||
const todayStock = ref(0);
|
||||
const currentImageIndex = ref(0);
|
||||
const checkInDate = ref('');
|
||||
const checkOutDate = ref('');
|
||||
const calculatedPrice = ref(0);
|
||||
|
||||
// UI状态
|
||||
const showAllFacilities = ref(false);
|
||||
@@ -319,17 +295,22 @@ const roomHighlights = computed(() => {
|
||||
return highlights;
|
||||
});
|
||||
|
||||
// 获取设施图标
|
||||
function getFacilityIcon(facility: string): string {
|
||||
return FACILITY_ICONS[facility] || '✓';
|
||||
}
|
||||
|
||||
// 初始化
|
||||
function initializeFromUrl() {
|
||||
const pages = getCurrentPages();
|
||||
const currentPage = pages[pages.length - 1] as any;
|
||||
const options = currentPage.options || currentPage.$page?.options || {};
|
||||
roomId.value = Number(options.id) || 0;
|
||||
|
||||
// 如果没有传入日期,使用当天日期
|
||||
if (options.checkIn) {
|
||||
checkInDate.value = options.checkIn;
|
||||
} else {
|
||||
const today = new Date();
|
||||
checkInDate.value = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
checkOutDate.value = options.checkOut || '';
|
||||
}
|
||||
|
||||
// 获取房间详情
|
||||
@@ -339,12 +320,41 @@ async function fetchRoomDetail() {
|
||||
try {
|
||||
const response = await getRoomDetail(roomId.value);
|
||||
roomData.value = response.data || {};
|
||||
|
||||
// 如果有入住日期,计算日历价格
|
||||
if (checkInDate.value) {
|
||||
await calculatePriceFromCalendar();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取房间详情失败:', error);
|
||||
uni.showToast({ title: '加载失败,请重试', icon: 'none' });
|
||||
}
|
||||
}
|
||||
|
||||
// 根据日历计算价格
|
||||
async function calculatePriceFromCalendar() {
|
||||
if (!roomId.value || !checkInDate.value) return;
|
||||
|
||||
try {
|
||||
// 如果没有退房日期,使用入住日期的第二天
|
||||
const checkOut = checkOutDate.value || (() => {
|
||||
const date = new Date(checkInDate.value);
|
||||
date.setDate(date.getDate() + 1);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
})();
|
||||
|
||||
const response = await getRoomCalendar(roomId.value, checkInDate.value, checkOut);
|
||||
const calendarData = response.data || [];
|
||||
|
||||
if (calendarData.length > 0) {
|
||||
// 使用入住日期当天的价格
|
||||
calculatedPrice.value = Number(calendarData[0].price || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('计算价格失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取今日库存
|
||||
async function fetchTodayStock() {
|
||||
if (!roomId.value) return;
|
||||
|
||||
@@ -133,9 +133,6 @@
|
||||
:class="['facility-item', { selected: form.facilities.includes(f) }]"
|
||||
@tap="toggleFacility(f)"
|
||||
>
|
||||
<view class="facility-icon">
|
||||
<u-icon :name="getFacilityIcon(f)" :size="40" :color="form.facilities.includes(f) ? '#4263eb' : '#495057'" />
|
||||
</view>
|
||||
<text class="facility-name">{{ f }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -212,24 +209,6 @@ const form = ref({
|
||||
description: '',
|
||||
});
|
||||
|
||||
function getFacilityIcon(facility: string): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
'WiFi': 'wifi',
|
||||
'空调': 'snow',
|
||||
'热水': 'droplet',
|
||||
'电视': 'play-circle',
|
||||
'冰箱': 'grid',
|
||||
'洗衣机': 'reload',
|
||||
'停车场': 'car',
|
||||
'电梯': 'arrow-up-circle',
|
||||
'厨房': 'cut',
|
||||
'阳台': 'flower',
|
||||
'泳池': 'water',
|
||||
'健身房': 'heart',
|
||||
};
|
||||
return iconMap[facility] || 'checkmark';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages();
|
||||
const page = pages[pages.length - 1] as any;
|
||||
@@ -651,11 +630,10 @@ async function handleSubmit() {
|
||||
.facility-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12rpx;
|
||||
padding: 20rpx 16rpx;
|
||||
padding: 24rpx 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
justify-content: center;
|
||||
border: 2rpx solid #e9ecef;
|
||||
transition: all 0.2s;
|
||||
|
||||
@@ -674,14 +652,8 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
.facility-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.facility-name {
|
||||
font-size: 24rpx;
|
||||
font-size: 26rpx;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
.login-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #0a0e27;
|
||||
}
|
||||
|
||||
/* 左侧装饰区域 - 深色科技风 */
|
||||
.login-left {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, #1a1f3a 0%, #0a0e27 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.decoration-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
animation: float 20s infinite ease-in-out;
|
||||
border: 1px solid rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.decoration-circle.circle-1 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
top: -150px;
|
||||
right: -150px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.decoration-circle.circle-2 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
bottom: -100px;
|
||||
left: -100px;
|
||||
animation-delay: 5s;
|
||||
}
|
||||
|
||||
.decoration-circle.circle-3 {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
top: 50%;
|
||||
left: 15%;
|
||||
animation-delay: 10s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-40px) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 40px;
|
||||
box-shadow: 0 12px 48px rgba(102, 126, 234, 0.3);
|
||||
border: 2px solid rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 56px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 16px;
|
||||
color: #fff;
|
||||
letter-spacing: 3px;
|
||||
text-shadow: 0 4px 12px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 22px;
|
||||
margin: 0 0 80px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.brand-features {
|
||||
display: flex;
|
||||
gap: 50px;
|
||||
justify-content: center;
|
||||
margin-top: 80px;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.25) 0%, rgba(118, 75, 162, 0.25) 100%);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
border: 2px solid rgba(102, 126, 234, 0.4);
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
font-size: 17px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 右侧登录表单 */
|
||||
.login-right {
|
||||
width: 520px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 50px;
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.login-form-wrapper {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 56px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: #0a0e27;
|
||||
margin: 0 0 16px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 17px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
border: 2px solid #e8e8e8;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.login-input:hover {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.login-input:focus,
|
||||
.login-input.ant-input-focused {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.login-input input {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
color: #999;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||||
transition: all 0.3s;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.login-button:active {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 48px;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.login-left {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-right {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login-right {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-form-wrapper {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Form, Input, Button, Card, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { Form, Input, Button, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
import { loginByPassword } from '@/api/admin';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
import './Login.css';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -25,22 +26,86 @@ const Login: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#001529' }}>
|
||||
<Card style={{ width: 400 }} title={<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 600, color: '#1890ff' }}>平台管理后台</div>}>
|
||||
<Form onFinish={onFinish} size="large">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="管理员用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<div className="login-container">
|
||||
{/* 左侧装饰区域 */}
|
||||
<div className="login-left">
|
||||
<div className="login-decoration">
|
||||
<div className="decoration-circle circle-1"></div>
|
||||
<div className="decoration-circle circle-2"></div>
|
||||
<div className="decoration-circle circle-3"></div>
|
||||
</div>
|
||||
<div className="login-brand">
|
||||
<div className="brand-icon">
|
||||
<SafetyOutlined style={{ fontSize: 64, color: '#fff' }} />
|
||||
</div>
|
||||
<h1 className="brand-title">品住会</h1>
|
||||
<p className="brand-subtitle">平台管理后台</p>
|
||||
<div className="brand-features">
|
||||
<div className="feature-item">
|
||||
<div className="feature-icon">✓</div>
|
||||
<div className="feature-text">商家管理</div>
|
||||
</div>
|
||||
<div className="feature-item">
|
||||
<div className="feature-icon">✓</div>
|
||||
<div className="feature-text">订单监控</div>
|
||||
</div>
|
||||
<div className="feature-item">
|
||||
<div className="feature-icon">✓</div>
|
||||
<div className="feature-text">数据分析</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧登录表单 */}
|
||||
<div className="login-right">
|
||||
<div className="login-form-wrapper">
|
||||
<div className="login-header">
|
||||
<h2 className="login-title">欢迎回来</h2>
|
||||
<p className="login-subtitle">请登录您的管理员账号</p>
|
||||
</div>
|
||||
|
||||
<Form onFinish={onFinish} size="large" className="login-form">
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined className="input-icon" />}
|
||||
placeholder="管理员用户名"
|
||||
className="login-input"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined className="input-icon" />}
|
||||
placeholder="密码"
|
||||
className="login-input"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
className="login-button"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className="login-footer">
|
||||
<p className="footer-text">© 2026 品住会. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,8 +12,8 @@ REDIS_PASSWORD=
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your_jwt_secret_key_change_in_production
|
||||
JWT_EXPIRES_IN=2h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
JWT_EXPIRES_IN=30d
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
|
||||
# 应用配置
|
||||
PORT=3000
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
RegisterDto,
|
||||
SendCodeDto,
|
||||
WechatLoginDto,
|
||||
WechatPhoneLoginDto,
|
||||
} from './dto/auth.dto';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
|
||||
@@ -39,6 +40,12 @@ export class AuthController {
|
||||
return this.authService.loginByWechat(dto);
|
||||
}
|
||||
|
||||
@Post('login/wechat-phone')
|
||||
@ApiOperation({ summary: '微信手机号一键登录' })
|
||||
async loginByWechatPhone(@Body() dto: WechatPhoneLoginDto) {
|
||||
return this.authService.loginByWechatPhone(dto);
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '用户注册' })
|
||||
async register(@Body() dto: RegisterDto) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
import { User } from '@/entities/user.entity';
|
||||
import { UserAccount } from '@/entities/user-account.entity';
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
LoginByPasswordDto,
|
||||
RegisterDto,
|
||||
WechatLoginDto,
|
||||
WechatPhoneLoginDto,
|
||||
} from './dto/auth.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -84,6 +86,66 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async loginByWechatPhone(dto: WechatPhoneLoginDto) {
|
||||
try {
|
||||
// 1. 调用微信接口获取 session_key
|
||||
const wechatData = await this.getWechatOpenid(dto.code);
|
||||
|
||||
if (!wechatData.session_key) {
|
||||
throw new BadRequestException('获取微信会话失败');
|
||||
}
|
||||
|
||||
// 2. 解密手机号
|
||||
const phoneData = this.decryptWechatData(
|
||||
dto.encryptedData,
|
||||
dto.iv,
|
||||
wechatData.session_key,
|
||||
);
|
||||
|
||||
if (!phoneData || !phoneData.phoneNumber) {
|
||||
throw new BadRequestException('解密手机号失败');
|
||||
}
|
||||
|
||||
const phone = phoneData.phoneNumber;
|
||||
this.logger.log(`微信手机号登录: phone=${phone}, openid=${wechatData.openid}`);
|
||||
|
||||
// 3. 查找或创建用户
|
||||
let user = await this.userRepo.findOne({ where: { phone } });
|
||||
|
||||
if (!user) {
|
||||
// 自动创建新用户
|
||||
user = this.userRepo.create({
|
||||
phone,
|
||||
wechatOpenid: wechatData.openid,
|
||||
wechatUnionid: wechatData.unionid,
|
||||
nickname: `用户${phone.slice(-4)}`,
|
||||
});
|
||||
await this.userRepo.save(user);
|
||||
this.logger.log(`微信手机号自动注册: phone=${phone}`);
|
||||
|
||||
// 自动创建用户账户
|
||||
await this.createUserAccount(user.id);
|
||||
} else {
|
||||
// 更新微信信息
|
||||
if (wechatData.openid && !user.wechatOpenid) {
|
||||
user.wechatOpenid = wechatData.openid;
|
||||
}
|
||||
if (wechatData.unionid && !user.wechatUnionid) {
|
||||
user.wechatUnionid = wechatData.unionid;
|
||||
}
|
||||
user.lastLoginAt = new Date();
|
||||
await this.userRepo.save(user);
|
||||
}
|
||||
|
||||
return this.generateToken(user);
|
||||
} catch (error) {
|
||||
this.logger.error('微信手机号登录失败', error);
|
||||
throw new BadRequestException(
|
||||
error.message || '微信手机号登录失败,请重试',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
const existing = await this.userRepo.findOne({
|
||||
where: { phone: dto.phone },
|
||||
@@ -146,7 +208,7 @@ export class AuthService {
|
||||
this.logger.log(`[DEV] 模拟微信登录: code=${code}`);
|
||||
return {
|
||||
openid: `dev_openid_${Date.now()}`,
|
||||
session_key: 'dev_session_key',
|
||||
session_key: 'dev_session_key_1234567890abcdef',
|
||||
unionid: `dev_unionid_${Date.now()}`,
|
||||
};
|
||||
}
|
||||
@@ -181,6 +243,46 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
private decryptWechatData(
|
||||
encryptedData: string,
|
||||
iv: string,
|
||||
sessionKey: string,
|
||||
): any {
|
||||
try {
|
||||
// 开发模式:返回模拟数据
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (isDev) {
|
||||
this.logger.log('[DEV] 模拟解密微信手机号');
|
||||
return {
|
||||
phoneNumber: '13800138000',
|
||||
purePhoneNumber: '13800138000',
|
||||
countryCode: '86',
|
||||
};
|
||||
}
|
||||
|
||||
// 生产模式:解密数据
|
||||
const sessionKeyBuffer = Buffer.from(sessionKey, 'base64');
|
||||
const encryptedDataBuffer = Buffer.from(encryptedData, 'base64');
|
||||
const ivBuffer = Buffer.from(iv, 'base64');
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-128-cbc',
|
||||
sessionKeyBuffer,
|
||||
ivBuffer,
|
||||
);
|
||||
decipher.setAutoPadding(true);
|
||||
|
||||
let decrypted = decipher.update(encryptedDataBuffer, undefined, 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
const result = JSON.parse(decrypted);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error('解密微信数据失败', error);
|
||||
throw new BadRequestException('解密失败');
|
||||
}
|
||||
}
|
||||
|
||||
private async validateByPhone(phone: string, code: string): Promise<User> {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const devCode = '123456';
|
||||
|
||||
@@ -37,6 +37,20 @@ export class WechatLoginDto {
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export class WechatPhoneLoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'code不能为空' })
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'encryptedData不能为空' })
|
||||
encryptedData: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'iv不能为空' })
|
||||
iv: string;
|
||||
}
|
||||
|
||||
export class RegisterDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '手机号不能为空' })
|
||||
|
||||
@@ -80,6 +80,13 @@ export class OrderService {
|
||||
|
||||
const roomAmount = totalRoomPrice * roomCount;
|
||||
|
||||
// 计算平台服务费(基于房费)
|
||||
const serviceFeeRate = await this.configService.getServiceFeeRate();
|
||||
const serviceFee = Math.round(roomAmount * serviceFeeRate * 100) / 100;
|
||||
|
||||
// 订单总额 = 房费 + 服务费
|
||||
const totalAmount = roomAmount + serviceFee;
|
||||
|
||||
// 处理优惠券
|
||||
let couponDiscount = 0;
|
||||
let userCouponId: number | null = null;
|
||||
@@ -99,18 +106,16 @@ export class OrderService {
|
||||
throw new BadRequestException('优惠券不可用');
|
||||
}
|
||||
|
||||
// 计算优惠金额
|
||||
// 计算优惠金额(优惠券只能抵扣房费部分,不能抵扣服务费)
|
||||
couponDiscount = this.couponService.calculateDiscount(userCoupon.coupon, roomAmount);
|
||||
userCouponId = userCoupon.id;
|
||||
}
|
||||
|
||||
const totalAmount = roomAmount;
|
||||
// 实付金额 = 订单总额 - 优惠券折扣
|
||||
const payAmount = totalAmount - couponDiscount;
|
||||
|
||||
// 计算软件服务费和商家收入
|
||||
const serviceFeeRate = await this.configService.getServiceFeeRate();
|
||||
const serviceFee = Math.round(payAmount * serviceFeeRate * 100) / 100;
|
||||
const merchantIncome = Math.round((payAmount - serviceFee) * 100) / 100;
|
||||
// 商家收入 = 房费 - 优惠券折扣
|
||||
const merchantIncome = Math.round((roomAmount - couponDiscount) * 100) / 100;
|
||||
|
||||
const now = new Date();
|
||||
const orderNo = [
|
||||
|
||||
@@ -19,4 +19,14 @@ export class RoomController {
|
||||
async findOne(@Param('id') id: number) {
|
||||
return this.roomService.findById(id);
|
||||
}
|
||||
|
||||
@Get(':id/calendar')
|
||||
@ApiOperation({ summary: '获取房源日历' })
|
||||
async getCalendar(
|
||||
@Param('id') id: number,
|
||||
@Query('startDate') startDate: string,
|
||||
@Query('endDate') endDate: string,
|
||||
) {
|
||||
return this.roomService.getRoomCalendar(id, startDate, endDate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { Repository, LessThan, MoreThanOrEqual } from 'typeorm';
|
||||
import { Room } from '@/entities/room.entity';
|
||||
import { RoomCalendar } from '@/entities/room-calendar.entity';
|
||||
import { QueryAvailableRoomsDto } from './dto/room.dto';
|
||||
@@ -23,6 +23,18 @@ export class RoomService {
|
||||
return room;
|
||||
}
|
||||
|
||||
async getRoomCalendar(roomId: number, startDate: string, endDate: string) {
|
||||
const calendar = await this.calendarRepo.find({
|
||||
where: {
|
||||
roomId: Number(roomId),
|
||||
date: MoreThanOrEqual(startDate) && LessThan(endDate),
|
||||
},
|
||||
order: { date: 'ASC' },
|
||||
});
|
||||
|
||||
return calendar;
|
||||
}
|
||||
|
||||
async findAvailable(query: QueryAvailableRoomsDto) {
|
||||
const {
|
||||
merchantId,
|
||||
@@ -37,7 +49,6 @@ export class RoomService {
|
||||
|
||||
const qb = this.roomRepo
|
||||
.createQueryBuilder('room')
|
||||
.leftJoinAndSelect('room.merchant', 'merchant')
|
||||
.where('room.status = :status', { status: 'on_sale' })
|
||||
.andWhere('room.audit_status = :auditStatus', { auditStatus: 'approved' });
|
||||
|
||||
@@ -51,22 +62,51 @@ export class RoomService {
|
||||
qb.andWhere('room.max_guests >= :totalGuests', { totalGuests });
|
||||
|
||||
// 如果提供了日期,检查可用性
|
||||
let roomPriceMap = new Map<number, number>(); // 存储每个房间的实际价格
|
||||
|
||||
if (checkIn && checkOut) {
|
||||
// 查询在该日期范围内有库存的房间
|
||||
const availableRoomIds = await this.calendarRepo
|
||||
// 计算需要的天数
|
||||
const checkInDate = new Date(checkIn);
|
||||
const checkOutDate = new Date(checkOut);
|
||||
const daysDiff = Math.ceil((checkOutDate.getTime() - checkInDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// 查询日历记录
|
||||
const calendarRecords = await this.calendarRepo
|
||||
.createQueryBuilder('cal')
|
||||
.select('DISTINCT cal.room_id', 'roomId')
|
||||
.where('cal.date >= :checkIn', { checkIn })
|
||||
.andWhere('cal.date < :checkOut', { checkOut })
|
||||
.andWhere('cal.status = :status', { status: 'available' })
|
||||
.andWhere('(cal.stock - cal.sold) >= :roomCount', { roomCount })
|
||||
.groupBy('cal.room_id')
|
||||
.having('COUNT(*) = DATEDIFF(:checkOut, :checkIn)', { checkIn, checkOut })
|
||||
.getRawMany();
|
||||
.getMany();
|
||||
|
||||
// 按房间ID分组,检查每个房间是否在所有日期都有足够库存,并计算总价
|
||||
const roomAvailability = new Map<number, number>();
|
||||
const roomTotalPrice = new Map<number, number>();
|
||||
|
||||
for (const record of calendarRecords) {
|
||||
const availableStock = record.stock - record.sold;
|
||||
if (availableStock >= roomCount) {
|
||||
const count = roomAvailability.get(record.roomId) || 0;
|
||||
roomAvailability.set(record.roomId, count + 1);
|
||||
|
||||
// 累加价格
|
||||
const totalPrice = roomTotalPrice.get(record.roomId) || 0;
|
||||
roomTotalPrice.set(record.roomId, totalPrice + Number(record.price));
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选出在所有日期都有库存的房间
|
||||
const availableRoomIds = Array.from(roomAvailability.entries())
|
||||
.filter(([_, count]) => count >= daysDiff)
|
||||
.map(([roomId, _]) => roomId);
|
||||
|
||||
if (availableRoomIds.length > 0) {
|
||||
const roomIds = availableRoomIds.map(item => item.roomId);
|
||||
qb.andWhere('room.id IN (:...roomIds)', { roomIds });
|
||||
qb.andWhere('room.id IN (:...roomIds)', { roomIds: availableRoomIds });
|
||||
|
||||
// 计算每个房间的平均价格(用于显示)
|
||||
availableRoomIds.forEach(roomId => {
|
||||
const totalPrice = roomTotalPrice.get(roomId) || 0;
|
||||
roomPriceMap.set(roomId, totalPrice / daysDiff);
|
||||
});
|
||||
} else {
|
||||
// 没有可用房间,返回空结果
|
||||
return { list: [], total: 0, page, pageSize };
|
||||
@@ -78,6 +118,35 @@ export class RoomService {
|
||||
|
||||
const [list, total] = await qb.getManyAndCount();
|
||||
|
||||
// 批量加载 merchant 关系
|
||||
if (list.length > 0) {
|
||||
const merchantIds = [...new Set(list.map(room => room.merchantId))];
|
||||
const merchants = await this.roomRepo.manager
|
||||
.createQueryBuilder()
|
||||
.from('merchants', 'merchant')
|
||||
.where('merchant.id IN (:...merchantIds)', { merchantIds })
|
||||
.getMany();
|
||||
|
||||
const merchantMap = new Map(merchants.map(m => [m.id, m]));
|
||||
|
||||
// 转换为普通对象并添加 currentPrice
|
||||
const result = list.map(room => {
|
||||
const roomData = {
|
||||
...room,
|
||||
merchant: merchantMap.get(room.merchantId),
|
||||
};
|
||||
|
||||
// 如果有日历价格,添加 currentPrice 字段
|
||||
if (roomPriceMap.has(room.id)) {
|
||||
roomData['currentPrice'] = roomPriceMap.get(room.id);
|
||||
}
|
||||
|
||||
return roomData;
|
||||
});
|
||||
|
||||
return { list: result, total, page, pageSize };
|
||||
}
|
||||
|
||||
return { list, total, page, pageSize };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,18 @@ export class QueryCouponDto {
|
||||
@IsOptional()
|
||||
@IsEnum(['platform', 'merchant', 'room'])
|
||||
scope?: 'platform' | 'merchant' | 'room';
|
||||
|
||||
@ApiProperty({ description: '商家ID', required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
merchantId?: number;
|
||||
|
||||
@ApiProperty({ description: '房源ID', required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
roomId?: number;
|
||||
}
|
||||
|
||||
export class ReceiveCouponDto {
|
||||
|
||||
Reference in New Issue
Block a user