feat(preview): 添加PC端预览页面和发货单预览功能

- 添加了PC端预览页面,支持多种设备模拟预览
- 在采购审核页面集成了发货单预览按钮
- 重构了发货表单组件,移除ref暴露机制
- 更新订单转换器以支持发货单数据转换
- 在发票上传页面添加供应商选择器组件
- 添加初始车次号相关字段到API类型定义
- 将发货表单中的Input组件替换为TextArea组件
- 修复了订单项ID匹配逻辑错误
This commit is contained in:
shenyifei 2025-12-27 13:46:57 +08:00
parent d6e3afd100
commit fffb0c7269
11 changed files with 880 additions and 88 deletions

View File

@ -4,6 +4,7 @@ import {
DatePicker, DatePicker,
Input, Input,
PickerOption, PickerOption,
TextArea,
Toast, Toast,
} from "@nutui/nutui-react-taro"; } from "@nutui/nutui-react-taro";
import dayjs from "dayjs"; import dayjs from "dayjs";
@ -22,7 +23,6 @@ export interface Step1FormRef {
const Step1Form = forwardRef<Step1FormRef, Step1FormProps>((props, ref) => { const Step1Form = forwardRef<Step1FormRef, Step1FormProps>((props, ref) => {
const { moduleList, orderShip, setOrderShip, readOnly = false } = props; const { moduleList, orderShip, setOrderShip, readOnly = false } = props;
console.log("moduleList", moduleList, orderShip);
// 当天和未来10天 // 当天和未来10天
const startDate = new Date(); const startDate = new Date();
@ -288,7 +288,7 @@ const Step1Form = forwardRef<Step1FormRef, Step1FormProps>((props, ref) => {
<View <View
className={`flex h-10 w-full items-center rounded-md ${formErrors.remark ? "border-4 border-red-500" : "border-4 border-gray-300"}`} className={`flex h-10 w-full items-center rounded-md ${formErrors.remark ? "border-4 border-red-500" : "border-4 border-gray-300"}`}
> >
<Input <TextArea
disabled={readOnly} disabled={readOnly}
value={orderShip?.remark || ""} value={orderShip?.remark || ""}
onChange={(value) => { onChange={(value) => {
@ -323,7 +323,6 @@ const Step1Form = forwardRef<Step1FormRef, Step1FormProps>((props, ref) => {
placeholder={ placeholder={
column.fieldProps?.placeholder || `${column.title}` column.fieldProps?.placeholder || `${column.title}`
} }
type="text"
className="flex-1" className="flex-1"
/> />
</View> </View>
@ -385,7 +384,7 @@ const Step1Form = forwardRef<Step1FormRef, Step1FormProps>((props, ref) => {
orderShip?.orderShipItemList?.map( orderShip?.orderShipItemList?.map(
(item: any) => { (item: any) => {
if ( if (
item.itemId === item.orderShipItemId ===
orderShipItem.orderShipItemId orderShipItem.orderShipItemId
) { ) {
return { return {

View File

@ -52,7 +52,6 @@ export { default as MaterialCostSection } from "./section/MaterialCostSection";
export { default as ProductionAdvanceSection } from "./section/ProductionAdvanceSection"; export { default as ProductionAdvanceSection } from "./section/ProductionAdvanceSection";
export { default as WorkerAdvanceSection } from "./section/WorkerAdvanceSection"; export { default as WorkerAdvanceSection } from "./section/WorkerAdvanceSection";
export { default as DeliveryFormSection } from "./section/DeliveryFormSection"; export { default as DeliveryFormSection } from "./section/DeliveryFormSection";
export type { DeliveryFormSectionRef } from "./section/DeliveryFormSection";
export { default as PurchaseFormSection } from "./section/PurchaseFormSection"; export { default as PurchaseFormSection } from "./section/PurchaseFormSection";
export { default as PurchaseStep1Form } from "./document/Step1Form"; export { default as PurchaseStep1Form } from "./document/Step1Form";

View File

@ -1,52 +1,40 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { useEffect, useState } from "react";
import { DeliveryStep1Form, DeliveryStep2Preview } from "@/components"; import { DeliveryStep1Form, DeliveryStep2Preview, Icon } from "@/components";
import { convertOrderShipVOToExamplesFormat } from "@/utils"; import { convertOrderShipVOToExamplesFormat } from "@/utils";
import { business } from "@/services"; import { business } from "@/services";
import { Popup } from "@nutui/nutui-react-taro"; import { Button, Popup } from "@nutui/nutui-react-taro";
import { View } from "@tarojs/components"; import { View } from "@tarojs/components";
export interface DeliveryFormSectionRef { export default function DeliveryFormSection(props: {
handlePreview: () => void; orderVO: BusinessAPI.OrderVO;
} onChange?: (orderDealer: BusinessAPI.OrderVO) => void;
readOnly?: boolean;
export default forwardRef< }) {
DeliveryFormSectionRef,
{
orderVO: BusinessAPI.OrderVO;
onChange?: (orderDealer: BusinessAPI.OrderVO) => void;
readOnly?: boolean;
}
>((props, ref) => {
const { orderVO, onChange, readOnly } = props; const { orderVO, onChange, readOnly } = props;
const [moduleList, setModuleList] = useState<any[]>([]); const [template, setTemplate] = useState<any[]>([]);
const [previewVisible, setPreviewVisible] = useState(false); const [previewVisible, setPreviewVisible] = useState(false);
const orderShip = orderVO.orderShipList[0]; const orderShip = orderVO.orderShipList[0];
const init = async (orderVO: BusinessAPI.OrderVO) => { const init = async (orderId: BusinessAPI.OrderVO["orderId"]) => {
const { data } = await business.dealer.showDealer({ const { data } = await business.dealer.showDealer({
dealerShowQry: { dealerShowQry: {
dealerId: orderVO.orderDealer.dealerId, dealerId: orderId,
}, },
}); });
if (data.data?.deliveryTemplate!) { if (data.data?.deliveryTemplate!) {
const template = JSON.parse(data.data?.deliveryTemplate!); const template = JSON.parse(data.data?.deliveryTemplate!);
// 将 orderShipVO 转换为 examples 的数据格式,然后再替换 moduleList 里面的 config 数据 setTemplate(template);
const convertedData = convertOrderShipVOToExamplesFormat(orderVO);
const updatedTemplate = await updateTemplateConfig(
template,
convertedData,
);
setModuleList(updatedTemplate);
} else {
setModuleList([]);
} }
}; };
// 将 orderShipVO 转换为 examples 的数据格式,然后再替换 moduleList 里面的 config 数据
const convertedData = convertOrderShipVOToExamplesFormat(orderVO);
// 更新模板配置 // 更新模板配置
const updateTemplateConfig = async (template: any[], data: any) => { const updateTemplateConfig = (template: any[], data: any) => {
let templateList: any[] = []; let templateList: any[] = [];
template.map(async (module: any) => { template.map(async (module: any) => {
let newModule = { ...module }; let newModule = { ...module };
@ -59,26 +47,26 @@ export default forwardRef<
return templateList; return templateList;
}; };
useEffect(() => {
if (orderVO.orderDealer.dealerId) {
init(orderVO.orderDealer.dealerId);
}
}, [orderVO.orderDealer.dealerId]);
// 预览发货单操作 // 预览发货单操作
const handlePreview = () => { const handlePreview = () => {
setPreviewVisible(true); setPreviewVisible(true);
}; };
useEffect(() => { if (!template) {
if (orderShip) {
init(orderVO);
}
}, [orderVO.orderDealer.dealerId, orderShip]);
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
handlePreview,
}));
if (!orderShip) {
return; return;
} }
const moduleList = updateTemplateConfig(template, convertedData);
console.log("moduleList", moduleList);
console.log("orderShip", orderShip);
return ( return (
<> <>
<DeliveryStep1Form <DeliveryStep1Form
@ -93,6 +81,20 @@ export default forwardRef<
}} }}
/> />
{/* 预览发货单 */}
<View className={"flex-1"}>
<Button
icon={<Icon name="eye" size={18} />}
size={"large"}
type="primary"
fill="outline"
block
onClick={handlePreview}
>
</Button>
</View>
{/* 预览发货单 */} {/* 预览发货单 */}
<Popup <Popup
duration={150} duration={150}
@ -115,4 +117,4 @@ export default forwardRef<
</Popup> </Popup>
</> </>
); );
}); }

View File

@ -2,7 +2,7 @@ import hocAuth from "@/hocs/auth";
import { CommonComponent } from "@/types/typings"; import { CommonComponent } from "@/types/typings";
import Taro from "@tarojs/taro"; import Taro from "@tarojs/taro";
import { business } from "@/services"; import { business } from "@/services";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { View } from "@tarojs/components"; import { View } from "@tarojs/components";
import { import {
ActionSheet, ActionSheet,
@ -19,7 +19,6 @@ import {
CostSummarySection, CostSummarySection,
DealerInfoSection, DealerInfoSection,
DeliveryFormSection, DeliveryFormSection,
DeliveryFormSectionRef,
EmptyBoxInfoSection, EmptyBoxInfoSection,
Icon, Icon,
MarketPriceSection, MarketPriceSection,
@ -243,23 +242,6 @@ export default hocAuth(function Page(props: CommonComponent) {
console.log("orderVO", orderVO); console.log("orderVO", orderVO);
// DeliveryFormSection ref
const deliveryFormRef = useRef<DeliveryFormSectionRef>(null);
const handlePreview = () => {
// 调用 DeliveryFormSection 组件的 handlePreview 方法
if (deliveryFormRef.current) {
deliveryFormRef.current.handlePreview();
} else {
// 如果 ref 不存在,提示用户
Toast.show("toast", {
icon: "fail",
title: "提示",
content: "发货单组件未加载,请稍后再试",
});
}
};
const [originPrincipal, setOriginPrincipal] = useState(""); const [originPrincipal, setOriginPrincipal] = useState("");
// 暂存和提交审核的Dialog状态 // 暂存和提交审核的Dialog状态
@ -848,13 +830,15 @@ export default hocAuth(function Page(props: CommonComponent) {
) )
} }
orderVO={orderVO} orderVO={orderVO}
onChange={setOrderVO} onChange={(orderVO: BusinessAPI.OrderVO) => {
setOrderVO({
...orderVO,
orderShipList: [convertOrderToOrderShip(orderVO)],
});
}}
// @ts-ignore // @ts-ignore
costList={costList} costList={costList}
calculator={calculator} calculator={calculator}
ref={
section.name === "deliveryForm" ? deliveryFormRef : null
}
/> />
</View> </View>
</View> </View>
@ -973,9 +957,6 @@ export default hocAuth(function Page(props: CommonComponent) {
title="更多操作" title="更多操作"
visible={moreActionVisible} visible={moreActionVisible}
options={[ options={[
{
name: "预览发货单据",
},
{ {
name: "暂存审核", name: "暂存审核",
}, },
@ -989,9 +970,6 @@ export default hocAuth(function Page(props: CommonComponent) {
if (item.name === "暂存审核") { if (item.name === "暂存审核") {
// 暂存操作 // 暂存操作
handleSave(); handleSave();
} else if (item.name === "预览发货单据") {
// 预览发货单据
handlePreview();
} else if (item.name === "返回首页") { } else if (item.name === "返回首页") {
// 返回首页 // 返回首页
Taro.redirectTo({ url: "/pages/main/index/index" }); Taro.redirectTo({ url: "/pages/main/index/index" });

View File

@ -133,13 +133,11 @@ export default hocAuth(function Page(props: CommonComponent) {
const template = JSON.parse(deliveryTemplate); const template = JSON.parse(deliveryTemplate);
// 将 orderShipVO 转换为 examples 的数据格式,然后再替换 moduleList 里面的 config 数据 // 将 orderShipVO 转换为 examples 的数据格式,然后再替换 moduleList 里面的 config 数据
const convertedData = convertOrderShipVOToExamplesFormat(orderVO); const convertedData = convertOrderShipVOToExamplesFormat(orderVO);
console.log("convertedData", convertedData);
const updatedTemplate = await updateTemplateConfig( const updatedTemplate = await updateTemplateConfig(
template, template,
convertedData, convertedData,
orderVO, orderVO,
); );
console.log("updatedTemplate", updatedTemplate);
setModuleList(updatedTemplate); setModuleList(updatedTemplate);
}; };
@ -241,6 +239,7 @@ export default hocAuth(function Page(props: CommonComponent) {
data: { data: picData }, data: { data: picData },
} = await poster.poster.postApiV1Poster({ } = await poster.poster.postApiV1Poster({
html: template.generateHtmlString(), html: template.generateHtmlString(),
//@ts-ignore
format: "a4", format: "a4",
}); });

View File

@ -231,6 +231,33 @@ export default hocAuth(function Page(props: CommonComponent) {
return ( return (
<> <>
<View className="p-2.5">
<SupplierPicker
type={"FARMER"}
onFinish={(supplierVO) => {
setSupplierVO(supplierVO);
actionRef.current?.reload();
}}
trigger={
<View className={"flex flex-1 flex-row gap-2.5"}>
<View
className={`flex h-10 w-full items-center rounded-md border-4 border-gray-300`}
>
<View
className={
"flex flex-1 flex-row items-center justify-between px-5"
}
>
<View className={"text-sm"}>
{supplierVO?.name || "请选择瓜农"}
</View>
<Icon name={"chevron-down"} />
</View>
</View>
</View>
}
/>
</View>
<PageList<BusinessAPI.OrderSupplierVO, BusinessAPI.OrderSupplierPageQry> <PageList<BusinessAPI.OrderSupplierVO, BusinessAPI.OrderSupplierPageQry>
rowId={"orderSupplierId"} rowId={"orderSupplierId"}
itemHeight={100} itemHeight={100}

View File

@ -1336,6 +1336,10 @@ declare namespace BusinessAPI {
enableLoss?: boolean; enableLoss?: boolean;
/** 损耗金额 */ /** 损耗金额 */
lossAmount?: number; lossAmount?: number;
/** 是否启用初始车次号 */
enableInitialTrainNo?: boolean;
/** 初始车次号 */
initialTrainNo?: number;
}; };
type DealerDestroyCmd = { type DealerDestroyCmd = {
@ -1660,6 +1664,10 @@ declare namespace BusinessAPI {
enableLoss?: boolean; enableLoss?: boolean;
/** 损耗金额 */ /** 损耗金额 */
lossAmount?: number; lossAmount?: number;
/** 是否启用初始车次号 */
enableInitialTrainNo?: boolean;
/** 初始车次号 */
initialTrainNo?: number;
/** 发货单模板 */ /** 发货单模板 */
deliveryTemplate?: string; deliveryTemplate?: string;
}; };
@ -1711,6 +1719,10 @@ declare namespace BusinessAPI {
enableLoss?: boolean; enableLoss?: boolean;
/** 损耗金额 */ /** 损耗金额 */
lossAmount?: number; lossAmount?: number;
/** 是否启用初始车次号 */
enableInitialTrainNo?: boolean;
/** 初始车次号 */
initialTrainNo?: number;
}; };
type DealerWarehouseCreateCmd = { type DealerWarehouseCreateCmd = {

View File

@ -10,6 +10,8 @@ import { DecimalUtils } from "@/utils/classes/calculators/core/DecimalUtils";
export const convertOrderToOrderShip = ( export const convertOrderToOrderShip = (
orderVO: BusinessAPI.OrderVO, orderVO: BusinessAPI.OrderVO,
): BusinessAPI.OrderShip => { ): BusinessAPI.OrderShip => {
const oldOrderShip = orderVO.orderShipList[0];
// 添加一个辅助函数用于分组 // 添加一个辅助函数用于分组
const groupBy = <T>( const groupBy = <T>(
array: T[], array: T[],
@ -34,6 +36,8 @@ export const convertOrderToOrderShip = (
(supplier) => String(supplier.purchasePrice), (supplier) => String(supplier.purchasePrice),
); );
const orderShipId = oldOrderShip?.orderShipId || generateShortId();
const orderShipItemList: BusinessAPI.OrderShipItem[] = Object.entries( const orderShipItemList: BusinessAPI.OrderShipItem[] = Object.entries(
suppliersByPrice, suppliersByPrice,
).map(([price, suppliers]) => { ).map(([price, suppliers]) => {
@ -59,35 +63,55 @@ export const convertOrderToOrderShip = (
), ),
); );
const totalBoxCount = DecimalUtils.toDecimalPlaces(
suppliers.reduce(
(sum, supplier) =>
DecimalUtils.add(
sum,
supplier.orderPackageList?.reduce(
(sum, item) => DecimalUtils.add(sum, item.boxCount || 0),
0,
) || 0,
),
0,
),
);
const oldOrderShipItem = oldOrderShip?.orderShipItemList?.find(
(item) => item.unitPrice == parseFloat(price),
);
return { return {
itemId: generateShortId(), orderShipItemId: oldOrderShipItem?.orderShipItemId || generateShortId(),
orderShipId: "", // 将在创建发货单时填充 orderShipId: orderShipId, // 将在创建发货单时填充
orderId: orderVO.orderId, orderId: orderVO.orderId,
boxCount: totalBoxCount,
grossWeight: totalGrossWeight, grossWeight: totalGrossWeight,
boxWeight: totalGrossWeight - totalNetWeight, boxWeight: totalGrossWeight - totalNetWeight,
netWeight: totalNetWeight, netWeight: totalNetWeight,
unitPrice: parseFloat(price), unitPrice: parseFloat(price),
totalAmount: totalAmount, totalAmount: totalAmount,
watermelonGrade: "", // 需要手动填写 watermelonGrade: oldOrderShipItem?.watermelonGrade || "", // 需要手动填写
}; };
}); });
// 构建orderShip对象不转换费用信息 // 构建orderShip对象不转换费用信息
return { return {
orderShipId: generateShortId(), orderShipId: orderShipId,
orderId: orderVO.orderId, orderId: orderVO.orderId,
orderSn: "", orderSn: oldOrderShip?.orderSn,
watermelonGrade: "", watermelonGrade: oldOrderShip?.watermelonGrade || "",
dealerId: orderVO.orderVehicle?.dealerId!, dealerId: orderVO.orderVehicle?.dealerId!,
dealerName: orderVO.orderVehicle?.dealerName!, dealerName: orderVO.orderVehicle?.dealerName!,
companyId: orderVO.orderCompany?.companyId, companyId: orderVO.orderCompany?.companyId,
companyName: orderVO.orderCompany?.fullName, companyName: orderVO.orderCompany?.fullName,
shippingAddress: orderVO.orderVehicle?.origin, shippingAddress:
oldOrderShip?.shippingAddress || orderVO.orderVehicle?.origin,
receivingAddress: orderVO.orderVehicle?.destination, receivingAddress: orderVO.orderVehicle?.destination,
shippingDate: orderVO.orderVehicle?.deliveryTime, shippingDate: orderVO.orderVehicle?.deliveryTime,
estimatedArrivalDate: "", estimatedArrivalDate: oldOrderShip?.estimatedArrivalDate || "",
pdfUrl: "", pdfUrl: oldOrderShip?.pdfUrl || "",
picUrl: "", picUrl: oldOrderShip?.picUrl || "",
state: "WAIT_PAYMENT", state: "WAIT_PAYMENT",
orderShipItemList, orderShipItemList,
}; };

751
pc-wrapper.html Normal file
View File

@ -0,0 +1,751 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>新发雷盛西瓜管理系统</title>
<style>
body {
margin: 0;
padding: 20px;
background: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.container {
max-width: 1600px;
margin: 0 auto;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px 30px;
}
.header h1 {
margin: 0 0 10px 0;
font-size: 24px;
}
.header p {
margin: 0;
opacity: 0.9;
}
.content-wrapper {
display: flex;
padding: 20px;
gap: 30px;
align-items: flex-start;
}
/* 左侧设备选择器 */
.device-selector {
display: flex;
flex-direction: column;
gap: 10px;
padding: 15px;
background: #f8f9fa;
border-radius: 12px;
flex-shrink: 0;
width: 140px;
height: fit-content;
position: sticky;
top: 20px;
}
.device-selector-label {
font-weight: 600;
color: #495057;
font-size: 13px;
text-align: center;
padding-bottom: 8px;
border-bottom: 2px solid #dee2e6;
margin-bottom: 5px;
}
.device-buttons {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-btn {
padding: 10px 12px;
border: 2px solid #dee2e6;
background: white;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: #495057;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
}
.device-btn:hover {
border-color: #667eea;
color: #667eea;
transform: translateX(2px);
}
.device-btn.active {
background: #667eea;
border-color: #667eea;
color: white;
transform: translateX(4px);
}
.device-btn .device-icon {
font-size: 20px;
}
.device-btn span:last-child {
font-size: 12px;
}
/* 右侧内容区 */
.main-content {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 40px;
justify-content: center;
min-width: 0;
}
.frame-section {
flex: 1;
min-width: 400px;
max-width: 500px;
}
.qrcode-section {
flex: 1;
min-width: 300px;
max-width: 400px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* 通用设备框架样式 */
.frame-wrapper {
overflow: hidden;
position: relative;
background: white;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
margin: 0 auto;
transition: all 0.3s ease;
}
/* iPhone X */
.frame-wrapper.iphone-x {
width: 375px;
height: 812px;
border: 12px solid #333;
border-radius: 40px;
}
.frame-wrapper.iphone-x:before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 25px;
background: #333;
border-bottom-left-radius: 15px;
border-bottom-right-radius: 15px;
z-index: 10;
}
.frame-wrapper.iphone-x:after {
content: "";
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
width: 134px;
height: 5px;
background: #333;
border-radius: 3px;
z-index: 10;
}
/* iPhone 14 Pro Max */
.frame-wrapper.iphone-14-pro {
width: 430px;
height: 932px;
border: 8px solid #1c1c1e;
border-radius: 55px;
}
.frame-wrapper.iphone-14-pro:before {
content: "";
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
width: 140px;
height: 35px;
background: #1c1c1e;
border-radius: 20px;
z-index: 10;
}
.frame-wrapper.iphone-14-pro:after {
content: "";
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
width: 134px;
height: 5px;
background: #333;
border-radius: 3px;
z-index: 10;
}
/* Samsung Galaxy S21 */
.frame-wrapper.galaxy-s21 {
width: 384px;
height: 854px;
border: 10px solid #1a1a1a;
border-radius: 32px;
}
.frame-wrapper.galaxy-s21:before {
content: "";
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
width: 12px;
height: 12px;
background: #1a1a1a;
border-radius: 50%;
z-index: 10;
}
/* iPad */
.frame-wrapper.ipad {
width: 768px;
height: 1024px;
border: 18px solid #b4b4b4;
border-radius: 24px;
}
.frame-wrapper.ipad:before {
content: "";
position: absolute;
top: 18px;
left: 50%;
transform: translateX(-50%);
width: 12px;
height: 12px;
background: #333;
border-radius: 50%;
z-index: 10;
}
/* iPad Pro 11 */
.frame-wrapper.ipad-pro {
width: 834px;
height: 1194px;
border: 14px solid #b4b4b4;
border-radius: 18px;
}
/* Android 通用 */
.frame-wrapper.android {
width: 360px;
height: 760px;
border: 10px solid #2c2c2c;
border-radius: 24px;
}
.frame-wrapper.android:before {
content: "";
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 8px;
background: #2c2c2c;
border-radius: 4px;
z-index: 10;
}
/* 屏幕容器 */
.frame-wrapper .screen-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
z-index: 1;
}
.frame-wrapper.iphone-x .screen-container,
.frame-wrapper.iphone-14-pro .screen-container,
.frame-wrapper.galaxy-s21 .screen-container,
.frame-wrapper.android .screen-container {
border-radius: 28px;
}
.frame-wrapper.ipad .screen-container,
.frame-wrapper.ipad-pro .screen-container {
border-radius: 12px;
}
iframe {
width: 100%;
height: calc(100% - 84px); /* 减去TabBar高度 */
border: none;
}
/* 顶部TabBar */
.tabbar {
height: 50px;
background: rgba(248, 248, 248, 0.95);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
padding: 0 16px;
z-index: 20;
}
.tabbar-time {
font-size: 15px;
font-weight: 600;
color: #000;
margin-right: auto;
}
.tabbar-icons {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.tabbar-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.tabbar-icon svg {
width: 100%;
height: 100%;
fill: #000;
}
/* 底部安全区域占位 */
.safe-area-bottom {
height: 34px;
background: transparent;
z-index: 5;
}
.qrcode-container {
background: white;
padding: 25px;
border-radius: 16px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
text-align: center;
margin-bottom: 25px;
border: 1px solid #eee;
}
#qrcode {
width: 200px;
height: 200px;
margin: 15px auto;
padding: 10px;
background: white;
border-radius: 8px;
}
.qrcode-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.qrcode-desc {
color: #666;
font-size: 14px;
line-height: 1.5;
margin-bottom: 20px;
max-width: 300px;
}
.url-display {
background: #f8f9fa;
padding: 12px 15px;
border-radius: 8px;
font-size: 13px;
color: #555;
word-break: break-all;
margin-top: 15px;
border: 1px solid #e9ecef;
max-width: 300px;
}
.mobile-tips {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 15px;
color: #666;
font-size: 14px;
}
.mobile-tips svg {
width: 20px;
height: 20px;
fill: #667eea;
}
.tips {
padding: 20px;
text-align: center;
color: #666;
font-size: 14px;
background: #f8f9fa;
border-top: 1px solid #eee;
}
@media (max-width: 1100px) {
.content-wrapper {
flex-direction: column;
}
.device-selector {
width: 100%;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
position: static;
}
.device-buttons {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.device-btn {
flex-direction: row;
padding: 8px 16px;
}
.device-btn:hover {
transform: translateY(-1px);
}
.device-btn.active {
transform: translateY(-1px);
}
.device-btn .device-icon {
font-size: 16px;
}
.device-btn span:last-child {
font-size: 14px;
}
.main-content {
flex-direction: column;
}
}
@media (max-width: 768px) {
.frame-section, .qrcode-section {
min-width: 100%;
max-width: 100%;
}
.qrcode-section {
order: -1;
}
}
/* 设备信息提示 */
.device-info {
text-align: center;
padding: 10px;
color: #666;
font-size: 13px;
background: #f8f9fa;
border-radius: 6px;
margin-top: 15px;
}
.device-info .device-size {
font-weight: 600;
color: #495057;
}
</style>
<script src="qrcode.min.js"></script>
</head>
<body>
<div class="container">
<div class="content-wrapper">
<!-- 左侧设备选择器 -->
<div class="device-selector">
<span class="device-selector-label">📱 设备</span>
<div class="device-buttons">
<button class="device-btn active" data-device="iphone-x">
<span class="device-icon">📱</span>
<span>iPhone X</span>
</button>
<button class="device-btn" data-device="iphone-14-pro">
<span class="device-icon">📲</span>
<span>iPhone 14 Pro</span>
</button>
<button class="device-btn" data-device="galaxy-s21">
<span class="device-icon">🤖</span>
<span>Galaxy S21</span>
</button>
<button class="device-btn" data-device="android">
<span class="device-icon">📱</span>
<span>Android</span>
</button>
</div>
</div>
<!-- 右侧内容区 -->
<div class="main-content">
<div class="frame-section">
<div class="frame-wrapper iphone-x" id="device-frame">
<div class="screen-container">
<!-- 顶部TabBar -->
<div class="tabbar">
<div class="tabbar-time" id="current-time">9:41</div>
</div>
<!-- iframe内容区域 -->
<iframe id="mobile-frame" src="/"
title="移动端页面"></iframe>
<!-- 底部安全区域占位 -->
<div class="safe-area-bottom"></div>
</div>
</div>
<div class="device-info">
当前设备: <span class="device-size" id="device-name">iPhone X (375×812)</span>
</div>
</div>
<div class="qrcode-section">
<div class="qrcode-container">
<div class="qrcode-title">手机扫码预览</div>
<div class="qrcode-desc">
使用手机扫描下方二维码,可直接在手机上访问此页面,获得真实的移动端体验
</div>
<div id="qrcode"></div>
<div class="url-display" id="current-url">
<!-- URL 将在这里显示 -->
</div>
<div class="mobile-tips">
<svg viewBox="0 0 24 24">
<path
d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/>
</svg>
<span>建议使用手机扫描体验最佳效果</span>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// 设备配置信息
const deviceConfigs = {
'iphone-x': {
name: 'iPhone X',
size: '375×812',
className: 'iphone-x'
},
'iphone-14-pro': {
name: 'iPhone 14 Pro Max',
size: '430×932',
className: 'iphone-14-pro'
},
'galaxy-s21': {
name: 'Samsung Galaxy S21',
size: '384×854',
className: 'galaxy-s21'
},
'android': {
name: 'Android (通用)',
size: '360×760',
className: 'android'
},
};
// 当前选择的设备
let currentDevice = 'iphone-x';
// 切换设备
function switchDevice(deviceId) {
const frame = document.getElementById('device-frame');
const deviceName = document.getElementById('device-name');
const config = deviceConfigs[deviceId];
// 移除所有设备类名
frame.className = 'frame-wrapper';
// 添加新设备类名
frame.classList.add(config.className);
// 更新设备信息显示
deviceName.textContent = `${config.name} (${config.size})`;
// 保存到localStorage
localStorage.setItem('previewDevice', deviceId);
// 更新按钮状态
document.querySelectorAll('.device-btn').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.device === deviceId) {
btn.classList.add('active');
}
});
currentDevice = deviceId;
}
// 初始化设备选择器
function initDeviceSelector() {
// 从localStorage读取上次选择的设备
const savedDevice = localStorage.getItem('previewDevice');
if (savedDevice && deviceConfigs[savedDevice]) {
switchDevice(savedDevice);
}
// 绑定按钮点击事件
document.querySelectorAll('.device-btn').forEach(btn => {
btn.addEventListener('click', function() {
const deviceId = this.dataset.device;
switchDevice(deviceId);
});
});
}
// 更新当前时间
function updateCurrentTime() {
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
document.getElementById('current-time').textContent = `${hours}:${minutes}`;
}
// 获取当前页面URL
function getCurrentUrl() {
// 如果 $request_uri 是占位符则使用实际URL
let iframeSrc = document.getElementById('mobile-frame').src;
if (iframeSrc.includes('$request_uri')) {
// 使用当前页面URL去掉预览页面的路径
let currentUrl = window.location.href;
// 移除预览页面的查询参数或路径
return currentUrl.split('?')[0].replace(/\/preview\/?$/, '');
}
return iframeSrc;
}
// 生成二维码
function generateQRCode() {
const url = getCurrentUrl();
const qrcodeElement = document.getElementById('qrcode');
const urlDisplay = document.getElementById('current-url');
// 显示URL
urlDisplay.textContent = url.length > 50 ? url.substring(0, 50) + '...' : url;
// 清空之前的二维码
qrcodeElement.innerHTML = '';
// 生成当前页面的二维码
new QRCode(document.getElementById('qrcode'), {
text: window.location.href,
width: 200,
height: 200,
margin: 1,
color: {
dark: '#333333',
light: '#ffffff'
}
});
}
// 处理iframe内部链接
var iframe = document.getElementById("mobile-frame");
iframe.onload = function () {
try {
var iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
var links = iframeDoc.querySelectorAll("a");
links.forEach(function (link) {
link.onclick = function (e) {
e.preventDefault();
var href = this.getAttribute("href");
if (href && !href.match(/^(http|https|#|javascript:)/)) {
iframe.src = href;
// 更新二维码
setTimeout(generateQRCode, 100);
}
};
});
} catch (e) {
// 跨域限制
}
// 每次iframe加载后更新二维码
generateQRCode();
};
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function () {
// 初始化设备选择器
initDeviceSelector();
// 更新时间
updateCurrentTime();
setInterval(updateCurrentTime, 60000); // 每分钟更新一次
// 生成二维码
generateQRCode();
});
// 监听URL变化如果使用History API
window.addEventListener('popstate', generateQRCode);
</script>
</body>
</html>

1
qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long