This commit is contained in:
zheng020 2026-04-27 18:20:38 +08:00
parent 720eab5f9a
commit b40c78fcad
5 changed files with 1092 additions and 4 deletions

View File

@ -131,6 +131,15 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/castlove/mint/tear-card",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
}
}
},
{
"path": "pages/support-activity/index",
"style": {

View File

@ -0,0 +1,28 @@
<template>
<web-view :src="webviewUrl" @onError="onWebviewError"></web-view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const webviewUrl = ref('')
//
const BOTTOM_IMAGE_URL = '/static/sucai/image-02.png'
onMounted(() => {
// static HTML
webviewUrl.value = `/static/html/tear-card.html?imageUrl=${encodeURIComponent(BOTTOM_IMAGE_URL)}`
})
function onWebviewError(e) {
console.error('Webview error:', e)
}
</script>
<style scoped>
web-view {
width: 100%;
height: 100vh;
}
</style>

View File

@ -0,0 +1,629 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<!-- <title>伪3D撕纸 · 初始顶层覆盖 · 拖拽后露出底层</title> -->
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
h1 {
font-size: 20px;
font-weight: 400;
color: #e94560;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.subtitle {
color: #aaa;
font-size: 13px;
margin-bottom: 30px;
text-align: center;
line-height: 1.7;
}
.tear-container {
position: relative;
width: 90vw;
height: 70vh;
background: transparent;
border-radius: 12px;
overflow: visible;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
touch-action: none;
cursor: default;
}
.layer {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
border-radius: 12px;
font-weight: bold;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
z-index: 1;
}
.layer-1 {
background: transparent;
color: rgba(0, 0, 0, 0.0);
font-size: 42px;
font-weight: 700;
pointer-events: auto;
}
.layer-1 img {
max-width: 80%;
max-height: 80%;
object-fit: contain;
display: none;
}
#tearCanvas {
position: absolute;
z-index: 10;
pointer-events: auto;
cursor: grab;
border-radius: 12px;
}
#tearCanvas:active {
cursor: grabbing;
}
.instructions {
margin-top: 20px;
color: #777;
font-size: 12px;
text-align: center;
line-height: 1.6;
max-width: 400px;
}
.reset-btn {
margin-top: 50px;
padding: 9px 24px;
background: transparent;
border: 1px solid #444;
color: #aaa;
border-radius: 20px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
letter-spacing: 0.5px;
}
.reset-btn:hover {
border-color: #e94560;
color: #e94560;
box-shadow: 0 0 12px rgba(233, 68, 96, 0.2);
}
@media (max-width: 440px) {
.tear-container {
width: 90vw;
height: 70vh;
}
h1 {
font-size: 18px;
}
}
</style>
</head>
<body>
<!-- <h1>伪3D撕纸 · 初始完全覆盖</h1> -->
<!-- <p class="subtitle">从顶部边缘向下拖拽 → 逐步撕开顶层,露出底层</p> -->
<div class="tear-container" id="container">
<div class="layer layer-1">
<img data-src="" alt="底层">
</div>
<canvas id="tearCanvas"></canvas>
</div>
<!-- <p class="instructions">
⬇️ 从顶部边缘拖拽 → 顶层撕裂并弯曲,底层显露<br>
🖱️ 只有在锯齿线附近(±20px)移动,才能改变弯曲中心<br>
🌊 拖拽接近底部 → 顶层逐渐淡出消失,完全露出底层<br>
双击画布或点击按钮重置
</p> -->
<button class="reset-btn" id="resetBtn">重 置</button>
<script>
(function () {
const container = document.getElementById('container');
const canvas = document.getElementById('tearCanvas');
const ctx = canvas.getContext('2d');
const resetBtn = document.getElementById('resetBtn');
const PAD = 48;
const DRAG_THRESHOLD = 3;
const EDGE_THRESHOLD = 26;
const TEAR_TOLERANCE = 20;
const JAGGED_SEGMENTS = 18;
const JAGGED_AMPLITUDE = 5.0;
const JAGGED_FREQ = 3.5;
const MAX_BEND = 54;
const MIN_BEND = 5;
const SHADOW_OFFSET_FACTOR = 0.7;
const THICKNESS_COLOR = '#6B4226';
const FADE_START_DISTANCE = 48;
// 从URL参数或data-src获取图片地址
function getBottomImageSrc() {
const params = new URLSearchParams(window.location.search);
const urlParam = params.get('imageUrl');
const dataSrc = document.querySelector('.layer-1 img').dataset.src;
return urlParam || dataSrc || '';
}
const BOTTOM_IMAGE_SRC = getBottomImageSrc();
let bottomImageLoaded = false;
let bottomImgEl = null;
let isDragging = false;
let hasTorn = false;
let containerW = 0, containerH = 0;
let canvasW = 0, canvasH = 0;
let offsetX = PAD, offsetY = PAD;
let edgePosition = 0; // 撕裂线Y坐标从0开始向下
let freeEdgeOffset = 0; // 纸片翘起偏移量
let bendCenterX = 0; // 弯曲中心X
let dragStartX = 0, dragStartY = 0;
function getCoords(e) {
const rect = container.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top, w: rect.width, h: rect.height };
}
function getTouchCoords(e) {
const touch = e.touches[0] || e.changedTouches[0];
const rect = container.getBoundingClientRect();
return { x: touch.clientX - rect.left, y: touch.clientY - rect.top, w: rect.width, h: rect.height };
}
function dist(x1, y1, x2, y2) { return Math.hypot(x1 - x2, y1 - y2); }
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function getJaggedPoints(x1, y1, x2, y2, segments, amp, freq, seed) {
const points = [{ x: x1, y: y1 }];
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
if (len < 1.5) { points.push({ x: x2, y: y2 }); return points; }
const ux = dx / len, uy = dy / len;
const px = -uy, py = ux;
for (let i = 1; i < segments; i++) {
const t = i / segments;
const bx = x1 + dx * t, by = y1 + dy * t;
const offset = amp * (Math.sin(t * freq * Math.PI + seed) * 0.7 +
Math.sin(t * freq * 2.3 * Math.PI + seed * 1.7) * 0.3);
points.push({ x: bx + px * offset, y: by + py * offset });
}
points.push({ x: x2, y: y2 });
return points;
}
function cx(x) { return x + offsetX; }
function cy(y) { return y + offsetY; }
// 计算顶层剩余部分的透明度(用于底部淡出效果)
function getTopLayerAlpha() {
if (!hasTorn) return 1;
const distanceToBottom = containerH - edgePosition;
if (distanceToBottom <= 0) return 0;
if (distanceToBottom >= FADE_START_DISTANCE) return 1;
return distanceToBottom / FADE_START_DISTANCE;
}
function updateEdgeTearAndCenter(mx, my) {
if (!hasTorn) return;
let newPos = clamp(my, 0, containerH);
if (newPos > edgePosition) edgePosition = newPos;
const desiredOffset = -my;
const absOffset = Math.abs(desiredOffset);
const clampedOffset = Math.min(absOffset, MAX_BEND);
const signedOffset = desiredOffset >= 0 ? clampedOffset : -clampedOffset;
if (Math.abs(signedOffset) > Math.abs(freeEdgeOffset)) {
freeEdgeOffset = signedOffset;
}
if (containerW > 0) {
bendCenterX = clamp(mx, 8, containerW - 8);
}
}
// 预加载底层图片
function preloadBottomImage(callback) {
if (!BOTTOM_IMAGE_SRC) {
callback();
return;
}
bottomImgEl = new Image();
bottomImgEl.src = BOTTOM_IMAGE_SRC;
bottomImgEl.onload = () => {
bottomImageLoaded = true;
callback();
};
bottomImgEl.onerror = () => {
bottomImageLoaded = false;
callback();
};
}
// 绘制明亮的底层(青瓷色,与顶层形成对比)
function drawBaseLayer() {
const w = containerW, h = containerH;
const grad = ctx.createLinearGradient(cx(0), cy(0), cx(w * 0.3), cy(h * 0.7));
grad.addColorStop(0, '#E9F5EF');
grad.addColorStop(0.5, '#CDE5D9');
grad.addColorStop(1, '#B0D2C2');
ctx.fillStyle = grad;
ctx.fillRect(cx(0), cy(0), w, h);
// 网格纹理
ctx.save();
ctx.globalAlpha = 0.2;
ctx.lineWidth = 0.8;
ctx.strokeStyle = '#5D8B7A';
for (let i = 0; i < w; i += 25) {
ctx.beginPath();
ctx.moveTo(cx(i), cy(0));
ctx.lineTo(cx(i), cy(h));
ctx.stroke();
}
for (let i = 0; i < h; i += 25) {
ctx.beginPath();
ctx.moveTo(cx(0), cy(i));
ctx.lineTo(cx(w), cy(i));
ctx.stroke();
}
ctx.restore();
// 底层图片
if (bottomImageLoaded && bottomImgEl) {
const scale = Math.min(w / bottomImgEl.width, h / bottomImgEl.height) * 0.8;
const drawW = bottomImgEl.width * scale;
const drawH = bottomImgEl.height * scale;
ctx.drawImage(bottomImgEl, cx(w / 2 - drawW / 2), cy(h / 2 - drawH / 2), drawW, drawH);
}
}
// 绘制未被撕裂的顶层部分(即撕裂线以下区域)
function drawUnrippedTop(alpha) {
if (!hasTorn) return;
if (alpha <= 0) return;
const w = containerW, h = containerH;
const tearY = edgePosition;
if (tearY >= h) return;
ctx.save();
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.rect(cx(0), cy(tearY), w, h - tearY);
ctx.clip();
// 顶层完整覆盖区域的材质(深色仿牛皮纸)
const grad = ctx.createLinearGradient(cx(0), cy(tearY), cx(w * 0.5), cy(h));
grad.addColorStop(0, '#B87C4E');
grad.addColorStop(0.6, '#9C633A');
grad.addColorStop(1, '#7A4A28');
ctx.fillStyle = grad;
ctx.fillRect(cx(0), cy(tearY), w, h - tearY);
// 添加细微纹理
ctx.globalCompositeOperation = 'overlay';
ctx.globalAlpha = 0.12;
for (let i = 0; i < 200; i++) {
ctx.fillStyle = '#FFF0D0';
ctx.beginPath();
ctx.arc(cx(Math.random() * w), cy(tearY + Math.random() * (h - tearY)), Math.random() * 2 + 0.5, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
ctx.restore();
}
// 绘制翘起的纸片(撕裂线以上部分,带弯曲和厚度)
function drawTornPiece(alpha) {
if (!hasTorn) return;
if (alpha <= 0) return;
const w = containerW, h = containerH;
const absOff = Math.abs(freeEdgeOffset);
if (absOff < 1) return;
const bendDir = freeEdgeOffset > 0 ? 1 : -1;
const bendAmount = clamp(absOff, MIN_BEND, MAX_BEND);
const jaggedStart = { x: 0, y: edgePosition };
const jaggedEnd = { x: w, y: edgePosition };
const freeEdgeStart = { x: 0, y: edgePosition + freeEdgeOffset };
const freeEdgeEnd = { x: w, y: edgePosition + freeEdgeOffset };
let dynamicCenterX = bendCenterX;
if (isNaN(dynamicCenterX)) dynamicCenterX = w / 2;
dynamicCenterX = clamp(dynamicCenterX, 8, w - 8);
const arcMidX = dynamicCenterX;
const arcMidY = edgePosition + freeEdgeOffset + freeEdgeOffset * 0.5;
const seed = jaggedStart.x * 0.37 + jaggedEnd.y * 0.53;
const jagged = getJaggedPoints(jaggedStart.x, jaggedStart.y, jaggedEnd.x, jaggedEnd.y, JAGGED_SEGMENTS,
JAGGED_AMPLITUDE, JAGGED_FREQ, seed);
function buildPath(offX, offY) {
const pts = [];
for (const p of jagged) pts.push({ x: p.x + offX, y: p.y + offY });
const cpOff = bendAmount * 0.15;
pts.push({ x: freeEdgeEnd.x + offX + (bendDir * cpOff), y: freeEdgeEnd.y + offY });
pts.push({ x: arcMidX + offX, y: arcMidY + offY });
pts.push({ x: freeEdgeStart.x + offX + (bendDir * cpOff), y: freeEdgeStart.y + offY });
return pts;
}
const mainPath = buildPath(0, 0);
const shadowDir = -bendDir;
const shadowDist = bendAmount * SHADOW_OFFSET_FACTOR;
const shadowPath = buildPath(shadowDir * shadowDist, 0);
ctx.save();
ctx.globalAlpha = alpha;
// 阴影
ctx.beginPath();
ctx.moveTo(cx(shadowPath[0].x), cy(shadowPath[0].y));
for (let i = 1; i < shadowPath.length; i++) ctx.lineTo(cx(shadowPath[i].x), cy(shadowPath[i].y));
ctx.closePath();
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = 15;
ctx.shadowOffsetX = 3;
ctx.shadowOffsetY = 5;
ctx.fill();
// 厚度
const thick = bendAmount * 0.48;
function drawEdgeThick(p1, p2, sign) {
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const nx = -dy / len, ny = dx / len;
const offNx = nx * thick * sign, offNy = ny * thick * sign;
ctx.beginPath();
ctx.moveTo(cx(p1.x), cy(p1.y));
ctx.lineTo(cx(p1.x + offNx), cy(p1.y + offNy));
ctx.lineTo(cx(p2.x + offNx), cy(p2.y + offNy));
ctx.lineTo(cx(p2.x), cy(p2.y));
ctx.closePath();
const grad = ctx.createLinearGradient(cx(p1.x), cy(p1.y), cx(p2.x), cy(p2.y));
grad.addColorStop(0, '#9B6A3C');
grad.addColorStop(1, '#553A22');
ctx.fillStyle = grad;
ctx.fill();
}
drawEdgeThick(jagged[jagged.length - 1], mainPath[jagged.length], -1);
drawEdgeThick(mainPath[mainPath.length - 1], jagged[0], 1);
// 纸片正面(深色,与底层形成反差)
ctx.beginPath();
ctx.moveTo(cx(mainPath[0].x), cy(mainPath[0].y));
for (let i = 1; i < mainPath.length; i++) ctx.lineTo(cx(mainPath[i].x), cy(mainPath[i].y));
ctx.closePath();
const paperGrad = ctx.createLinearGradient(cx(freeEdgeStart.x), cy(freeEdgeStart.y), cx(jaggedStart.x), cy(jaggedStart.y));
paperGrad.addColorStop(0, '#D09E6E');
paperGrad.addColorStop(0.5, '#B47C48');
paperGrad.addColorStop(1, '#8B5A2E');
ctx.fillStyle = paperGrad;
ctx.fill();
ctx.strokeStyle = 'rgba(40,20,5,0.3)';
ctx.lineWidth = 1.2;
ctx.stroke();
// 弯曲高光
ctx.beginPath();
for (let i = jagged.length; i < mainPath.length; i++) ctx.lineTo(cx(mainPath[i].x), cy(mainPath[i].y));
ctx.strokeStyle = 'rgba(255,225,150,0.6)';
ctx.lineWidth = 2;
ctx.shadowBlur = 5;
ctx.stroke();
// 锯齿边缘金色高光
ctx.beginPath();
ctx.moveTo(cx(jagged[0].x), cy(jagged[0].y));
for (let i = 1; i < jagged.length; i++) ctx.lineTo(cx(jagged[i].x), cy(jagged[i].y));
ctx.strokeStyle = 'rgba(245,195,80,0.9)';
ctx.lineWidth = 2.0;
ctx.stroke();
ctx.restore();
}
// 绘制锯齿边缘线(撕裂线位置,仅在撕裂存在时显示)
function drawTearLine(alpha) {
if (!hasTorn) return;
if (alpha <= 0) return;
const w = containerW;
const seed = 0.618;
const jagged = getJaggedPoints(0, edgePosition, w, edgePosition, JAGGED_SEGMENTS, JAGGED_AMPLITUDE, JAGGED_FREQ, seed);
ctx.save();
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.moveTo(cx(jagged[0].x), cy(jagged[0].y));
for (let i = 1; i < jagged.length; i++) ctx.lineTo(cx(jagged[i].x), cy(jagged[i].y));
ctx.strokeStyle = 'rgba(210,150,70,0.8)';
ctx.lineWidth = 2.2;
ctx.shadowBlur = 6;
ctx.stroke();
ctx.restore();
}
// 绘制整个场景:底层 + 未被撕掉的顶层部分 + 翘起纸片 + 撕裂线
function drawScene() {
ctx.clearRect(0, 0, canvasW, canvasH);
drawBaseLayer(); // 先绘制底层(一直存在)
if (!hasTorn) {
// 未撕裂时,绘制完整顶层覆盖
ctx.save();
const w = containerW, h = containerH;
const grad = ctx.createLinearGradient(cx(0), cy(0), cx(w * 0.5), cy(h));
grad.addColorStop(0, '#B87C4E');
grad.addColorStop(0.6, '#9C633A');
grad.addColorStop(1, '#7A4A28');
ctx.fillStyle = grad;
ctx.fillRect(cx(0), cy(0), w, h);
// 添加纹理
ctx.globalCompositeOperation = 'overlay';
ctx.globalAlpha = 0.1;
for (let i = 0; i < 300; i++) {
ctx.fillStyle = '#FFF0D0';
ctx.beginPath();
ctx.arc(cx(Math.random() * w), cy(Math.random() * h), Math.random() * 2 + 0.5, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
return;
}
// 已撕裂状态:先绘制未被撕裂的顶层部分(撕裂线以下区域)
const alpha = getTopLayerAlpha();
drawUnrippedTop(alpha);
// 绘制翘起的纸片(撕裂线以上)
drawTornPiece(alpha);
// 绘制锯齿撕裂线
drawTearLine(alpha);
}
function resizeCanvas() {
const rect = container.getBoundingClientRect();
const newW = Math.round(rect.width);
const newH = Math.round(rect.height);
if (containerW === newW && containerH === newH && canvasW > 0) return;
if (hasTorn && containerW > 0 && containerH > 0) {
const sy = newH / containerH;
edgePosition = Math.min(edgePosition * sy, newH);
freeEdgeOffset *= sy;
}
containerW = newW;
containerH = newH;
canvasW = newW + 2 * PAD;
canvasH = newH + 2 * PAD;
offsetX = PAD;
offsetY = PAD;
canvas.width = canvasW;
canvas.height = canvasH;
canvas.style.width = canvasW + 'px';
canvas.style.height = canvasH + 'px';
canvas.style.left = -PAD + 'px';
canvas.style.top = -PAD + 'px';
if (containerW > 0) bendCenterX = clamp(bendCenterX, 5, containerW - 5);
else bendCenterX = containerW / 2;
drawScene();
}
// 交互逻辑
function onPointerDown(x, y) {
dragStartX = x;
dragStartY = y;
isDragging = true;
}
function onPointerMove(x, y) {
if (!isDragging) return;
if (!hasTorn) {
if (dist(x, y, dragStartX, dragStartY) < DRAG_THRESHOLD) return;
if (dragStartY > EDGE_THRESHOLD) {
isDragging = false;
return;
}
hasTorn = true;
edgePosition = clamp(dragStartY, 0, containerH);
let initOffset = -edgePosition;
initOffset = clamp(initOffset, -MAX_BEND, MAX_BEND);
freeEdgeOffset = initOffset;
bendCenterX = clamp(dragStartX, 8, containerW - 8);
drawScene();
updateEdgeTearAndCenter(x, y);
drawScene();
return;
}
const distToLine = Math.abs(y - edgePosition);
if (distToLine <= TEAR_TOLERANCE) {
updateEdgeTearAndCenter(x, y);
drawScene();
}
// 线外不更新任何状态
}
function onPointerUp() {
isDragging = false;
}
function resetTear() {
hasTorn = false;
isDragging = false;
edgePosition = 0;
freeEdgeOffset = 0;
if (containerW > 0) bendCenterX = containerW / 2;
drawScene();
}
// 事件绑定 - 修复:同时在 container 和 canvas 上绑定双击事件
container.addEventListener('mousedown', (e) => { e.preventDefault(); const c = getCoords(e); onPointerDown(c.x, c.y); });
window.addEventListener('mousemove', (e) => { if (!isDragging) return; e.preventDefault(); const c = getCoords(e); onPointerMove(c.x, c.y); });
window.addEventListener('mouseup', onPointerUp);
container.addEventListener('touchstart', (e) => { e.preventDefault(); const c = getTouchCoords(e); onPointerDown(c.x, c.y); }, { passive: false });
window.addEventListener('touchmove', (e) => { if (!isDragging) return; e.preventDefault(); const c = getTouchCoords(e); onPointerMove(c.x, c.y); }, { passive: false });
window.addEventListener('touchend', onPointerUp);
window.addEventListener('touchcancel', onPointerUp);
// 修复:在 container 和 canvas 上都添加双击事件
// container.addEventListener('dblclick', (e) => { e.preventDefault(); resetTear(); });
// canvas.addEventListener('dblclick', (e) => { e.preventDefault(); resetTear(); });
resetBtn.addEventListener('click', resetTear);
window.addEventListener('resize', () => { clearTimeout(window._rst); window._rst = setTimeout(resizeCanvas, 100); });
window.addEventListener('orientationchange', () => setTimeout(resizeCanvas, 200));
// 设置底层图片
const imgEl = document.querySelector('.layer-1 img');
imgEl.src = BOTTOM_IMAGE_SRC;
imgEl.dataset.src = BOTTOM_IMAGE_SRC;
// 预加载完成后初始化
preloadBottomImage(() => {
resizeCanvas();
resetTear();
});
resetTear();
})();
</script>
</body>
</html>

View File

@ -1,7 +1,7 @@
// API 基础配置
// const baseURL = 'http://101.132.250.62:8080'
// const baseURL = 'http://192.168.110.60:8080'
const baseURL = 'http://localhost:8080'
const baseURL = 'http://192.168.110.60:8080'
// const baseURL = 'http://localhost:8080'
// 是否使用模拟数据(开发调试时设为 true后端API准备好后改为 false
const USE_MOCK_API = false
@ -60,7 +60,8 @@ export function request(options) {
if (res.data && res.data.code !== undefined) {
if (res.data.code === 200) {
resolve(res.data)
} else if (res.data.code === 401 || res.data.code === 400 || res.data.code === 403) {
} else if (res.data.code === 401 || res.data.code === 400 || res.data
.code === 403) {
// 业务状态码401/400/403未授权/冻结/封号),清除缓存并跳转到登录页
uni.removeStorageSync('access_token')
uni.removeStorageSync('user')
@ -68,7 +69,8 @@ export function request(options) {
// 保留错误消息用于显示
const errorMsg = res.data.message || '登录已过期,请重新登录'
uni.reLaunch({
url: '/pages/login/login?error=' + encodeURIComponent(errorMsg)
url: '/pages/login/login?error=' + encodeURIComponent(
errorMsg)
})
reject(new Error(errorMsg))

420
横向瀑布流.html Normal file
View File

@ -0,0 +1,420 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>横向瀑布流 · 完美填满与呼吸间隙</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#0f0f14; overflow:hidden; height:100vh; height:-webkit-fill-available; font-family:system-ui, -apple-system, sans-serif; }
.scroll-container {
position:fixed; top:0; left:0; width:100vw; height:100vh; height:-webkit-fill-available;
overflow-x:auto; overflow-y:hidden; -webkit-overflow-scrolling:touch; cursor:grab;
}
.scroll-container:active { cursor:grabbing; }
.scroll-container.dragging { cursor:grabbing; }
.waterfall-inner { position:relative; height:100%; min-height:100%; }
.card {
position:absolute; border-radius:14px; overflow:hidden; cursor:pointer;
transition: transform 0.25s, box-shadow 0.3s; will-change:transform;
box-shadow:0 4px 18px rgba(0,0,0,0.35); border:1px solid rgba(255,255,255,0.06);
background-size:cover; background-position:center;
display:flex; flex-direction:column; justify-content:flex-end; align-items:center;
}
.card:active { transform:scale(0.96); }
.card .pattern-layer { position:absolute; inset:0; opacity:0.25; pointer-events:none; }
.card .likes {
position:relative; z-index:1; color:white; font-weight:700; font-size:1rem;
text-shadow:0 2px 6px rgba(0,0,0,0.7); margin-bottom:12px; user-select:none;
}
.header {
position:fixed; top:0; left:0; right:0; z-index:100; padding:12px 18px;
display:flex; align-items:center; justify-content:space-between;
background:linear-gradient(180deg, rgba(15,15,20,0.95) 50%, rgba(15,15,20,0) 100%);
pointer-events:none;
}
.header > * { pointer-events:auto; }
.header-title { font-size:1.05rem; font-weight:700; color:#fff; display:flex; align-items:center; gap:8px; }
.header-dot { width:9px; height:9px; border-radius:50%; background:#ff6b6b; animation:pulse 2s infinite; }
@keyframes pulse { 0%,100%{box-shadow:0 0 0 0 rgba(255,107,107,0.6)} 50%{box-shadow:0 0 0 12px rgba(255,107,107,0)} }
.status { color:#ccc; font-size:0.8rem; }
</style>
</head>
<body>
<div class="header">
<div class="header-title"><div class="header-dot"></div>灵感瀑布</div>
<div class="status" id="statusText">自动滚动中</div>
</div>
<div class="scroll-container" id="scrollContainer">
<div class="waterfall-inner" id="waterfallInner"></div>
</div>
<script>
(() => {
const container = document.getElementById('scrollContainer');
const inner = document.getElementById('waterfallInner');
const statusText = document.getElementById('statusText');
// ---------- 布局引擎(带列间隙填充)----------
class WaterfallLayout {
constructor(containerHeight, unitHeight = 35, minGap = 10, jitter = 0.4) {
this.containerHeight = containerHeight;
this.unitHeight = unitHeight;
this.minGap = minGap;
this.jitter = jitter;
this.recalcUnits();
}
recalcUnits() {
this.unitCount = Math.max(4, Math.round(this.containerHeight / this.unitHeight));
this.actualUnitHeight = this.containerHeight / this.unitCount;
this.rowCurrentX = new Array(this.unitCount).fill(0);
}
// 分段高度
computeHeights(allCards) {
const n = allCards.length;
if (n === 0) return new Map();
const sortedLikes = [...allCards.map(c => c.likes)].sort((a,b)=>a-b);
const heightMap = new Map();
const perturbation = 0.08;
for (const card of allCards) {
let less = 0, equal = 0;
for (const v of sortedLikes) {
if (v < card.likes) less++;
else if (v === card.likes) equal++;
}
const percentile = (less + equal * 0.5) / n;
let ratio;
if (percentile < 0.60) ratio = 0.25;
else if (percentile < 0.85) ratio = 0.333;
else if (percentile < 0.96) ratio = 0.5;
else ratio = 1.0;
const noise = 1 + (Math.random() - 0.5) * 2 * perturbation;
let rawHeight = this.containerHeight * ratio * noise;
rawHeight = Math.max(this.containerHeight * 0.22, Math.min(this.containerHeight, rawHeight));
const units = Math.round(rawHeight / this.actualUnitHeight);
const clampedUnits = Math.max(1, Math.min(units, this.unitCount));
const finalHeight = clampedUnits * this.actualUnitHeight;
heightMap.set(card.id, finalHeight);
}
return heightMap;
}
// 全量计算
compute(cardsData) {
this.rowCurrentX = new Array(this.unitCount).fill(0);
const heightMap = this.computeHeights(cardsData);
const rawCards = [];
for (const card of cardsData) {
const height = heightMap.get(card.id);
const width = (height * 9) / 16;
const units = Math.round(height / this.actualUnitHeight);
const span = Math.max(1, Math.min(units, this.unitCount));
let bestRow = 0, bestX = Infinity;
for (let r = 0; r <= this.unitCount - span; r++) {
let maxX = 0;
for (let s = 0; s < span; s++) maxX = Math.max(maxX, this.rowCurrentX[r + s]);
if (maxX < bestX) { bestX = maxX; bestRow = r; }
}
const left = bestX;
const top = bestRow * this.actualUnitHeight;
const gap = this.minGap + (Math.random() - 0.5) * this.minGap * this.jitter * 2;
const newX = left + width + gap;
for (let s = 0; s < span; s++) this.rowCurrentX[bestRow + s] = newX;
rawCards.push({ ...card, left, top, width, height, spanUnits: span });
}
// 应用列间隙分布
return this.applyColumnGaps(rawCards);
}
// 追加卡片并应用间隙
addCards(newCards, allCardsSoFar) {
const heightMap = this.computeHeights(allCardsSoFar);
const rawCards = [];
for (const card of newCards) {
const height = heightMap.get(card.id);
const width = (height * 9) / 16;
const units = Math.round(height / this.actualUnitHeight);
const span = Math.max(1, Math.min(units, this.unitCount));
let bestRow = 0, bestX = Infinity;
for (let r = 0; r <= this.unitCount - span; r++) {
let maxX = 0;
for (let s = 0; s < span; s++) maxX = Math.max(maxX, this.rowCurrentX[r + s]);
if (maxX < bestX) { bestX = maxX; bestRow = r; }
}
const left = bestX;
const top = bestRow * this.actualUnitHeight;
const gap = this.minGap + (Math.random() - 0.5) * this.minGap * this.jitter * 2;
const newX = left + width + gap;
for (let s = 0; s < span; s++) this.rowCurrentX[bestRow + s] = newX;
rawCards.push({ ...card, left, top, width, height, spanUnits: span });
}
return this.applyColumnGaps(rawCards);
}
// 核心:将每一列的剩余垂直空间随机分配为卡片间隙
applyColumnGaps(cards) {
if (cards.length === 0) return cards;
// 按 left 聚类成列(同一列的卡片 left 几乎相同)
const sorted = [...cards].sort((a,b) => a.left - b.left);
const columns = [];
let currentCol = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const prev = currentCol[currentCol.length - 1];
if (Math.abs(sorted[i].left - prev.left) < this.minGap * 2) {
currentCol.push(sorted[i]);
} else {
columns.push(currentCol);
currentCol = [sorted[i]];
}
}
columns.push(currentCol);
// 处理每一列
for (const col of columns) {
// 按 top 排序
col.sort((a,b) => a.top - b.top);
const sumHeight = col.reduce((s, c) => s + c.height, 0);
const slack = this.containerHeight - sumHeight;
if (slack <= 0) continue; // 已满或超出,保持原样
const n = col.length;
// 生成 n-1 个间隙(如果只有一张卡片,则将 slack 分配为顶部间隙?不,单卡片直接全高即可,无需间隙)
if (n === 1) {
// 单卡片:可稍微增加高度到容器高度,或者保持原高度在顶部,底部留白就无法避免。
// 为了视觉一致,不改变单卡高度,但可以居中?为了简单,不做变动。
continue;
}
// 生成 n-1 个随机权重,归一化使总和为 slack
const rawWeights = Array.from({ length: n - 1 }, () => Math.random());
const weightSum = rawWeights.reduce((a,b) => a + b, 0) || 1;
const gaps = rawWeights.map(w => (w / weightSum) * slack);
// 调整每张卡片的 top
let currentTop = col[0].top;
col[0].finalTop = currentTop;
for (let i = 1; i < n; i++) {
currentTop += col[i-1].height + gaps[i-1];
col[i].finalTop = currentTop;
}
// 最后一张卡片底部currentTop + lastCard.height 应等于 containerHeight
// 为精确,可微调最后一个间隙
}
// 合并 finalTop 到卡片对象
const updatedCards = cards.map(card => {
const colCard = columns.flat().find(c => c.id === card.id);
return {
...card,
top: colCard?.finalTop !== undefined ? colCard.finalTop : card.top,
// 高度不变
};
});
return updatedCards;
}
getTotalWidth() {
return Math.max(...this.rowCurrentX) + this.minGap * 2;
}
}
// ---------- 颜色/图案 ----------
const palettes = [
{ grad: 'linear-gradient(135deg, #2d1b69 0%, #4c1d95 50%, #5b21b6 100%)', acc: '#a78bfa' },
{ grad: 'linear-gradient(135deg, #0c4a6e 0%, #0369a1 50%, #0284c7 100%)', acc: '#38bdf8' },
{ grad: 'linear-gradient(135deg, #450a0a 0%, #991b1b 50%, #b91c1c 100%)', acc: '#f87171' },
{ grad: 'linear-gradient(135deg, #064e3b 0%, #047857 50%, #059669 100%)', acc: '#4ade80' },
{ grad: 'linear-gradient(135deg, #3b0764 0%, #6d28d9 50%, #7c3aed 100%)', acc: '#c084fc' },
{ grad: 'linear-gradient(135deg, #78350f 0%, #b45309 50%, #d97706 100%)', acc: '#fbbf24' },
{ grad: 'linear-gradient(135deg, #1e3a5f 0%, #1d4ed8 50%, #2563eb 100%)', acc: '#60a5fa' },
{ grad: 'linear-gradient(135deg, #4a1942 0%, #9d174d 50%, #be185d 100%)', acc: '#f472b6' },
];
function randomPalette() { return palettes[Math.floor(Math.random() * palettes.length)]; }
function patternSVG(acc, seed) {
const op = '0.15';
const shapes = ['circles','triangles','dots','waves'];
const s = shapes[seed % shapes.length];
if(s==='circles') return `<svg width="100%" height="100%" viewBox="0 0 200 150" preserveAspectRatio="none"><circle cx="30" cy="30" r="25" fill="none" stroke="${acc}" stroke-width="2" opacity="${op}"/><circle cx="160" cy="50" r="35" fill="none" stroke="${acc}" stroke-width="1.5" opacity="${op}"/><circle cx="80" cy="110" r="20" fill="none" stroke="${acc}" stroke-width="2" opacity="${op}"/></svg>`;
if(s==='triangles') return `<svg width="100%" height="100%" viewBox="0 0 200 150" preserveAspectRatio="none"><polygon points="40,20 70,70 10,70" fill="none" stroke="${acc}" stroke-width="1.5" opacity="${op}"/><polygon points="150,30 180,80 120,80" fill="none" stroke="${acc}" stroke-width="1" opacity="${op}"/></svg>`;
if(s==='dots') return `<svg width="100%" height="100%" viewBox="0 0 200 150" preserveAspectRatio="none"><circle cx="25" cy="25" r="3" fill="${acc}" opacity="${op}"/><circle cx="60" cy="40" r="2" fill="${acc}" opacity="${op}"/><circle cx="100" cy="20" r="4" fill="${acc}" opacity="${op}"/><circle cx="150" cy="55" r="2.5" fill="${acc}" opacity="${op}"/></svg>`;
if(s==='waves') return `<svg width="100%" height="100%" viewBox="0 0 200 150" preserveAspectRatio="none"><path d="M0,60 Q50,30 100,60 Q150,90 200,60" fill="none" stroke="${acc}" stroke-width="2" opacity="${op}"/><path d="M0,90 Q50,70 100,90 Q150,110 200,90" fill="none" stroke="${acc}" stroke-width="1.5" opacity="${op}"/></svg>`;
return '';
}
// ---------- 全局状态 ----------
let allCards = [];
let idCounter = 0;
const layout = new WaterfallLayout(window.innerHeight - 80, 35, 10, 0.4);
let autoScrollActive = true;
const autoScrollSpeed = 1.2;
let userInteracting = false;
let interactionTimeout = null;
const AUTO_RESUME_DELAY = 2500;
function createCardElement(cardData) {
const div = document.createElement('div');
div.className = 'card';
div.dataset.id = cardData.id;
div.style.left = cardData.left + 'px';
div.style.top = cardData.top + 'px';
div.style.width = cardData.width + 'px';
div.style.height = cardData.height + 'px';
div.style.background = cardData.palette.grad;
const pattern = document.createElement('div');
pattern.className = 'pattern-layer';
pattern.innerHTML = patternSVG(cardData.palette.acc, cardData.id);
div.appendChild(pattern);
const likesSpan = document.createElement('div');
likesSpan.className = 'likes';
likesSpan.textContent = '❤️ ' + cardData.likes;
div.appendChild(likesSpan);
return div;
}
function renderAllCards() {
inner.innerHTML = '';
const frag = document.createDocumentFragment();
allCards.forEach(c => frag.appendChild(createCardElement(c)));
inner.appendChild(frag);
inner.style.width = layout.getTotalWidth() + 'px';
}
function updateCardDOM(cardData) {
const el = document.querySelector(`.card[data-id="${cardData.id}"]`);
if (!el) return;
el.style.left = cardData.left + 'px';
el.style.top = cardData.top + 'px';
el.style.width = cardData.width + 'px';
el.style.height = cardData.height + 'px';
const likesEl = el.querySelector('.likes');
if (likesEl) likesEl.textContent = '❤️ ' + cardData.likes;
}
function generateBatch(count) {
const batch = [];
const minLikes = 20;
const maxLikes = 1800;
const alpha = 0.7;
for (let i = 0; i < count; i++) {
const u = Math.random();
const likes = Math.floor(minLikes + (maxLikes - minLikes) * Math.pow(1 - u, 1/alpha));
batch.push({ id: idCounter++, likes, palette: randomPalette() });
}
return batch;
}
function initLoad() {
const initialCards = generateBatch(50);
allCards = layout.compute(initialCards);
renderAllCards();
}
function appendMore(count = 35) {
const newCards = generateBatch(count);
const placed = layout.addCards(newCards, allCards);
allCards.push(...placed);
const frag = document.createDocumentFragment();
placed.forEach(c => frag.appendChild(createCardElement(c)));
inner.appendChild(frag);
inner.style.width = layout.getTotalWidth() + 'px';
const leftEdge = container.scrollLeft - 600;
allCards.forEach(c => {
if (c.left + c.width < leftEdge) {
const el = document.querySelector(`.card[data-id="${c.id}"]`);
if (el) el.remove();
}
});
if (allCards.length > 200) allCards = allCards.slice(-150);
}
function handleLike(cardId) {
const idx = allCards.findIndex(c => c.id == cardId);
if (idx === -1) return;
allCards[idx].likes += Math.floor(20 + Math.random() * 80);
const plainData = allCards.map(c => ({ id: c.id, likes: c.likes, palette: c.palette }));
const newLayout = layout.compute(plainData);
allCards = newLayout.map((c, i) => ({ ...c, palette: allCards[i].palette }));
allCards.forEach(c => updateCardDOM(c));
inner.style.width = layout.getTotalWidth() + 'px';
}
function autoScroll() {
if (!autoScrollActive || userInteracting) return;
container.scrollLeft += autoScrollSpeed;
const maxScroll = inner.scrollWidth - container.clientWidth;
if (container.scrollLeft >= maxScroll - 600) {
appendMore(35);
}
requestAnimationFrame(() => autoScroll());
}
function pauseAutoScroll() {
userInteracting = true;
statusText.textContent = '手动模式';
clearTimeout(interactionTimeout);
interactionTimeout = setTimeout(() => {
userInteracting = false;
autoScrollActive = true;
statusText.textContent = '自动滚动中';
}, AUTO_RESUME_DELAY);
}
container.addEventListener('wheel', (e) => {
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
e.preventDefault();
container.scrollLeft += e.deltaY;
pauseAutoScroll();
}, { passive: false });
let dragging = false, startX, startScroll;
container.addEventListener('pointerdown', (e) => {
dragging = true;
startX = e.clientX;
startScroll = container.scrollLeft;
container.classList.add('dragging');
pauseAutoScroll();
});
window.addEventListener('pointermove', (e) => {
if (!dragging) return;
container.scrollLeft = startScroll + (startX - e.clientX);
});
window.addEventListener('pointerup', () => {
dragging = false;
container.classList.remove('dragging');
});
inner.addEventListener('click', (e) => {
const cardEl = e.target.closest('.card');
if (!cardEl) return;
const id = Number(cardEl.dataset.id);
handleLike(id);
pauseAutoScroll();
});
container.addEventListener('touchstart', () => pauseAutoScroll(), { passive: true });
window.addEventListener('resize', () => {
layout.containerHeight = window.innerHeight - 80;
layout.recalcUnits();
const plainData = allCards.map(c => ({ id: c.id, likes: c.likes, palette: c.palette }));
allCards = layout.compute(plainData);
renderAllCards();
});
initLoad();
autoScroll();
console.log('✨ 完美填满:随机垂直间隙分布,零底部留白');
})();
</script>
</body>
</html>