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:
Megghy
2025-09-26 23:10:09 +08:00
parent ca575a623e
commit 83a6c36d57
16 changed files with 1787 additions and 1864 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -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",

View File

@@ -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
View File

@@ -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']

View File

@@ -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>

View File

@@ -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' // 指向工具箱仪表盘

View File

@@ -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"

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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

View File

@@ -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"

View File

@@ -12,7 +12,7 @@
"allowJs": false,
"sourceMap": true,
"baseUrl": ".",
"types": ["node", "vue-vine/macros"],
"types": ["node", "vue-vine/macros", "jszip"],
"paths": {
"@/*": ["src/*"]
},