mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
feat: enhance DefaultIndexTemplate with ordered links and UI improvements
- Added computed property to order links based on user settings or default order. - Updated UI to display ordered links dynamically. - Improved text display for UID and adjusted button styles for better UX. - Modified TypeScript configuration to include 'jszip' type definitions.
This commit is contained in:
@@ -48,6 +48,7 @@
|
||||
"file-saver": "^2.0.5",
|
||||
"grapheme-splitter": "^1.0.4",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"linqts": "^2.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -81,6 +82,7 @@
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.16",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/jszip": "^3.10.6",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"@vitejs/plugin-vue-jsx": "^4.2.0",
|
||||
|
||||
@@ -143,6 +143,11 @@ export interface Setting_Index {
|
||||
videos: string[]
|
||||
notification: string
|
||||
links: { [key: string]: string }
|
||||
/**
|
||||
* 自定义链接顺序(存储链接名称的有序数组)
|
||||
* 旧数据无此字段时在前端初始化为 Object.keys(links)
|
||||
*/
|
||||
linkOrder?: string[]
|
||||
}
|
||||
export interface Setting_LiveRequest {
|
||||
orderPrefix: string
|
||||
@@ -824,6 +829,7 @@ export interface ResponseUserIndexModel {
|
||||
notification: string
|
||||
videos: VideoCollectVideo[]
|
||||
links: { [key: string]: string }
|
||||
linkOrder?: string[]
|
||||
}
|
||||
|
||||
// 签到排行信息
|
||||
|
||||
1
src/components.d.ts
vendored
1
src/components.d.ts
vendored
@@ -31,6 +31,7 @@ declare module 'vue' {
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
|
||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
|
||||
@@ -1,608 +0,0 @@
|
||||
<template>
|
||||
<div class="dynamic-nine-grid-tool">
|
||||
<n-card title="动态九图生成器">
|
||||
<n-space vertical size="large">
|
||||
<div class="upload-section">
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
accept="image/*"
|
||||
>
|
||||
<n-button type="primary">上传原始图片</n-button>
|
||||
</n-upload>
|
||||
<n-text depth="3" class="mt-1">
|
||||
请上传一张方形或长方形图片,将会自动分割成3x3的九宫格图片
|
||||
</n-text>
|
||||
</div> <div v-if="originalImage" class="cropper-container">
|
||||
<div>
|
||||
<n-text>请确保您上传的图片是方形或近似方形,以获得最佳效果</n-text>
|
||||
</div>
|
||||
<div class="image-preview">
|
||||
<img :src="originalImage" alt="原始图片" class="original-image-preview" />
|
||||
</div> <div class="cropper-controls">
|
||||
<n-button @click="generatePreview" type="primary">生成预览</n-button>
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
accept="image/*"
|
||||
>
|
||||
<n-button type="warning">重新上传</n-button>
|
||||
</n-upload>
|
||||
</div>
|
||||
</div> <div v-if="croppedSquareImage" class="preview-section">
|
||||
<n-h4>九宫格预览</n-h4>
|
||||
<n-text depth="3">
|
||||
九宫格预览显示了图片分割后的效果,每个格子都可以添加下方图片
|
||||
</n-text>
|
||||
|
||||
<div class="whole-image-preview">
|
||||
<img :src="croppedSquareImage" alt="完整预览" class="whole-preview-img" />
|
||||
<div class="grid-overlay">
|
||||
<div v-for="i in 9" :key="`overlay-${i}`" class="grid-overlay-cell">
|
||||
<div class="cell-number">{{ i }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nine-grid-preview">
|
||||
<div
|
||||
v-for="i in 9"
|
||||
:key="`grid-${i}`"
|
||||
class="grid-item-container"
|
||||
>
|
||||
<div class="grid-image-container">
|
||||
<div class="grid-position-indicator">第{{ i }}格</div>
|
||||
<div class="grid-image-wrapper">
|
||||
<img
|
||||
:src="croppedSquareImage"
|
||||
alt="九宫格预览"
|
||||
class="grid-image-base"
|
||||
:style="{
|
||||
clipPath: generateClipPath(i-1),
|
||||
transform: 'scale(3)',
|
||||
transformOrigin: calculateTransformOrigin(i-1)
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="additionalImages[i-1]" class="additional-image-preview">
|
||||
<img :src="additionalImages[i-1] || ''" alt="附加图片" />
|
||||
</div>
|
||||
<div class="grid-controls">
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="(data) => handleAdditionalImageChange(data, i - 1)"
|
||||
accept="image/*"
|
||||
>
|
||||
<n-button size="tiny">添加下方图片</n-button>
|
||||
</n-upload>
|
||||
<n-button
|
||||
v-if="additionalImages[i-1]"
|
||||
size="tiny"
|
||||
type="error"
|
||||
@click="() => removeAdditionalImage(i-1)"
|
||||
>
|
||||
删除下方图片
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<n-button @click="generateFinalImages" type="success" class="mt-2">生成九张图片</n-button>
|
||||
<n-button @click="downloadAllImages" type="info" class="mt-2" :disabled="finalImages.length === 0">
|
||||
打包下载全部
|
||||
</n-button>
|
||||
</div>
|
||||
</div><div v-if="finalImages.length > 0" class="final-images-section">
|
||||
<n-h4>最终图片</n-h4>
|
||||
<n-text depth="3">
|
||||
以下是生成的九宫格图片,您可以单独下载每张图片
|
||||
</n-text>
|
||||
<div class="final-images-grid">
|
||||
<div v-for="(imgDataUrl, index) in finalImages" :key="`final-${index}`" class="final-image-item">
|
||||
<img :src="imgDataUrl" :alt="`最终图片 ${index + 1}`" />
|
||||
<div class="image-number">{{ index + 1 }}</div>
|
||||
<n-button size="small" @click="() => downloadImage(imgDataUrl, `grid_image_${index + 1}.png`)" class="download-button">
|
||||
下载
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { NCard, NButton, NUpload, NSpace, NH4, NText, useMessage } from 'naive-ui';
|
||||
import type { UploadFileInfo } from 'naive-ui';
|
||||
// 直接引入 vue-cropperjs,它应该会自动包含所需的 CSS
|
||||
import VueCropper from 'vue-cropperjs';
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const originalImage = ref<string | null>(null);
|
||||
const croppedSquareImage = ref<string | null>(null);
|
||||
const cropperRef = ref<any>(null); // VueCropper Instance
|
||||
|
||||
const additionalImages = ref<(string | null)[]>(Array(9).fill(null));
|
||||
const finalImages = ref<string[]>([]);
|
||||
|
||||
// 添加九宫格位置控制变量
|
||||
const gridPositions = ref<{ x: number, y: number }[]>(
|
||||
Array(9).fill(null).map(() => ({ x: 0, y: 0 }))
|
||||
);
|
||||
|
||||
const { files, open, reset } = useFileDialog({
|
||||
accept: 'image/*',
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const handleFileChange = (data: { file: UploadFileInfo }) => {
|
||||
const file = data.file.file;
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
originalImage.value = e.target?.result as string;
|
||||
generatePreview();
|
||||
finalImages.value = []; // Reset final images
|
||||
additionalImages.value = Array(9).fill(null); // Reset additional images
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const generatePreview = async () => {
|
||||
if (!originalImage.value) return;
|
||||
|
||||
const img = new Image();
|
||||
img.src = originalImage.value;
|
||||
await new Promise(resolve => img.onload = resolve);
|
||||
|
||||
// 创建方形预览图
|
||||
const size = Math.min(img.width, img.height);
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
if (!tempCtx) return;
|
||||
|
||||
const offsetX = (img.width - size) / 2;
|
||||
const offsetY = (img.height - size) / 2;
|
||||
|
||||
tempCanvas.width = size;
|
||||
tempCanvas.height = size;
|
||||
|
||||
tempCtx.drawImage(img, offsetX, offsetY, size, size, 0, 0, size, size);
|
||||
croppedSquareImage.value = tempCanvas.toDataURL();
|
||||
|
||||
message.success('图片已准备就绪,可以查看九宫格预览');
|
||||
};
|
||||
|
||||
const onCropperReady = () => {
|
||||
if (cropperRef.value) {
|
||||
// 设置裁剪区域为方形
|
||||
const containerData = cropperRef.value.cropper.getContainerData();
|
||||
const size = Math.min(containerData.width, containerData.height) * 0.8;
|
||||
|
||||
cropperRef.value.cropper.setCropBoxData({
|
||||
left: (containerData.width - size) / 2,
|
||||
top: (containerData.height - size) / 2,
|
||||
width: size,
|
||||
height: size
|
||||
});
|
||||
|
||||
message.success('可以调整裁剪区域');
|
||||
}
|
||||
};
|
||||
|
||||
const cropImage = () => {
|
||||
if (cropperRef.value) {
|
||||
croppedSquareImage.value = cropperRef.value.getCroppedCanvas({
|
||||
imageSmoothingQuality: 'high',
|
||||
}).toDataURL();
|
||||
|
||||
message.success('图片裁剪成功!');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdditionalImageChange = (data: { file: UploadFileInfo }, index: number) => {
|
||||
const file = data.file.file;
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
additionalImages.value[index] = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const generateFinalImages = async () => {
|
||||
if (!originalImage.value) {
|
||||
message.error('请先上传一张图片');
|
||||
return;
|
||||
}
|
||||
finalImages.value = [];
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
message.error('无法创建画布');
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载原始图片
|
||||
const originalImg = new Image();
|
||||
originalImg.src = originalImage.value;
|
||||
await new Promise(resolve => originalImg.onload = resolve);
|
||||
|
||||
// 确保图片是正方形的
|
||||
const size = Math.min(originalImg.width, originalImg.height);
|
||||
const offsetX = (originalImg.width - size) / 2;
|
||||
const offsetY = (originalImg.height - size) / 2;
|
||||
|
||||
// 每个格子的尺寸
|
||||
const gridSize = size / 3;
|
||||
|
||||
// 为每个格子生成图片
|
||||
for (let i = 0; i < 9; i++) {
|
||||
// 计算当前格子在原图中的位置
|
||||
const row = Math.floor(i / 3);
|
||||
const col = i % 3;
|
||||
const srcX = offsetX + col * gridSize;
|
||||
const srcY = offsetY + row * gridSize;
|
||||
|
||||
// 加载额外图片(如果有)
|
||||
let additionalImg: HTMLImageElement | null = null;
|
||||
let additionalHeight = 0;
|
||||
|
||||
if (additionalImages.value[i]) {
|
||||
additionalImg = new Image();
|
||||
additionalImg.src = additionalImages.value[i] as string;
|
||||
await new Promise(resolve => additionalImg!.onload = resolve);
|
||||
|
||||
// 计算额外图片等比例缩放后的高度
|
||||
additionalHeight = (gridSize / additionalImg.width) * additionalImg.height;
|
||||
}
|
||||
|
||||
// 设置画布尺寸
|
||||
canvas.width = gridSize;
|
||||
canvas.height = gridSize + additionalHeight;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 绘制九宫格中的一格
|
||||
ctx.drawImage(
|
||||
originalImg,
|
||||
srcX, srcY, gridSize, gridSize, // 从原图截取的区域
|
||||
0, 0, gridSize, gridSize // 绘制到画布的位置和大小
|
||||
);
|
||||
|
||||
// 如果有额外图片,绘制在下方
|
||||
if (additionalImg) {
|
||||
ctx.drawImage(
|
||||
additionalImg,
|
||||
0, gridSize, gridSize, additionalHeight
|
||||
);
|
||||
}
|
||||
|
||||
// 保存生成的图片
|
||||
finalImages.value.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
|
||||
message.success('九宫格图片已生成!可以单独下载每张图片');
|
||||
};
|
||||
|
||||
const downloadImage = (dataUrl: string, filename: string) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const resetCropper = () => {
|
||||
if (cropperRef.value) {
|
||||
cropperRef.value.cropper.reset();
|
||||
message.info('已重置裁剪区域');
|
||||
}
|
||||
};
|
||||
|
||||
const removeAdditionalImage = (index: number) => {
|
||||
additionalImages.value[index] = null;
|
||||
message.success('已删除附加图片');
|
||||
};
|
||||
|
||||
const downloadAllImages = () => {
|
||||
if (finalImages.value.length === 0) {
|
||||
message.error('请先生成九宫格图片');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建一个 zip 文件的替代方案
|
||||
// 这里我们简单地连续下载所有图片
|
||||
finalImages.value.forEach((dataUrl, index) => {
|
||||
setTimeout(() => {
|
||||
downloadImage(dataUrl, `grid_image_${index + 1}.png`);
|
||||
}, index * 300); // 避免浏览器阻止多次下载,添加延迟
|
||||
});
|
||||
|
||||
message.success('正在下载所有图片...');
|
||||
};
|
||||
|
||||
// 生成CSS clip-path以显示图片的特定部分
|
||||
const generateClipPath = (index: number) => {
|
||||
const row = Math.floor(index / 3);
|
||||
const col = index % 3;
|
||||
const startX = col * 33.33;
|
||||
const startY = row * 33.33;
|
||||
const endX = startX + 33.33;
|
||||
const endY = startY + 33.33;
|
||||
|
||||
return `polygon(${startX}% ${startY}%, ${endX}% ${startY}%, ${endX}% ${endY}%, ${startX}% ${endY}%)`;
|
||||
};
|
||||
|
||||
// 计算图片的transform-origin,确保正确缩放
|
||||
const calculateTransformOrigin = (index: number) => {
|
||||
const row = Math.floor(index / 3);
|
||||
const col = index % 3;
|
||||
const originX = col * 50; // 使用百分比
|
||||
const originY = row * 50;
|
||||
|
||||
return `${originX}% ${originY}%`;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dynamic-nine-grid-tool {
|
||||
max-width: 900px;
|
||||
margin: auto;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border: 1px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
margin: 1rem 0;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
background-color: #fafafa;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.cropper-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
margin: 1.5rem 0;
|
||||
padding: 15px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.nine-grid-preview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 8px;
|
||||
margin: 1rem auto;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.grid-item-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.grid-image-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-position-indicator {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.grid-image-base {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.additional-image-preview {
|
||||
width: 100%;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.additional-image-preview img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.grid-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.final-images-section {
|
||||
margin-top: 1.5rem;
|
||||
padding: 15px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.final-images-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.final-image-item {
|
||||
position: relative;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.final-image-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-number {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.whole-image-preview {
|
||||
position: relative;
|
||||
margin: 20px auto;
|
||||
max-width: 300px;
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.whole-preview-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.grid-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-overlay-cell {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.7);
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cell-number {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.grid-image-wrapper {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-image-base {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.original-image-preview {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -225,7 +225,7 @@ export default //管理页面
|
||||
{
|
||||
path: 'tools/dynamic-nine-grid',
|
||||
name: 'ManageToolDynamicNineGrid',
|
||||
component: () => import('@/components/manage/tools/ToolDynamicNineGrid.vue'),
|
||||
component: () => import('@/views/manage/tools/ToolDynamicNineGrid.vue'),
|
||||
meta: {
|
||||
title: '动态九图生成器',
|
||||
parent: 'manage-tools-dashboard' // 指向工具箱仪表盘
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
} from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { NAlert, NButton, NCard, NDivider, NIcon, NSpace, NSpin, NText, NTime, NTooltip, useMessage } from 'naive-ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { NAlert, NButton, NCard, NDatePicker, NDivider, NIcon, NSpace, NSpin, NText, NTime, NTooltip, useMessage } from 'naive-ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
|
||||
@@ -88,6 +88,26 @@ const isLoading = ref(true)
|
||||
const statisticStartDate = new Date(2023, 10, 4)
|
||||
const statisticStartDateTime = statisticStartDate.getTime()
|
||||
|
||||
// 日期范围选择(毫秒时间戳区间)
|
||||
const dateRange = ref<[number, number] | null>(null)
|
||||
const dateShortcuts: Record<string, [number, number] | (() => [number, number])> = {
|
||||
'最近7天': () => {
|
||||
const end = endOfDay(new Date()).getTime()
|
||||
const start = startOfDay(addDays(new Date(), -6)).getTime()
|
||||
return [start, end] as [number, number]
|
||||
},
|
||||
'最近30天': () => {
|
||||
const end = endOfDay(new Date()).getTime()
|
||||
const start = startOfDay(addDays(new Date(), -29)).getTime()
|
||||
return [start, end] as [number, number]
|
||||
},
|
||||
'最近90天': () => {
|
||||
const end = endOfDay(new Date()).getTime()
|
||||
const start = startOfDay(addDays(new Date(), -89)).getTime()
|
||||
return [start, end] as [number, number]
|
||||
},
|
||||
}
|
||||
|
||||
// 响应式图表高度
|
||||
const chartHeight = computed(() => {
|
||||
// 可以根据窗口大小动态调整图表高度
|
||||
@@ -150,103 +170,99 @@ function getBaseChartOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成每日时间序列数据的通用函数
|
||||
* @param historyData 原始历史记录
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
* @param initialTimeIndex 起始索引
|
||||
* @param initialCount 初始计数值
|
||||
*/
|
||||
function generateTimeSeries(
|
||||
historyData: HistoryRecordModel[],
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
initialTimeIndex: number,
|
||||
initialCount: number
|
||||
) {
|
||||
const timeSeries: { time: Date; count: number; change: boolean; exist: boolean }[] = []
|
||||
let lastDayCount = initialCount
|
||||
let lastTimeIndex = initialTimeIndex
|
||||
let currentTime = startTime
|
||||
|
||||
while (currentTime <= endTime) {
|
||||
const dayEndTime = endOfDay(currentTime).getTime()
|
||||
let dayExist = false
|
||||
|
||||
while (true) {
|
||||
const data = historyData[lastTimeIndex]
|
||||
if (!data) {
|
||||
break
|
||||
}
|
||||
|
||||
if ((historyData[lastTimeIndex + 1]?.time ?? Number.MAX_VALUE) > dayEndTime) {
|
||||
const changed = data.count !== lastDayCount
|
||||
lastDayCount = data.count
|
||||
dayExist = true
|
||||
break
|
||||
}
|
||||
lastTimeIndex++
|
||||
}
|
||||
|
||||
timeSeries.push({
|
||||
time: currentTime,
|
||||
count: lastDayCount,
|
||||
change: lastDayCount !== (timeSeries[timeSeries.length - 1]?.count ?? initialCount),
|
||||
exist: dayExist,
|
||||
})
|
||||
|
||||
currentTime = addDays(currentTime, 1)
|
||||
}
|
||||
|
||||
return timeSeries
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理粉丝历史数据并生成图表选项
|
||||
*/
|
||||
function processFansChartOptions() {
|
||||
if (!fansHistory.value || fansHistory.value.length === 0) return
|
||||
|
||||
// 确定开始时间
|
||||
let startTime = new Date(accountInfo.value?.createAt ?? Date.now())
|
||||
startTime = startTime < statisticStartDate ? statisticStartDate : startTime
|
||||
startTime = startOfDay(startTime)
|
||||
const endTime = new Date()
|
||||
let startTimeBase = new Date(accountInfo.value?.createAt ?? Date.now())
|
||||
startTimeBase = startTimeBase < statisticStartDate ? statisticStartDate : startTimeBase
|
||||
const startTime = startOfDay(dateRange.value ? new Date(dateRange.value[0]) : startTimeBase)
|
||||
const endTime = dateRange.value ? new Date(dateRange.value[1]) : new Date()
|
||||
|
||||
if (startTime > endTime) {
|
||||
fansOption.value = { ...getBaseChartOptions(), series: [] } // Simplified empty state
|
||||
return
|
||||
}
|
||||
|
||||
const initialIndex = fansHistory.value.findIndex((entry) => entry.time >= statisticStartDateTime)
|
||||
const initialCount = initialIndex >= 0 ? fansHistory.value[initialIndex].count : 0
|
||||
|
||||
const completeTimeSeries = generateTimeSeries(fansHistory.value, startTime, endTime, initialIndex, initialCount)
|
||||
|
||||
// 用于存储完整的时间序列数据
|
||||
const completeTimeSeries: { time: Date; count: number; change: boolean, exist: boolean }[] = []
|
||||
// 用于存储粉丝增量数据
|
||||
const fansIncreacement: { time: Date; count: number }[] = []
|
||||
|
||||
// 查找统计开始时间之后的第一个数据点
|
||||
let lastFansTimeIndex = fansHistory.value.length > 0
|
||||
? fansHistory.value[0].time >= statisticStartDateTime
|
||||
? 0
|
||||
: fansHistory.value.findIndex((entry) => entry.time >= statisticStartDateTime)
|
||||
: -1
|
||||
let lastDayCount = lastFansTimeIndex >= 0 ? fansHistory.value[lastFansTimeIndex].count : 0
|
||||
|
||||
// 生成完整的天序列数据
|
||||
let currentTime = startTime
|
||||
while (currentTime <= endTime) {
|
||||
const dayEndTime = endOfDay(currentTime).getTime()
|
||||
while (true) {
|
||||
const data = fansHistory.value[lastFansTimeIndex]
|
||||
if (!data) {
|
||||
completeTimeSeries.push({
|
||||
time: currentTime,
|
||||
count: lastDayCount,
|
||||
change: false,
|
||||
exist: false,
|
||||
})
|
||||
break
|
||||
}
|
||||
// 如果下一个数据的时间大于当前天的结束时间
|
||||
if ((fansHistory.value[lastFansTimeIndex + 1]?.time ?? Number.MAX_VALUE) > dayEndTime) {
|
||||
const changed = data.count !== lastDayCount
|
||||
lastDayCount = data.count
|
||||
|
||||
completeTimeSeries.push({
|
||||
time: currentTime,
|
||||
count: lastDayCount,
|
||||
change: changed,
|
||||
exist: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
lastFansTimeIndex++
|
||||
}
|
||||
|
||||
currentTime = addDays(currentTime, 1) // 移动到下一天
|
||||
}
|
||||
|
||||
// 计算粉丝增量数据
|
||||
let previousDayCount = completeTimeSeries[0].count
|
||||
completeTimeSeries.forEach((entry, index, array) => {
|
||||
if (index === 0 || !isSameDay(entry.time, array[index - 1].time)) {
|
||||
let previousDayCount = completeTimeSeries[0]?.count ?? 0
|
||||
completeTimeSeries.forEach((entry, index) => {
|
||||
if (index > 0) {
|
||||
const dailyIncrement = entry.count - previousDayCount
|
||||
fansIncreacement.push({
|
||||
time: startOfDay(array[index - 1].time),
|
||||
count: dailyIncrement,
|
||||
})
|
||||
fansIncreacement.push({ time: startOfDay(entry.time), count: dailyIncrement })
|
||||
}
|
||||
previousDayCount = entry.count
|
||||
} else if (index === array.length - 1) {
|
||||
const dailyIncrement = entry.count - previousDayCount
|
||||
fansIncreacement.push({
|
||||
time: startOfDay(entry.time),
|
||||
count: dailyIncrement,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 准备图表数据
|
||||
const chartData = {
|
||||
xAxisData: completeTimeSeries.map((entry) => format(entry.time, 'yyyy-MM-dd')),
|
||||
hourlyCounts: completeTimeSeries.map((entry) => entry.count),
|
||||
dailyIncrements: fansIncreacement.map((entry) => ({
|
||||
date: format(entry.time, 'yyyy-MM-dd'),
|
||||
count: entry.count,
|
||||
})),
|
||||
seriesData: completeTimeSeries.map((entry) => entry.count),
|
||||
incrementData: fansIncreacement.map((entry) => entry.count),
|
||||
}
|
||||
|
||||
// 生成图表配置
|
||||
const baseOptions = getBaseChartOptions()
|
||||
fansOption.value = {
|
||||
...baseOptions,
|
||||
...getBaseChartOptions(),
|
||||
tooltip: {
|
||||
...baseOptions.tooltip,
|
||||
...getBaseChartOptions().tooltip,
|
||||
formatter: (param: any) => {
|
||||
const name = param[0].name + '<br>'
|
||||
let str = ''
|
||||
@@ -260,54 +276,22 @@ function processFansChartOptions() {
|
||||
},
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '粉丝数',
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '每日增量',
|
||||
},
|
||||
{ type: 'value', name: '粉丝数', min: 'dataMin' },
|
||||
{ type: 'value', name: '每日增量' },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
axisTick: {
|
||||
alignWithLabel: true,
|
||||
},
|
||||
axisLine: {
|
||||
onZero: false,
|
||||
lineStyle: {
|
||||
color: '#5470C6',
|
||||
},
|
||||
},
|
||||
data: chartData.xAxisData,
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
axisTick: {
|
||||
alignWithLabel: true,
|
||||
},
|
||||
axisLine: {
|
||||
onZero: false,
|
||||
lineStyle: {
|
||||
color: '#EE6666',
|
||||
},
|
||||
},
|
||||
data: fansIncreacement.map((f) => format(f.time, 'yyyy-MM-dd')),
|
||||
},
|
||||
{ type: 'category', data: chartData.xAxisData },
|
||||
{ type: 'category', data: chartData.xAxisData.slice(1) }, // Align increments
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '粉丝数',
|
||||
type: 'line',
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
data: chartData.hourlyCounts,
|
||||
data: chartData.seriesData,
|
||||
itemStyle: {
|
||||
color: function (data: any) {
|
||||
return completeTimeSeries[data.dataIndex].change ? '#18a058' : '#5470C6'
|
||||
color: (data: any) => {
|
||||
const item = completeTimeSeries[data.dataIndex]
|
||||
return !item.exist ? '#cccccc' : item.change ? '#18a058' : '#5470C6'
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -316,15 +300,8 @@ function processFansChartOptions() {
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
xAxisIndex: 1,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
data: chartData.dailyIncrements.map((f) => f.count),
|
||||
itemStyle: {
|
||||
color: function (params: any) {
|
||||
return params.value < 0 ? '#FF4D4F' : '#3398DB' // 负数时红色,正数时默认颜色
|
||||
},
|
||||
},
|
||||
data: chartData.incrementData,
|
||||
itemStyle: { color: (params: any) => (params.value < 0 ? '#FF4D4F' : '#3398DB') },
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -336,120 +313,143 @@ function processFansChartOptions() {
|
||||
function processGuardsChartOptions() {
|
||||
if (!guardHistory.value || guardHistory.value.length === 0) return
|
||||
|
||||
// 确定开始时间
|
||||
let startTime = new Date(accountInfo.value?.createAt ?? Date.now())
|
||||
startTime = startTime < statisticStartDate ? statisticStartDate : startTime
|
||||
startTime = startOfDay(startTime)
|
||||
const endTime = new Date()
|
||||
let startTimeBase = new Date(accountInfo.value?.createAt ?? Date.now())
|
||||
startTimeBase = startTimeBase < statisticStartDate ? statisticStartDate : startTimeBase
|
||||
const startTime = startOfDay(dateRange.value ? new Date(dateRange.value[0]) : startTimeBase)
|
||||
const endTime = dateRange.value ? new Date(dateRange.value[1]) : new Date()
|
||||
|
||||
// 生成完整的舰长天序列
|
||||
const completeGuardTimeSeries: { time: Date; count: number }[] = []
|
||||
let currentGuardTime = startTime
|
||||
let lastGuardTimeIndex = 0
|
||||
let lastDayGuardCount = 0
|
||||
|
||||
while (currentGuardTime <= endTime) {
|
||||
const dayEndTime = endOfDay(currentGuardTime).getTime()
|
||||
while (true) {
|
||||
const data = guardHistory.value[lastGuardTimeIndex]
|
||||
if (!data) {
|
||||
completeGuardTimeSeries.push({
|
||||
time: currentGuardTime,
|
||||
count: lastDayGuardCount,
|
||||
})
|
||||
break
|
||||
if (startTime > endTime) {
|
||||
guardsOption.value = { ...getBaseChartOptions(), series: [] } // Simplified empty state
|
||||
return
|
||||
}
|
||||
|
||||
if ((guardHistory.value[lastGuardTimeIndex + 1]?.time ?? Number.MAX_VALUE) > dayEndTime) {
|
||||
lastDayGuardCount = data.count
|
||||
completeGuardTimeSeries.push({
|
||||
time: currentGuardTime,
|
||||
count: lastDayGuardCount,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
lastGuardTimeIndex++
|
||||
}
|
||||
|
||||
currentGuardTime = addDays(currentGuardTime, 1) // 移动到下一天
|
||||
}
|
||||
|
||||
// 计算守护增量数据
|
||||
const guardsIncreacement: { time: number; count: number; timeString: string }[] = []
|
||||
const guards: { time: number; count: number; timeString: string }[] = []
|
||||
|
||||
let lastDayGuards = 0
|
||||
let lastDay = 0
|
||||
|
||||
completeGuardTimeSeries.forEach((g) => {
|
||||
if (!isSameDay(g.time, new Date(lastDay * 1000))) {
|
||||
guardsIncreacement.push({
|
||||
time: lastDayGuards,
|
||||
count: lastDay === 0 ? 0 : g.count - lastDayGuards,
|
||||
timeString: format(g.time, 'yyyy-MM-dd'),
|
||||
})
|
||||
guards.push({
|
||||
time: g.time.getTime() / 1000,
|
||||
count: g.count,
|
||||
timeString: format(g.time, 'yyyy-MM-dd'),
|
||||
})
|
||||
lastDay = g.time.getTime() / 1000
|
||||
lastDayGuards = g.count
|
||||
const initialIndex = guardHistory.value.findIndex((entry) => entry.time >= startTime.getTime())
|
||||
const initialCount = initialIndex >= 0 ? guardHistory.value[initialIndex].count : 0
|
||||
|
||||
const completeTimeSeries = generateTimeSeries(guardHistory.value, startTime, endTime, initialIndex, initialCount)
|
||||
|
||||
const guardIncrements: number[] = []
|
||||
let previousDayCount = completeTimeSeries[0]?.count ?? 0
|
||||
completeTimeSeries.forEach((entry, index) => {
|
||||
if (index > 0) {
|
||||
guardIncrements.push(entry.count - previousDayCount)
|
||||
}
|
||||
previousDayCount = entry.count
|
||||
})
|
||||
|
||||
// 生成图表配置
|
||||
const baseOptions = getBaseChartOptions()
|
||||
const xAxisData = completeTimeSeries.map((entry) => format(entry.time, 'yyyy-MM-dd'))
|
||||
|
||||
guardsOption.value = {
|
||||
...baseOptions,
|
||||
...getBaseChartOptions(),
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
},
|
||||
{ type: 'value', name: '舰长数', min: 'dataMin' },
|
||||
{ type: 'value', name: '日增' },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
axisTick: {
|
||||
alignWithLabel: true,
|
||||
},
|
||||
axisLine: {
|
||||
onZero: false,
|
||||
lineStyle: {
|
||||
color: '#EE6666',
|
||||
},
|
||||
},
|
||||
data: guardsIncreacement.map((f) => f.timeString),
|
||||
},
|
||||
{ type: 'category', data: xAxisData },
|
||||
{ type: 'category', data: xAxisData.slice(1) },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '舰长数',
|
||||
type: 'line',
|
||||
step: 'middle',
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
data: completeTimeSeries.map(item => item.count),
|
||||
itemStyle: {
|
||||
color: (data: any) => completeTimeSeries[data.dataIndex].exist ? '#5470C6' : '#cccccc',
|
||||
},
|
||||
data: guards.map((f) => f.count),
|
||||
},
|
||||
{
|
||||
name: '日增',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
xAxisIndex: 1,
|
||||
data: guardIncrements,
|
||||
itemStyle: { color: (params: any) => (params.value < 0 ? '#FF4D4F' : '#3398DB') },
|
||||
},
|
||||
data: guardsIncreacement.map((f) => f.count),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理投稿数据并生成图表选项的通用函数
|
||||
* @param {('views' | 'likes')} dataType - 要处理的数据类型 ('views' 或 'likes')
|
||||
* @param {string} title - 图表主标题
|
||||
*/
|
||||
function processUpstatChartOptions(dataType: 'views' | 'likes', title: string) {
|
||||
if (!upstatHistory.value || upstatHistory.value.length === 0) {
|
||||
return {
|
||||
...getBaseChartOptions(),
|
||||
xAxis: [{ type: 'category', data: [] }],
|
||||
yAxis: [{ type: 'value' }, { type: 'value' }],
|
||||
series: [
|
||||
{ name: title, type: 'line', data: [] },
|
||||
{ name: '日增', type: 'bar', data: [], yAxisIndex: 1 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const rangeStart = dateRange.value ? dateRange.value[0] : -Infinity
|
||||
const rangeEnd = dateRange.value ? dateRange.value[1] : Infinity
|
||||
const filtered = upstatHistory.value.filter((u) => u.time >= rangeStart && u.time <= rangeEnd)
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return {
|
||||
...getBaseChartOptions(),
|
||||
xAxis: [{ type: 'category', data: [] }],
|
||||
yAxis: [{ type: 'value' }, { type: 'value' }],
|
||||
series: [
|
||||
{ name: title, type: 'line', data: [] },
|
||||
{ name: '日增', type: 'bar', data: [], yAxisIndex: 1 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const increments: { time: number; value: number }[] = []
|
||||
let lastValue = filtered[0].stats[dataType]
|
||||
|
||||
filtered.forEach((u) => {
|
||||
const currentValue = u.stats[dataType]
|
||||
increments.push({
|
||||
time: u.time,
|
||||
value: currentValue - lastValue,
|
||||
})
|
||||
lastValue = currentValue
|
||||
})
|
||||
|
||||
return {
|
||||
...getBaseChartOptions(),
|
||||
yAxis: [
|
||||
{ type: 'value', min: 'dataMin' },
|
||||
{ type: 'value' },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
axisTick: { alignWithLabel: true },
|
||||
axisLine: { onZero: false, lineStyle: { color: '#EE6666' } },
|
||||
data: filtered.map((f) => format(f.time, 'yyyy-MM-dd')),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: title,
|
||||
type: 'line',
|
||||
emphasis: { focus: 'series' },
|
||||
data: filtered.map((f) => f.stats[dataType]),
|
||||
itemStyle: {
|
||||
color: function (params: any) {
|
||||
return params.value < 0 ? '#FF4D4F' : '#3398DB'
|
||||
color: function (data: any) {
|
||||
return increments[data.dataIndex].value !== 0 ? '#5470C6' : '#cccccc'
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '日增',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
emphasis: { focus: 'series' },
|
||||
data: increments.map((f) => f.value),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -458,134 +458,14 @@ function processGuardsChartOptions() {
|
||||
* 处理播放量历史数据并生成图表选项
|
||||
*/
|
||||
function processUpstatViewChartOptions() {
|
||||
if (!upstatHistory.value || upstatHistory.value.length === 0) return
|
||||
|
||||
// 计算播放量增量数据
|
||||
const upstatViewIncreace: { time: number; value: number }[] = []
|
||||
let lastUpstatView = upstatHistory.value[0].stats.views
|
||||
|
||||
upstatHistory.value.forEach((u) => {
|
||||
upstatViewIncreace.push({
|
||||
time: u.time,
|
||||
value: u.stats.views - lastUpstatView,
|
||||
})
|
||||
lastUpstatView = u.stats.views
|
||||
})
|
||||
|
||||
// 生成图表配置
|
||||
const baseOptions = getBaseChartOptions()
|
||||
upstatViewOption.value = {
|
||||
...baseOptions,
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
},
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
axisTick: {
|
||||
alignWithLabel: true,
|
||||
},
|
||||
axisLine: {
|
||||
onZero: false,
|
||||
lineStyle: {
|
||||
color: '#EE6666',
|
||||
},
|
||||
},
|
||||
data: upstatHistory.value.map((f) => format(f.time, 'yyyy-MM-dd')),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '播放数',
|
||||
type: 'line',
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
data: upstatHistory.value.map((f) => f.stats.views),
|
||||
},
|
||||
{
|
||||
name: '日增',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
data: upstatViewIncreace.map((f) => f.value),
|
||||
},
|
||||
],
|
||||
}
|
||||
upstatViewOption.value = processUpstatChartOptions('views', '播放数')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理点赞量历史数据并生成图表选项
|
||||
*/
|
||||
function processUpstatLikeChartOptions() {
|
||||
if (!upstatHistory.value || upstatHistory.value.length === 0) return
|
||||
|
||||
// 计算点赞量增量数据
|
||||
const upstatLikeIncreace: { time: number; value: number }[] = []
|
||||
let lastUpstatLike = upstatHistory.value[0].stats.likes
|
||||
|
||||
upstatHistory.value.forEach((u) => {
|
||||
upstatLikeIncreace.push({
|
||||
time: u.time,
|
||||
value: u.stats.likes - lastUpstatLike,
|
||||
})
|
||||
lastUpstatLike = u.stats.likes
|
||||
})
|
||||
|
||||
// 生成图表配置
|
||||
const baseOptions = getBaseChartOptions()
|
||||
upstatLikeOption.value = {
|
||||
...baseOptions,
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
},
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
axisTick: {
|
||||
alignWithLabel: true,
|
||||
},
|
||||
axisLine: {
|
||||
onZero: false,
|
||||
lineStyle: {
|
||||
color: '#EE6666',
|
||||
},
|
||||
},
|
||||
data: upstatHistory.value.map((f) => format(f.time, 'yyyy-MM-dd')),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '点赞数',
|
||||
type: 'line',
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
data: upstatHistory.value.map((f) => f.stats.likes),
|
||||
},
|
||||
{
|
||||
name: '日增',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
data: upstatLikeIncreace.map((f) => f.value),
|
||||
},
|
||||
],
|
||||
}
|
||||
upstatLikeOption.value = processUpstatChartOptions('likes', '点赞数')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -605,6 +485,14 @@ onMounted(async () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 选择日期范围后,自动刷新图表
|
||||
watch(
|
||||
() => dateRange.value,
|
||||
() => {
|
||||
if (!isLoading.value) processAllChartOptions()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -676,6 +564,17 @@ onMounted(async () => {
|
||||
</NTooltip>
|
||||
<br>
|
||||
<br>
|
||||
<NSpace align="center">
|
||||
<NText depth="3">日期范围:</NText>
|
||||
<NDatePicker
|
||||
v-model:value="dateRange"
|
||||
type="daterange"
|
||||
clearable
|
||||
separator="至"
|
||||
:shortcuts="dateShortcuts"
|
||||
/>
|
||||
</NSpace>
|
||||
<br>
|
||||
<NSpace
|
||||
vertical
|
||||
class="charts-container"
|
||||
|
||||
@@ -252,6 +252,8 @@
|
||||
const settingModalVisiable = ref(false);
|
||||
const showAddVideoModal = ref(false);
|
||||
const showAddLinkModal = ref(false);
|
||||
const editingLinkName = ref<string | null>(null);
|
||||
const newLinkName = ref('');
|
||||
|
||||
// 主页数据
|
||||
const indexDisplayInfo = ref<ResponseUserIndexModel>();
|
||||
@@ -259,6 +261,15 @@
|
||||
const addLinkName = ref('');
|
||||
const addLinkUrl = ref('');
|
||||
const linkKey = ref(0);
|
||||
// 初始化 linkOrder (兼容旧数据)
|
||||
onMounted(() => {
|
||||
if (accountInfo.value?.settings?.index) {
|
||||
const idx = accountInfo.value.settings.index;
|
||||
if (!idx.linkOrder || idx.linkOrder.length === 0) {
|
||||
idx.linkOrder = Object.keys(idx.links || {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取B站用户数据
|
||||
@@ -461,10 +472,56 @@
|
||||
*/
|
||||
async function removeLink(name: string) {
|
||||
delete accountInfo.value.settings.index.links[name];
|
||||
if (accountInfo.value.settings.index.linkOrder) {
|
||||
accountInfo.value.settings.index.linkOrder = accountInfo.value.settings.index.linkOrder.filter(k => k !== name);
|
||||
}
|
||||
await updateIndexSettings();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
/** 上移/下移视频 */
|
||||
function moveVideo(id: string, dir: 'up' | 'down') {
|
||||
const list = accountInfo.value.settings.index.videos;
|
||||
const i = list.indexOf(id);
|
||||
if (i === -1) return;
|
||||
const ni = dir === 'up' ? i - 1 : i + 1;
|
||||
if (ni < 0 || ni >= list.length) return;
|
||||
[list[i], list[ni]] = [list[ni], list[i]];
|
||||
updateIndexSettings();
|
||||
}
|
||||
/** 上移/下移链接 */
|
||||
function moveLink(name: string, dir: 'up' | 'down') {
|
||||
const order = accountInfo.value.settings.index.linkOrder;
|
||||
if (!order) return;
|
||||
const i = order.indexOf(name);
|
||||
const ni = dir === 'up' ? i - 1 : i + 1;
|
||||
if (i === -1 || ni < 0 || ni >= order.length) return;
|
||||
[order[i], order[ni]] = [order[ni], order[i]];
|
||||
updateIndexSettings();
|
||||
linkKey.value++;
|
||||
}
|
||||
/** 编辑链接名称 */
|
||||
function startEditLink(name: string) {
|
||||
editingLinkName.value = name;
|
||||
newLinkName.value = name;
|
||||
}
|
||||
async function confirmEditLink(oldName: string) {
|
||||
const idxSetting = accountInfo.value.settings.index;
|
||||
if (!newLinkName.value || newLinkName.value === oldName) {
|
||||
editingLinkName.value = null; return;
|
||||
}
|
||||
if (idxSetting.links[newLinkName.value]) { message.error('名称已存在'); return; }
|
||||
idxSetting.links[newLinkName.value] = idxSetting.links[oldName];
|
||||
delete idxSetting.links[oldName];
|
||||
if (idxSetting.linkOrder) {
|
||||
idxSetting.linkOrder = idxSetting.linkOrder.map(k => k === oldName ? newLinkName.value : k);
|
||||
}
|
||||
await updateIndexSettings();
|
||||
editingLinkName.value = null;
|
||||
linkKey.value++;
|
||||
}
|
||||
function cancelEditLink() { editingLinkName.value = null; }
|
||||
|
||||
/**
|
||||
* 打开模板设置
|
||||
*/
|
||||
@@ -719,12 +776,23 @@
|
||||
>
|
||||
<SimpleVideoCard :video="item" />
|
||||
<template #footer>
|
||||
<NSpace>
|
||||
<NButton
|
||||
size="small"
|
||||
secondary
|
||||
@click="moveVideo(item.id, 'up')"
|
||||
>上移</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
secondary
|
||||
@click="moveVideo(item.id, 'down')"
|
||||
>下移</NButton>
|
||||
<NButton
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="removeVideo(item.id)"
|
||||
>
|
||||
删除
|
||||
</NButton>
|
||||
>删除</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NCard>
|
||||
</NFlex>
|
||||
@@ -743,10 +811,29 @@
|
||||
:key="linkKey"
|
||||
>
|
||||
<NFlex
|
||||
v-for="item in Object.entries(indexDisplayInfo?.links ?? {})"
|
||||
:key="item[0]"
|
||||
v-for="name in (accountInfo.settings.index.linkOrder?.filter(n=>indexDisplayInfo?.links[n]) || Object.keys(indexDisplayInfo?.links||{}))"
|
||||
:key="name"
|
||||
align="center"
|
||||
>
|
||||
<template v-if="editingLinkName === name">
|
||||
<NInput
|
||||
v-model:value="newLinkName"
|
||||
size="small"
|
||||
style="width: 100px"
|
||||
/>
|
||||
<NButton
|
||||
size="tiny"
|
||||
type="primary"
|
||||
text
|
||||
@click="confirmEditLink(name)"
|
||||
>保存</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
text
|
||||
@click="cancelEditLink"
|
||||
>取消</NButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NTag
|
||||
@@ -754,16 +841,35 @@
|
||||
size="small"
|
||||
type="info"
|
||||
>
|
||||
{{ item[0] }}
|
||||
{{ name }}
|
||||
</NTag>
|
||||
</template>
|
||||
{{ item[1] }}
|
||||
{{ indexDisplayInfo?.links[name] }}
|
||||
</NTooltip>
|
||||
<NPopconfirm @positive-click="removeLink(item[0])">
|
||||
<NSpace>
|
||||
<NButton
|
||||
size="tiny"
|
||||
secondary
|
||||
text
|
||||
@click="moveLink(name, 'up')"
|
||||
>↑</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
secondary
|
||||
text
|
||||
@click="moveLink(name, 'down')"
|
||||
>↓</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
text
|
||||
@click="startEditLink(name)"
|
||||
>改名</NButton>
|
||||
<NPopconfirm @positive-click="removeLink(name)">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
type="error"
|
||||
text
|
||||
size="tiny"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon :component="Delete24Regular" />
|
||||
@@ -772,6 +878,8 @@
|
||||
</template>
|
||||
确定要删除这个链接吗?
|
||||
</NPopconfirm>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
</NTabPane>
|
||||
@@ -890,9 +998,6 @@
|
||||
</NSpace>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</NSpin>
|
||||
</NCard>
|
||||
|
||||
<!-- 模板设置模态框 -->
|
||||
<NModal
|
||||
v-model:show="settingModalVisiable"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<n-h1>直播工具箱</n-h1>
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane name="nine-grid" tab="动态九宫格生成器">
|
||||
<DynamicNineGridGenerator />
|
||||
<ToolDynamicNineGrid />
|
||||
</n-tab-pane>
|
||||
<!-- 更多工具可以在这里添加 -->
|
||||
</n-tabs>
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NH1, NTabs, NTabPane } from 'naive-ui'
|
||||
import DynamicNineGridGenerator from './tools/DynamicNineGridGenerator.vue'
|
||||
import ToolDynamicNineGrid from '@/components/manage/tools/ToolDynamicNineGrid.vue'
|
||||
|
||||
// 后续可能需要的逻辑
|
||||
</script>
|
||||
|
||||
@@ -1,499 +0,0 @@
|
||||
<template>
|
||||
<div class="dynamic-nine-grid-generator p-4">
|
||||
<n-h2>动态九宫格图片生成器</n-h2>
|
||||
<n-space vertical :size="20">
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="handleImageUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<n-button type="primary">上传图片</n-button>
|
||||
</n-upload>
|
||||
|
||||
<div v-if="originalImageSrc" class="image-preview-area">
|
||||
<n-h3>原图预览</n-h3>
|
||||
<img ref="originalImageRef" :src="originalImageSrc" alt="Original Image" class="original-image-preview" @load="onImageLoad"/>
|
||||
</div>
|
||||
|
||||
<div v-if="cropper" class="cropper-controls">
|
||||
<n-h3>裁剪区域 (选择一个正方形区域作为小图)</n-h3>
|
||||
<div ref="cropperContainerRef" class="cropper-container"></div>
|
||||
<n-button @click="cropImage" type="info" class="mt-2">确认裁剪区域</n-button>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="croppedImageSrc" class="cropped-image-preview-area">
|
||||
<n-h3>单张小图预览</n-h3>
|
||||
<img :src="croppedImageSrc" alt="Cropped Tile" class="cropped-tile-preview" />
|
||||
</div>
|
||||
|
||||
<div v-if="croppedImageSrc" class="grid-customization">
|
||||
<n-h3>九宫格生成与自定义</n-h3>
|
||||
<n-space vertical>
|
||||
<n-text>点击下方小图进行自定义内容添加。</n-text>
|
||||
<div class="nine-grid-preview-container">
|
||||
<div
|
||||
v-for="index in 9"
|
||||
:key="index"
|
||||
class="grid-cell"
|
||||
@click="selectCellForCustomization(index -1)"
|
||||
:class="{ 'selected-cell': selectedCellIndex === index -1 }"
|
||||
>
|
||||
<img :src="gridImages[index-1]?.finalSrc || croppedImageSrc" :alt="`Grid ${index}`" />
|
||||
<div v-if="gridImages[index-1]?.customContent" class="custom-content-indicator">有自定义</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="showCustomizationModal" preset="card" title="自定义小图内容" style="width: 600px;">
|
||||
<n-space vertical v-if="selectedCellIndex !== null && gridImages[selectedCellIndex]">
|
||||
<n-h4>当前编辑: 第 {{ selectedCellIndex + 1 }} 张小图</n-h4>
|
||||
<img :src="gridImages[selectedCellIndex].baseSrc" alt="Base for customization" class="customization-base-preview"/>
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="handleAddCustomImage"
|
||||
accept="image/*"
|
||||
list-type="image-card"
|
||||
>
|
||||
<n-button>添加自定义图片</n-button>
|
||||
</n-upload>
|
||||
<div v-if="gridImages[selectedCellIndex].customImages.length > 0" class="custom-images-preview">
|
||||
<n-h5>已添加的自定义图片:</n-h5>
|
||||
<n-image-group>
|
||||
<n-space>
|
||||
<n-image
|
||||
v-for="(img, idx) in gridImages[selectedCellIndex].customImages"
|
||||
:key="idx"
|
||||
width="100"
|
||||
:src="img.src"
|
||||
:alt="`Custom ${idx + 1}`"
|
||||
/>
|
||||
</n-space>
|
||||
</n-image-group>
|
||||
<n-button @click="clearCustomImages(selectedCellIndex)" type="warning" size="small" class="mt-2">清空自定义图片</n-button>
|
||||
</div>
|
||||
<n-button @click="applyCellCustomization" type="primary">应用自定义</n-button>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
|
||||
<n-button v-if="croppedImageSrc" @click="generateAndDownloadNineGrid" type="success" :loading="isGenerating">
|
||||
生成并下载九宫格图片
|
||||
</n-button>
|
||||
<n-text v-if="isGenerating">正在生成图片,请稍候...</n-text>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch } from 'vue';
|
||||
import {
|
||||
NButton,
|
||||
NSpace,
|
||||
NUpload,
|
||||
NH2,
|
||||
NH3,
|
||||
NText,
|
||||
NModal,
|
||||
NImageGroup,
|
||||
NImage,
|
||||
NH4,
|
||||
NH5,
|
||||
useMessage
|
||||
} from 'naive-ui';
|
||||
import Cropper from 'cropperjs';
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
import { useFileDialog, useBase64, useLocalStorage } from '@vueuse/core';
|
||||
import html2canvas from 'html2canvas'; // 用于将DOM元素转为图片
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
interface GridImageData {
|
||||
baseSrc: string; // 裁剪后的基础图片
|
||||
customImages: { src: string, file: File }[]; // 用户为这个格子添加的自定义图片
|
||||
finalSrc: string | null; // 最终合成的图片 (base + custom)
|
||||
}
|
||||
|
||||
const originalImageSrc = ref<string | null>(null);
|
||||
const originalImageRef = ref<HTMLImageElement | null>(null);
|
||||
const cropperContainerRef = ref<HTMLDivElement | null>(null);
|
||||
const cropper = ref<Cropper | null>(null);
|
||||
const croppedImageSrc = ref<string | null>(null); // 存储裁剪后的单张小图数据URL
|
||||
|
||||
const gridImages = ref<GridImageData[]>([]); // 存储9张小图的数据,包括自定义内容
|
||||
const showCustomizationModal = ref(false);
|
||||
const selectedCellIndex = ref<number | null>(null);
|
||||
const isGenerating = ref(false);
|
||||
|
||||
// 使用localStorage存储一些可复用设置,例如裁剪比例等,如果需要的话
|
||||
// const cropperAspectRatio = useLocalStorage('Setting.Tools.DynamicNineGridGenerator.cropperAspectRatio', 1); // 强制1:1
|
||||
|
||||
const handleImageUpload = (options: { file: { file: File } }) => {
|
||||
const file = options.file.file;
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
originalImageSrc.value = e.target?.result as string;
|
||||
croppedImageSrc.value = null; // 清空之前的裁剪
|
||||
gridImages.value = [];
|
||||
if (cropper.value) {
|
||||
cropper.value.destroy();
|
||||
cropper.value = null;
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const onImageLoad = () => {
|
||||
nextTick(() => {
|
||||
if (originalImageRef.value && cropperContainerRef.value) {
|
||||
if (cropper.value) {
|
||||
cropper.value.destroy();
|
||||
}
|
||||
// 先清空容器再初始化,避免重复
|
||||
cropperContainerRef.value.innerHTML = '';
|
||||
const imgElement = originalImageRef.value.cloneNode() as HTMLImageElement;
|
||||
cropperContainerRef.value.appendChild(imgElement);
|
||||
|
||||
cropper.value = new Cropper(imgElement, {
|
||||
aspectRatio: 1, // 固定为正方形裁剪
|
||||
viewMode: 1,
|
||||
dragMode: 'move',
|
||||
background: false,
|
||||
autoCropArea: 0.8,
|
||||
responsive: true,
|
||||
crop(event) {
|
||||
// console.log(event.detail.x);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cropImage = () => {
|
||||
if (cropper.value) {
|
||||
const croppedCanvas = cropper.value.getCroppedCanvas();
|
||||
if (croppedCanvas) {
|
||||
croppedImageSrc.value = croppedCanvas.toDataURL();
|
||||
// 初始化九宫格图片数据
|
||||
gridImages.value = Array(9).fill(null).map(() => ({
|
||||
baseSrc: croppedImageSrc.value!,
|
||||
customImages: [],
|
||||
finalSrc: croppedImageSrc.value!, // 初始时finalSrc与baseSrc相同
|
||||
}));
|
||||
message.success('图片裁剪成功!');
|
||||
} else {
|
||||
message.error('无法裁剪图片,请重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectCellForCustomization = (index: number) => {
|
||||
selectedCellIndex.value = index;
|
||||
if (!gridImages.value[index]) { // 以防万一
|
||||
gridImages.value[index] = {
|
||||
baseSrc: croppedImageSrc.value!,
|
||||
customImages: [],
|
||||
finalSrc: croppedImageSrc.value!,
|
||||
};
|
||||
}
|
||||
showCustomizationModal.value = true;
|
||||
};
|
||||
|
||||
const handleAddCustomImage = (options: { file: { file: File } }) => {
|
||||
const file = options.file.file;
|
||||
if (file && selectedCellIndex.value !== null) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
gridImages.value[selectedCellIndex.value!]?.customImages.push({ src: e.target?.result as string, file });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCustomImages = (index: number) => {
|
||||
if (gridImages.value[index]) {
|
||||
gridImages.value[index].customImages = [];
|
||||
// 需要重新合成或标记为待合成
|
||||
applyCellCustomization();
|
||||
}
|
||||
};
|
||||
|
||||
const applyCellCustomization = async () => {
|
||||
if (selectedCellIndex.value === null || !gridImages.value[selectedCellIndex.value]) {
|
||||
showCustomizationModal.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const cell = gridImages.value[selectedCellIndex.value];
|
||||
const baseImg = new Image();
|
||||
baseImg.src = cell.baseSrc;
|
||||
|
||||
await new Promise(resolve => baseImg.onload = resolve);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
message.error('无法获取Canvas绘图上下文');
|
||||
showCustomizationModal.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let totalHeight = baseImg.height;
|
||||
const customImageElements: HTMLImageElement[] = [];
|
||||
|
||||
for (const customImgData of cell.customImages) {
|
||||
const img = new Image();
|
||||
img.src = customImgData.src;
|
||||
await new Promise(resolve => img.onload = resolve);
|
||||
totalHeight += img.height;
|
||||
customImageElements.push(img);
|
||||
}
|
||||
|
||||
canvas.width = baseImg.width; // 宽度以基础裁剪图为准
|
||||
canvas.height = totalHeight;
|
||||
|
||||
ctx.drawImage(baseImg, 0, 0);
|
||||
|
||||
let currentY = baseImg.height;
|
||||
for (const img of customImageElements) {
|
||||
ctx.drawImage(img, 0, currentY);
|
||||
currentY += img.height;
|
||||
}
|
||||
|
||||
cell.finalSrc = canvas.toDataURL();
|
||||
message.success(`第 ${selectedCellIndex.value + 1} 张小图自定义已应用`);
|
||||
showCustomizationModal.value = false;
|
||||
selectedCellIndex.value = null;
|
||||
};
|
||||
|
||||
|
||||
const generateAndDownloadNineGrid = async () => {
|
||||
if (!croppedImageSrc.value || gridImages.value.some(img => !img.finalSrc)) {
|
||||
message.error('请先上传并裁剪图片,并确保所有小图都已处理。');
|
||||
return;
|
||||
}
|
||||
isGenerating.value = true;
|
||||
message.info("开始生成九宫格大图,请稍候...");
|
||||
|
||||
// 确保所有finalSrc都是最新的
|
||||
for (let i = 0; i < gridImages.value.length; i++) {
|
||||
if (!gridImages.value[i].finalSrc) { // 如果有未处理的(理论上不应该,因为applyCellCustomization会处理)
|
||||
await applyCellCustomizationInternal(i); // 复用一个内部的合成逻辑
|
||||
}
|
||||
}
|
||||
|
||||
// 创建一个临时的容器来渲染九宫格
|
||||
const tempGridContainer = document.createElement('div');
|
||||
tempGridContainer.style.display = 'grid';
|
||||
tempGridContainer.style.gridTemplateColumns = 'repeat(3, 1fr)';
|
||||
tempGridContainer.style.gridTemplateRows = 'repeat(3, 1fr)';
|
||||
tempGridContainer.style.gap = '4px'; // B站动态图片间隔
|
||||
tempGridContainer.style.position = 'absolute'; // 防止影响当前页面布局
|
||||
tempGridContainer.style.left = '-9999px'; // 移出视口
|
||||
|
||||
const imageLoadPromises = gridImages.value.map((imgData, index) => {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.src = imgData.finalSrc!;
|
||||
img.onload = () => {
|
||||
// 为了确保html2canvas能正确处理,我们获取图片的原始宽高
|
||||
// 但在网格布局中,它们会被调整。这里主要是为了加载。
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = reject;
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const loadedImages = await Promise.all(imageLoadPromises);
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
loadedImages.forEach(img => {
|
||||
const cellDiv = document.createElement('div');
|
||||
cellDiv.style.width = `${img.naturalWidth}px`; // 使用图片原始宽度
|
||||
cellDiv.style.height = `${img.naturalHeight}px`; // 使用图片原始高度
|
||||
const cellImg = img.cloneNode() as HTMLImageElement;
|
||||
cellDiv.appendChild(cellImg);
|
||||
tempGridContainer.appendChild(cellDiv);
|
||||
maxWidth = Math.max(maxWidth, img.naturalWidth);
|
||||
maxHeight = Math.max(maxHeight, img.naturalHeight);
|
||||
});
|
||||
|
||||
// 设置容器的总宽高,基于最大单图尺寸乘以3,再加上gap
|
||||
tempGridContainer.style.width = `${maxWidth * 3 + 4 * 2}px`;
|
||||
tempGridContainer.style.height = `${maxHeight * 3 + 4 * 2}px`;
|
||||
|
||||
|
||||
document.body.appendChild(tempGridContainer);
|
||||
|
||||
// 等待一小段时间确保DOM更新和图片渲染
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const canvas = await html2canvas(tempGridContainer, {
|
||||
useCORS: true,
|
||||
backgroundColor: null, // 透明背景
|
||||
logging: true,
|
||||
scale: window.devicePixelRatio, // 提高清晰度
|
||||
onclone: (clonedDoc) => { // 确保克隆的文档中的图片也使用原始尺寸
|
||||
const clonedImgs = clonedDoc.querySelectorAll('.nine-grid-preview-container img');
|
||||
clonedImgs.forEach((clonedImgElem ) => {
|
||||
const originalImg = Array.from(tempGridContainer.querySelectorAll('img')).find(orig => orig.src === (clonedImgElem as HTMLImageElement).src);
|
||||
if (originalImg) {
|
||||
(clonedImgElem as HTMLImageElement).style.width = `${originalImg.naturalWidth}px`;
|
||||
(clonedImgElem as HTMLImageElement).style.height = `${originalImg.naturalHeight}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.body.removeChild(tempGridContainer);
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
const link = document.createElement('a');
|
||||
link.download = 'nine-grid-combined.png';
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
message.success('九宫格图片已生成并开始下载!');
|
||||
} catch (error) {
|
||||
console.error("Error generating nine grid image:", error);
|
||||
message.error('生成九宫格图片失败,详情请查看控制台。');
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 内部合成逻辑,避免重复代码
|
||||
async function applyCellCustomizationInternal(cellIndex: number) {
|
||||
const cell = gridImages.value[cellIndex];
|
||||
if (!cell) return;
|
||||
|
||||
const baseImg = new Image();
|
||||
baseImg.src = cell.baseSrc;
|
||||
await new Promise(resolve => baseImg.onload = resolve);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let totalHeight = baseImg.height;
|
||||
const customImageElements: HTMLImageElement[] = [];
|
||||
|
||||
for (const customImgData of cell.customImages) {
|
||||
const img = new Image();
|
||||
img.src = customImgData.src;
|
||||
await new Promise(resolve => img.onload = resolve);
|
||||
totalHeight += img.height;
|
||||
customImageElements.push(img);
|
||||
}
|
||||
|
||||
canvas.width = baseImg.width;
|
||||
canvas.height = totalHeight;
|
||||
ctx.drawImage(baseImg, 0, 0);
|
||||
let currentY = baseImg.height;
|
||||
for (const img of customImageElements) {
|
||||
ctx.drawImage(img, 0, currentY);
|
||||
currentY += img.height;
|
||||
}
|
||||
cell.finalSrc = canvas.toDataURL();
|
||||
}
|
||||
|
||||
|
||||
watch(originalImageSrc, (newSrc) => {
|
||||
if (newSrc) {
|
||||
// 图片加载后初始化Cropper
|
||||
} else {
|
||||
if (cropper.value) {
|
||||
cropper.value.destroy();
|
||||
cropper.value = null;
|
||||
}
|
||||
croppedImageSrc.value = null;
|
||||
gridImages.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dynamic-nine-grid-generator {
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.original-image-preview, .cropped-tile-preview, .customization-base-preview {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid #eee;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
width: 100%;
|
||||
max-height: 500px; /* 限制cropper的高度 */
|
||||
margin-top: 10px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
/* cropperjs 的默认样式可能需要调整,确保它在 naive-ui 环境下正确显示 */
|
||||
:deep(.cropper-view-box),
|
||||
:deep(.cropper-face) {
|
||||
border-radius: 0; /* 如果需要直角 */
|
||||
}
|
||||
|
||||
.nine-grid-preview-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 4px; /* B站动态图片间隔 */
|
||||
border: 1px solid #ddd;
|
||||
padding: 4px;
|
||||
background-color: #f0f0f0;
|
||||
max-width: 400px; /* 控制预览区域大小 */
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
border: 1px solid #ccc;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
aspect-ratio: 1 / 1; /* 保持小格子为正方形,根据内容可能会被撑开 */
|
||||
}
|
||||
|
||||
.grid-cell img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: cover; /* 确保图片填满单元格,可能会裁剪 */
|
||||
}
|
||||
.grid-cell.selected-cell {
|
||||
border: 2px solid #18a058; /* Naive UI 主题色 */
|
||||
box-shadow: 0 0 5px #18a058;
|
||||
}
|
||||
|
||||
.custom-content-indicator {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.custom-images-preview .n-image {
|
||||
border: 1px solid #eee;
|
||||
margin: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<tool-dynamic-nine-grid />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToolDynamicNineGrid from '@/components/manage/tools/ToolDynamicNineGrid.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -14,24 +14,48 @@
|
||||
<n-text depth="3" class="mt-1">
|
||||
请上传一张方形或长方形图片,将会自动分割成3x3的九宫格图片
|
||||
</n-text>
|
||||
</div> <div v-if="originalImage" class="cropper-container">
|
||||
<div>
|
||||
<n-text>请确保您上传的图片是方形或近似方形,以获得最佳效果</n-text>
|
||||
</div>
|
||||
<div class="image-preview">
|
||||
<img :src="originalImage" alt="原始图片" class="original-image-preview" />
|
||||
</div> <div class="cropper-controls">
|
||||
<n-button @click="generatePreview" type="primary">生成预览</n-button>
|
||||
<div v-if="originalImage" class="two-column-layout">
|
||||
<div class="left-pane">
|
||||
<div class="cropper-container">
|
||||
<div class="cropper-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<n-text depth="3">手动裁剪</n-text>
|
||||
<n-switch v-model:value="enableManualCrop" />
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<n-button size="small" tertiary @click="generatePreview">自动中心裁剪</n-button>
|
||||
<n-button size="small" type="primary" @click="applyCrop" :disabled="!enableManualCrop">应用裁剪</n-button>
|
||||
<n-button size="small" @click="resetCrop" :disabled="!enableManualCrop">重置裁剪</n-button>
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
accept="image/*"
|
||||
>
|
||||
<n-button type="warning">重新上传</n-button>
|
||||
<n-button size="small" type="warning">重新选择图片</n-button>
|
||||
</n-upload>
|
||||
</div>
|
||||
</div> <div v-if="croppedSquareImage" class="preview-section">
|
||||
</div>
|
||||
<div class="image-preview">
|
||||
<VueCropper
|
||||
v-if="enableManualCrop"
|
||||
ref="cropperRef"
|
||||
:src="originalImage || ''"
|
||||
:aspect-ratio="1"
|
||||
:view-mode="1"
|
||||
:auto-crop-area="1"
|
||||
:background="false"
|
||||
:responsive="true"
|
||||
style="width: 100%; height: 480px;"
|
||||
/>
|
||||
<img v-else :src="originalImage || ''" alt="原始图片" class="original-image-preview" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-pane" v-if="croppedSquareImage">
|
||||
<div class="preview-section">
|
||||
<n-h4>九宫格预览</n-h4>
|
||||
<n-text depth="3">
|
||||
九宫格预览显示了图片分割后的效果,每个格子都可以添加下方图片
|
||||
@@ -46,6 +70,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导出设置 -->
|
||||
<div class="export-settings">
|
||||
<n-text depth="3">导出设置:</n-text>
|
||||
<div class="export-item">
|
||||
<n-text depth="3">单张边长</n-text>
|
||||
<n-input-number v-model:value="tileSize" :min="128" :max="4096" :step="128" />
|
||||
<n-text depth="3">px</n-text>
|
||||
</div>
|
||||
<div class="export-item">
|
||||
<n-text depth="3">格式</n-text>
|
||||
<n-select v-model:value="exportFormat" :options="formatOptions" style="min-width: 120px" />
|
||||
</div>
|
||||
<div class="export-item" v-if="exportFormat === 'jpeg'">
|
||||
<n-text depth="3">JPEG质量</n-text>
|
||||
<n-input-number v-model:value="jpegQuality" :min="0.1" :max="1" :step="0.05" />
|
||||
</div>
|
||||
<div class="export-item">
|
||||
<n-button tertiary size="small" @click="removeAllAdditionalImages" :disabled="!hasAnyAdditional">
|
||||
清空所有下方图片
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nine-grid-preview">
|
||||
<div
|
||||
v-for="i in 9"
|
||||
@@ -55,16 +102,10 @@
|
||||
<div class="grid-image-container">
|
||||
<div class="grid-position-indicator">第{{ i }}格</div>
|
||||
<div class="grid-image-wrapper">
|
||||
<img
|
||||
:src="croppedSquareImage"
|
||||
alt="九宫格预览"
|
||||
class="grid-image-base"
|
||||
:style="{
|
||||
clipPath: generateClipPath(i-1),
|
||||
transform: 'scale(3)',
|
||||
transformOrigin: calculateTransformOrigin(i-1)
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="grid-bg-tile"
|
||||
:style="{ backgroundImage: 'url(' + croppedSquareImage + ')', backgroundPosition: bgPosition(i-1) }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="additionalImages[i-1]" class="additional-image-preview">
|
||||
@@ -91,12 +132,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<n-button @click="generateFinalImages" type="success" class="mt-2">生成九张图片</n-button>
|
||||
<n-button @click="downloadAllImages" type="info" class="mt-2" :disabled="finalImages.length === 0">
|
||||
打包下载全部
|
||||
<n-button @click="generateFinalImages" type="success" class="mt-2" :loading="isGenerating">生成九张图片</n-button>
|
||||
<n-button @click="downloadAllAsZip" type="info" class="mt-2" :disabled="finalImages.length === 0 || isGenerating">
|
||||
打包下载 ZIP
|
||||
</n-button>
|
||||
</div>
|
||||
</div><div v-if="finalImages.length > 0" class="final-images-section">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="finalImages.length > 0" class="final-images-section">
|
||||
<n-h4>最终图片</n-h4>
|
||||
<n-text depth="3">
|
||||
以下是生成的九宫格图片,您可以单独下载每张图片
|
||||
@@ -105,7 +149,7 @@
|
||||
<div v-for="(imgDataUrl, index) in finalImages" :key="`final-${index}`" class="final-image-item">
|
||||
<img :src="imgDataUrl" :alt="`最终图片 ${index + 1}`" />
|
||||
<div class="image-number">{{ index + 1 }}</div>
|
||||
<n-button size="small" @click="() => downloadImage(imgDataUrl, `grid_image_${index + 1}.png`)" class="download-button">
|
||||
<n-button size="small" @click="() => downloadImage(imgDataUrl, getFileName(index))" class="download-button">
|
||||
下载
|
||||
</n-button>
|
||||
</div>
|
||||
@@ -118,39 +162,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { NCard, NButton, NUpload, NSpace, NH4, NText, useMessage } from 'naive-ui';
|
||||
import { ref, computed } from 'vue';
|
||||
import { NCard, NButton, NUpload, NSpace, NH4, NText, NInputNumber, NSelect, NSwitch, useMessage } from 'naive-ui';
|
||||
import type { UploadFileInfo } from 'naive-ui';
|
||||
// 直接引入 vue-cropperjs,它应该会自动包含所需的 CSS
|
||||
import VueCropper from 'vue-cropperjs';
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
import JSZip from 'jszip';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const originalImage = ref<string | null>(null);
|
||||
const croppedSquareImage = ref<string | null>(null);
|
||||
const cropperRef = ref<any>(null); // VueCropper Instance
|
||||
|
||||
const additionalImages = ref<(string | null)[]>(Array(9).fill(null));
|
||||
const finalImages = ref<string[]>([]);
|
||||
|
||||
// 添加九宫格位置控制变量
|
||||
const gridPositions = ref<{ x: number, y: number }[]>(
|
||||
Array(9).fill(null).map(() => ({ x: 0, y: 0 }))
|
||||
);
|
||||
// 导出设置
|
||||
const tileSize = ref<number>(1024);
|
||||
const exportFormat = ref<'png' | 'jpeg'>('png');
|
||||
const jpegQuality = ref<number>(0.92);
|
||||
const formatOptions: { label: string; value: 'png' | 'jpeg' }[] = [
|
||||
{ label: 'PNG(无损)', value: 'png' },
|
||||
{ label: 'JPEG(有损)', value: 'jpeg' },
|
||||
];
|
||||
|
||||
const { files, open, reset } = useFileDialog({
|
||||
accept: 'image/*',
|
||||
multiple: false,
|
||||
});
|
||||
const isGenerating = ref<boolean>(false);
|
||||
|
||||
// 裁剪相关
|
||||
const enableManualCrop = ref<boolean>(false);
|
||||
const cropperRef = ref<any>(null);
|
||||
|
||||
const handleFileChange = (data: { file: UploadFileInfo }) => {
|
||||
const file = data.file.file;
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
reader.onload = async (e) => {
|
||||
originalImage.value = e.target?.result as string;
|
||||
generatePreview();
|
||||
await updateImageMetaAndDefaultCrop();
|
||||
finalImages.value = []; // Reset final images
|
||||
additionalImages.value = Array(9).fill(null); // Reset additional images
|
||||
};
|
||||
@@ -158,6 +206,16 @@ const handleFileChange = (data: { file: UploadFileInfo }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateImageMetaAndDefaultCrop = async () => {
|
||||
if (!originalImage.value) return;
|
||||
const img = new Image();
|
||||
img.src = originalImage.value;
|
||||
await new Promise(resolve => img.onload = resolve);
|
||||
// 默认:如果不是正方形,打开手动裁剪;但也先生成一次中心裁剪的预览
|
||||
enableManualCrop.value = img.width !== img.height;
|
||||
await generatePreview();
|
||||
};
|
||||
|
||||
const generatePreview = async () => {
|
||||
if (!originalImage.value) return;
|
||||
|
||||
@@ -184,31 +242,38 @@ const generatePreview = async () => {
|
||||
message.success('图片已准备就绪,可以查看九宫格预览');
|
||||
};
|
||||
|
||||
const onCropperReady = () => {
|
||||
if (cropperRef.value) {
|
||||
// 设置裁剪区域为方形
|
||||
const containerData = cropperRef.value.cropper.getContainerData();
|
||||
const size = Math.min(containerData.width, containerData.height) * 0.8;
|
||||
|
||||
cropperRef.value.cropper.setCropBoxData({
|
||||
left: (containerData.width - size) / 2,
|
||||
top: (containerData.height - size) / 2,
|
||||
width: size,
|
||||
height: size
|
||||
const applyCrop = () => {
|
||||
if (!enableManualCrop.value || !cropperRef.value) return;
|
||||
try {
|
||||
const canvas: HTMLCanvasElement = cropperRef.value.getCroppedCanvas({
|
||||
// 不做强制缩放,保持裁剪原分辨率
|
||||
fillColor: '#fff'
|
||||
});
|
||||
|
||||
message.success('可以调整裁剪区域');
|
||||
if (!canvas) {
|
||||
message.error('无法获取裁剪结果');
|
||||
return;
|
||||
}
|
||||
croppedSquareImage.value = exportFormat.value === 'jpeg'
|
||||
? canvas.toDataURL('image/jpeg', jpegQuality.value)
|
||||
: canvas.toDataURL('image/png');
|
||||
message.success('已应用裁剪');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('裁剪失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const cropImage = () => {
|
||||
if (cropperRef.value) {
|
||||
croppedSquareImage.value = cropperRef.value.getCroppedCanvas({
|
||||
imageSmoothingQuality: 'high',
|
||||
}).toDataURL();
|
||||
const resetCrop = () => {
|
||||
cropperRef.value?.reset?.();
|
||||
};
|
||||
|
||||
message.success('图片裁剪成功!');
|
||||
}
|
||||
// 预览瓦片背景位置(背景图按 300% 缩放,位置采用 0/50/100%)
|
||||
const bgPosition = (index: number) => {
|
||||
const row = Math.floor(index / 3);
|
||||
const col = index % 3;
|
||||
const x = col * 50;
|
||||
const y = row * 50;
|
||||
return `${x}% ${y}%`;
|
||||
};
|
||||
|
||||
const handleAdditionalImageChange = (data: { file: UploadFileInfo }, index: number) => {
|
||||
@@ -223,80 +288,84 @@ const handleAdditionalImageChange = (data: { file: UploadFileInfo }, index: numb
|
||||
};
|
||||
|
||||
const generateFinalImages = async () => {
|
||||
if (!originalImage.value) {
|
||||
const sourceUrl = croppedSquareImage.value || originalImage.value;
|
||||
if (!sourceUrl) {
|
||||
message.error('请先上传一张图片');
|
||||
return;
|
||||
}
|
||||
isGenerating.value = true;
|
||||
finalImages.value = [];
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
message.error('无法创建画布');
|
||||
isGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载原始图片
|
||||
const originalImg = new Image();
|
||||
originalImg.src = originalImage.value;
|
||||
await new Promise(resolve => originalImg.onload = resolve);
|
||||
// 加载源图片(优先使用正方形预览)
|
||||
const srcImg = new Image();
|
||||
srcImg.src = sourceUrl;
|
||||
await new Promise(resolve => srcImg.onload = resolve);
|
||||
|
||||
// 确保图片是正方形的
|
||||
const size = Math.min(originalImg.width, originalImg.height);
|
||||
const offsetX = (originalImg.width - size) / 2;
|
||||
const offsetY = (originalImg.height - size) / 2;
|
||||
// 源图的正方形区域
|
||||
const s = Math.min(srcImg.width, srcImg.height);
|
||||
const offX = (srcImg.width - s) / 2;
|
||||
const offY = (srcImg.height - s) / 2;
|
||||
const cellSrcSize = s / 3; // 每格源区域尺寸
|
||||
|
||||
// 每个格子的尺寸
|
||||
const gridSize = size / 3;
|
||||
|
||||
// 为每个格子生成图片
|
||||
try {
|
||||
for (let i = 0; i < 9; i++) {
|
||||
// 计算当前格子在原图中的位置
|
||||
const row = Math.floor(i / 3);
|
||||
const col = i % 3;
|
||||
const srcX = offsetX + col * gridSize;
|
||||
const srcY = offsetY + row * gridSize;
|
||||
const srcX = offX + col * cellSrcSize;
|
||||
const srcY = offY + row * cellSrcSize;
|
||||
|
||||
// 加载额外图片(如果有)
|
||||
// 附加图片(如果有)
|
||||
let additionalImg: HTMLImageElement | null = null;
|
||||
let additionalHeight = 0;
|
||||
|
||||
if (additionalImages.value[i]) {
|
||||
additionalImg = new Image();
|
||||
additionalImg.src = additionalImages.value[i] as string;
|
||||
await new Promise(resolve => additionalImg!.onload = resolve);
|
||||
|
||||
// 计算额外图片等比例缩放后的高度
|
||||
additionalHeight = (gridSize / additionalImg.width) * additionalImg.height;
|
||||
additionalHeight = (tileSize.value / additionalImg.width) * additionalImg.height;
|
||||
}
|
||||
|
||||
// 设置画布尺寸
|
||||
canvas.width = gridSize;
|
||||
canvas.height = gridSize + additionalHeight;
|
||||
|
||||
// 清空画布
|
||||
// 画布尺寸
|
||||
canvas.width = tileSize.value;
|
||||
canvas.height = tileSize.value + additionalHeight;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 绘制九宫格中的一格
|
||||
// 绘制主图格子
|
||||
ctx.drawImage(
|
||||
originalImg,
|
||||
srcX, srcY, gridSize, gridSize, // 从原图截取的区域
|
||||
0, 0, gridSize, gridSize // 绘制到画布的位置和大小
|
||||
srcImg,
|
||||
srcX, srcY, cellSrcSize, cellSrcSize,
|
||||
0, 0, tileSize.value, tileSize.value
|
||||
);
|
||||
|
||||
// 如果有额外图片,绘制在下方
|
||||
// 绘制附加图片
|
||||
if (additionalImg) {
|
||||
ctx.drawImage(
|
||||
additionalImg,
|
||||
0, gridSize, gridSize, additionalHeight
|
||||
0, tileSize.value, tileSize.value, additionalHeight
|
||||
);
|
||||
}
|
||||
|
||||
// 保存生成的图片
|
||||
finalImages.value.push(canvas.toDataURL('image/png'));
|
||||
// 导出设置
|
||||
const mime = exportFormat.value === 'png' ? 'image/png' : 'image/jpeg';
|
||||
const dataUrl = exportFormat.value === 'jpeg'
|
||||
? canvas.toDataURL(mime, jpegQuality.value)
|
||||
: canvas.toDataURL(mime);
|
||||
finalImages.value.push(dataUrl);
|
||||
}
|
||||
|
||||
message.success('九宫格图片已生成!可以单独下载每张图片');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('生成过程中出现问题,请重试');
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImage = (dataUrl: string, filename: string) => {
|
||||
@@ -308,11 +377,13 @@ const downloadImage = (dataUrl: string, filename: string) => {
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const resetCropper = () => {
|
||||
if (cropperRef.value) {
|
||||
cropperRef.value.cropper.reset();
|
||||
message.info('已重置裁剪区域');
|
||||
}
|
||||
// 计算是否存在任一附加图片
|
||||
const hasAnyAdditional = computed(() => additionalImages.value.some(Boolean));
|
||||
|
||||
const removeAllAdditionalImages = () => {
|
||||
if (!hasAnyAdditional.value) return;
|
||||
additionalImages.value = Array(9).fill(null);
|
||||
message.success('已清空所有附加图片');
|
||||
};
|
||||
|
||||
const removeAdditionalImage = (index: number) => {
|
||||
@@ -320,50 +391,49 @@ const removeAdditionalImage = (index: number) => {
|
||||
message.success('已删除附加图片');
|
||||
};
|
||||
|
||||
const downloadAllImages = () => {
|
||||
const dataUrlToBlob = (dataUrl: string): Blob => {
|
||||
const arr = dataUrl.split(',');
|
||||
const mimeMatch = arr[0].match(/:(.*?);/);
|
||||
const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
return new Blob([u8arr], { type: mime });
|
||||
};
|
||||
|
||||
const downloadAllAsZip = async () => {
|
||||
if (finalImages.value.length === 0) {
|
||||
message.error('请先生成九宫格图片');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建一个 zip 文件的替代方案
|
||||
// 这里我们简单地连续下载所有图片
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
finalImages.value.forEach((dataUrl, index) => {
|
||||
setTimeout(() => {
|
||||
downloadImage(dataUrl, `grid_image_${index + 1}.png`);
|
||||
}, index * 300); // 避免浏览器阻止多次下载,添加延迟
|
||||
const blob = dataUrlToBlob(dataUrl);
|
||||
zip.file(getFileName(index), blob);
|
||||
});
|
||||
|
||||
message.success('正在下载所有图片...');
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
saveAs(content, 'dynamic-nine-grid.zip');
|
||||
message.success('ZIP 已开始下载');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('打包失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 生成CSS clip-path以显示图片的特定部分
|
||||
const generateClipPath = (index: number) => {
|
||||
const row = Math.floor(index / 3);
|
||||
const col = index % 3;
|
||||
const startX = col * 33.33;
|
||||
const startY = row * 33.33;
|
||||
const endX = startX + 33.33;
|
||||
const endY = startY + 33.33;
|
||||
|
||||
return `polygon(${startX}% ${startY}%, ${endX}% ${startY}%, ${endX}% ${endY}%, ${startX}% ${endY}%)`;
|
||||
// 下载文件名(根据导出格式)
|
||||
const getFileName = (index: number) => {
|
||||
const ext = exportFormat.value === 'png' ? 'png' : 'jpg';
|
||||
return `grid_image_${index + 1}.${ext}`;
|
||||
};
|
||||
|
||||
// 计算图片的transform-origin,确保正确缩放
|
||||
const calculateTransformOrigin = (index: number) => {
|
||||
const row = Math.floor(index / 3);
|
||||
const col = index % 3;
|
||||
const originX = col * 50; // 使用百分比
|
||||
const originY = row * 50;
|
||||
|
||||
return `${originX}% ${originY}%`;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dynamic-nine-grid-tool {
|
||||
max-width: 900px;
|
||||
max-width: 1200px;
|
||||
margin: auto;
|
||||
padding: 20px 0;
|
||||
}
|
||||
@@ -387,6 +457,43 @@ const calculateTransformOrigin = (index: number) => {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.two-column-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.two-column-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.left-pane, .right-pane {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cropper-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cropper-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -443,11 +550,15 @@ const calculateTransformOrigin = (index: number) => {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.grid-image-base {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform-origin: 0 0;
|
||||
/* 以背景图方式渲染九宫格,避免 clip-path 带来的渲染开销 */
|
||||
.grid-bg-tile {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 300% 300%;
|
||||
}
|
||||
|
||||
.additional-image-preview {
|
||||
@@ -459,6 +570,8 @@ const calculateTransformOrigin = (index: number) => {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-top: 1px solid #eee;
|
||||
max-height: 160px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.grid-controls {
|
||||
@@ -578,13 +691,21 @@ const calculateTransformOrigin = (index: number) => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-image-base {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
.export-settings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 16px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
margin: 8px 0 12px;
|
||||
border: 1px dashed #e0e0e6;
|
||||
border-radius: 8px;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
.export-item {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.original-image-preview {
|
||||
@@ -592,6 +713,8 @@ const calculateTransformOrigin = (index: number) => {
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
|
||||
@@ -239,8 +239,8 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 活跃歌曲列表 -->
|
||||
<SongRequestList
|
||||
@update:sort-type="value => { accountInfo.settings.songRequest.sortType = value; updateSettings() }"
|
||||
@update:is-reverse="value => {
|
||||
@update:sort-type="(value: any) => { accountInfo.settings.songRequest.sortType = value; updateSettings() }"
|
||||
@update:is-reverse="(value: any) => {
|
||||
if (songRequest.configCanEdit) {
|
||||
accountInfo.settings.songRequest.isReverse = value
|
||||
updateSettings()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import SimpleVideoCard from '@/components/SimpleVideoCard.vue';
|
||||
import { defineTemplateConfig, ExtractConfigData } from '@/data/VTsuruConfigTypes';
|
||||
import { USER_INDEX_API_URL } from '@/data/constants';
|
||||
import { NAvatar, NButton, NCard, NDivider, NFlex, NSpace, NText, useMessage } from 'naive-ui';
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
defineExpose({ Config, DefaultConfig })
|
||||
const width = window.innerWidth
|
||||
@@ -23,6 +23,20 @@ const message = useMessage()
|
||||
const accountInfo = useAccount()
|
||||
|
||||
const indexInfo = ref<ResponseUserIndexModel>((await getIndexInfo()) || ({} as ResponseUserIndexModel))
|
||||
// 计算链接顺序(如果后端未提供 linkOrder 则使用对象键顺序)
|
||||
const orderedLinks = computed(() => {
|
||||
if (!indexInfo.value) return [] as [string, string][];
|
||||
const entries = Object.entries(indexInfo.value.links || {});
|
||||
if (!indexInfo.value.links) return [];
|
||||
const order = (accountInfo.value?.settings?.index?.linkOrder?.length
|
||||
? accountInfo.value.settings.index.linkOrder
|
||||
: (indexInfo.value as any)?.linkOrder) as string[] | undefined;
|
||||
if (order && order.length) {
|
||||
const map = new Map(entries);
|
||||
return order.filter(k => map.has(k)).map(k => [k, map.get(k)!]) as [string, string][];
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
async function getIndexInfo() {
|
||||
try {
|
||||
isLoading.value = true
|
||||
@@ -123,9 +137,9 @@ export const Config = defineTemplateConfig([
|
||||
<NText
|
||||
strong
|
||||
depth="3"
|
||||
style="font-size: medium"
|
||||
style="font-size: 16px"
|
||||
>
|
||||
{{ userInfo.streamerInfo?.uId }}
|
||||
UID: {{ userInfo.streamerInfo?.uId }}
|
||||
</NText>
|
||||
<NText
|
||||
strong
|
||||
@@ -143,37 +157,36 @@ export const Config = defineTemplateConfig([
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="navigate('https://space.bilibili.com/' + userInfo?.biliId)"
|
||||
>
|
||||
个人主页
|
||||
</NButton>
|
||||
>个人主页</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
secondary
|
||||
@click="navigate('https://live.bilibili.com/' + userInfo?.biliRoomId)"
|
||||
>直播间</NButton>
|
||||
</NSpace>
|
||||
<template v-if="orderedLinks.length > 0">
|
||||
<NDivider> 相关链接 </NDivider>
|
||||
<NFlex
|
||||
justify="center"
|
||||
wrap
|
||||
>
|
||||
直播间
|
||||
</NButton>
|
||||
<template v-if="Object.keys(indexInfo.links || {}).length > 0">
|
||||
<NFlex align="center">
|
||||
<NDivider vertical />
|
||||
<NButton
|
||||
v-for="link in Object.entries(indexInfo.links || {})"
|
||||
v-for="link in orderedLinks"
|
||||
:key="link[0] + link[1]"
|
||||
size="small"
|
||||
type="info"
|
||||
secondary
|
||||
tag="a"
|
||||
:href="link[1]"
|
||||
target="_blank"
|
||||
style="margin:4px"
|
||||
>
|
||||
{{ link[0] }}
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</template>
|
||||
</NSpace>
|
||||
<template v-if="indexInfo.videos?.length || 0 > 0">
|
||||
<NDivider>
|
||||
<NText>相关视频</NText>
|
||||
</NDivider>
|
||||
<NDivider><NText style="font-size:18px">相关视频</NText></NDivider>
|
||||
<NFlex justify="center">
|
||||
<SimpleVideoCard
|
||||
v-for="video in indexInfo.videos"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"allowJs": false,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["node", "vue-vine/macros"],
|
||||
"types": ["node", "vue-vine/macros", "jszip"],
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user