素材
This commit is contained in:
parent
720eab5f9a
commit
b40c78fcad
@ -131,6 +131,15 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/castlove/mint/tear-card",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"app-plus": {
|
||||||
|
"bounce": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/support-activity/index",
|
"path": "pages/support-activity/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
28
frontend/pages/castlove/mint/tear-card.vue
Normal file
28
frontend/pages/castlove/mint/tear-card.vue
Normal 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>
|
||||||
629
frontend/static/html/tear-card.html
Normal file
629
frontend/static/html/tear-card.html
Normal 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>
|
||||||
@ -1,7 +1,7 @@
|
|||||||
// API 基础配置
|
// API 基础配置
|
||||||
// const baseURL = 'http://101.132.250.62:8080'
|
// const baseURL = 'http://101.132.250.62:8080'
|
||||||
// const baseURL = 'http://192.168.110.60:8080'
|
const baseURL = 'http://192.168.110.60:8080'
|
||||||
const baseURL = 'http://localhost:8080'
|
// const baseURL = 'http://localhost:8080'
|
||||||
|
|
||||||
// 是否使用模拟数据(开发调试时设为 true,后端API准备好后改为 false)
|
// 是否使用模拟数据(开发调试时设为 true,后端API准备好后改为 false)
|
||||||
const USE_MOCK_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 && res.data.code !== undefined) {
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
resolve(res.data)
|
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(未授权/冻结/封号),清除缓存并跳转到登录页
|
// 业务状态码401/400/403(未授权/冻结/封号),清除缓存并跳转到登录页
|
||||||
uni.removeStorageSync('access_token')
|
uni.removeStorageSync('access_token')
|
||||||
uni.removeStorageSync('user')
|
uni.removeStorageSync('user')
|
||||||
@ -68,7 +69,8 @@ export function request(options) {
|
|||||||
// 保留错误消息用于显示
|
// 保留错误消息用于显示
|
||||||
const errorMsg = res.data.message || '登录已过期,请重新登录'
|
const errorMsg = res.data.message || '登录已过期,请重新登录'
|
||||||
uni.reLaunch({
|
uni.reLaunch({
|
||||||
url: '/pages/login/login?error=' + encodeURIComponent(errorMsg)
|
url: '/pages/login/login?error=' + encodeURIComponent(
|
||||||
|
errorMsg)
|
||||||
})
|
})
|
||||||
|
|
||||||
reject(new Error(errorMsg))
|
reject(new Error(errorMsg))
|
||||||
|
|||||||
420
横向瀑布流.html
Normal file
420
横向瀑布流.html
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user