完成保存草稿到作品库预览编辑功能,添加防篡改水印功能

This commit is contained in:
tangxinyue 2026-07-27 18:16:19 +08:00
parent 46fe97c589
commit 6a108992e2
12 changed files with 797 additions and 111 deletions

View File

@ -209,3 +209,16 @@
[2026-07-25 10:06:42,035] [INFO] [D:\AAA-Code\flaunt-web\service\core\router\index.cjs:28] GET 200 /api/user?nonce=57df84ed41a34157f3de139fa046d5f4&signature=c580ad55611e2949914ce98f0e4b68df&timestamp=1784945202
[2026-07-25 10:06:42,037] [INFO] [unknow:0] GET 200 /favicon.ico
[2026-07-25 10:06:42,179] [INFO] [D:\AAA-Code\flaunt-web\service\core\router\index.cjs:28] GET 200 /api/order?nonce=180858c062a7d37f8a3bc5addc1f7c5f&signature=ed4d810b96feff3c2bcb8354b9396516&status=2&timestamp=1784945202
[2026-07-25 10:40:31,791] [INFO] [unknow:0] GET 200 /favicon.ico
[2026-07-25 10:40:32,709] [INFO] [unknow:0] GET 200 /assets/works.js
[2026-07-25 10:40:32,710] [INFO] [unknow:0] GET 200 /assets/works.css
[2026-07-25 10:40:32,734] [INFO] [unknow:0] GET 200 /about?t=1784947232719
[2026-07-25 10:40:32,888] [INFO] [unknow:0] GET 200 /favicon.ico
[2026-07-25 10:40:33,018] [INFO] [D:\AAA-Code\flaunt-web\service\core\router\index.cjs:28] GET 200 /api/user?nonce=51cd4c69c8f40c85725c4c7012572837&signature=493b3172a42ce9e7aa87bbde0a0e648a&timestamp=1784947233
[2026-07-25 10:40:33,020] [INFO] [D:\AAA-Code\flaunt-web\service\core\router\index.cjs:28] GET 200 /api/user/config?nonce=41b001a436a201a06776f30ab1a27f3c&signature=df2393a0cc5cdce01cb19394ecbe20a3&timestamp=1784947233
[2026-07-25 10:40:35,661] [INFO] [D:\AAA-Code\flaunt-web\service\core\router\index.cjs:28] GET 200 /api/app/code?nonce=36d0e906c36c3dfba2c57dae63071af4&signature=c16ede81b8db3bd367d5469dfba193ea&timestamp=1784947235
[2026-07-25 10:43:11,325] [INFO] [D:\AAA-Code\flaunt-web\service\core\router\index.cjs:28] GET 200 /api/app/code?nonce=60c325a3f3639913aa1ced29496ec98d&signature=3318713827559bc10a9922aaae5ad1de&timestamp=1784947391
[2026-07-25 10:43:13,507] [INFO] [unknow:0] GET 200 /.well-known/appspecific/com.chrome.devtools.json
[2026-07-25 10:43:20,485] [INFO] [D:\AAA-Code\flaunt-web\service\core\router\index.cjs:28] GET 200 /api/app/code?nonce=fa79dee27ab021c46d92f63fb3385ec9&signature=04c18efa72c875e51b9044e78b25e86d&timestamp=1784947400
[2026-07-25 10:46:56,136] [INFO] [D:\AAA-Code\flaunt-web\service\core\router\index.cjs:28] GET 200 /api/user?nonce=8f01b34875563a6e6098e726aa7b7a2e&signature=ebea6add12ddea8167b1a77318a6aa6f&timestamp=1784947616
[2026-07-25 10:46:56,138] [INFO] [unknow:0] GET 200 /favicon.ico

View File

@ -82,8 +82,8 @@ export default {
* @param {*} data
* @returns
*/
async getAppCode(data) {
return await get(appConfig.baseURL, "/app/code", data);
async getDownloadCode(data) {
return await get(appConfig.baseURL, "/web/download", data);
}
};

View File

@ -71,9 +71,13 @@
</div>
<LoginDialog ref="loginDialogRef" />
<BaseDialog ref="appCodeRef" v-model="showAppCode" width="260px" @close="showAppCode = false" title="下载APP">
<div class="qrCode-box">
<img style="width: 200px;height: 200px;margin: 0 auto;" :src="appCodeImg" alt="">
<BaseDialog ref="appCodeRef" v-model="showAppCode" width="494px" class="app-code-dialog"
@close="showAppCode = false" title="下载APP">
<div class="app-download-wrapper">
<div class="content-box">
<div class="scan-title">扫码下载装样大师手机版</div>
<img class="qrcode" :src="appCodeImg" alt="">
</div>
</div>
</BaseDialog>
</header>
@ -102,10 +106,10 @@ async function openAppCodeDialog() {
showAppCode.value = true
if (!appCodeImg.value) {
try {
const res = await authService.getAppCode()
const res = await authService.getDownloadCode()
console.log('获取APP二维码响应结果:', res)
const data = res.data || res
appCodeImg.value = data.qrcode || data.qr_code || data.qrcode_url || data.url || (typeof data === 'string' ? data : '')
appCodeImg.value = data.android_qrcode
} catch (e) {
console.log(e.message)
}
@ -559,11 +563,53 @@ function openLoginDialog() {
height: 100%;
}
.qrCode-box {
.app-code-dialog {
:deep(.el-dialog) {
padding: 0 !important;
background-color: transparent !important;
box-shadow: 0 0 0 0 transparent !important;
}
:deep(.close) {
top: calc(100% + 18px);
right: calc(50% - 34px);
}
}
.app-download-wrapper {
width: 494px;
height: 504px;
background-image: url('@/public/other/gongzidan/download-app-bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
position: relative;
.content-box {
position: absolute;
top: 45%;
/* 大约在白底开始的位置,根据高保真微调 */
left: 0;
width: 100%;
padding-left: 40px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 0;
.scan-title {
font-weight: 500;
font-size: 20px;
color: #1A1A1A;
line-height: 29px;
letter-spacing: 1px;
}
.qrcode {
width: 200px;
height: 200px;
}
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="phone-box">
<div class="phone-box" ref="containerRef">
<img class="phone-bg" src="@/public/simulator/ios-phone.png" alt="">
<div class="iphone-frame" :class="theme" id="capture-frame">
<slot></slot>
@ -12,6 +12,8 @@
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import { ref } from 'vue'
import { useWatermarkGuard } from '@/hooks/useWatermarkGuard'
defineProps({
theme: {
@ -22,6 +24,9 @@ defineProps({
const userStore = useUserStore()
const { isVip } = storeToRefs(userStore)
const containerRef = ref(null)
useWatermarkGuard(containerRef, isVip)
</script>
<style lang="scss" scoped>

View File

@ -0,0 +1,176 @@
import { onMounted, onBeforeUnmount, ref } from 'vue';
import { ElMessage } from 'element-plus';
/**
* 保护水印节点防篡改
* @param {Ref<HTMLElement>} containerRef - 包含水印的父容器的 Ref
* @param {String} watermarkSelector - 水印节点的类名选择器默认 '.watermark-overlay'
* @param {Ref<Boolean>} isVipRef - 用户的 VIP 状态引用如果是 VIP 则不进行检查
*/
export function useWatermarkGuard(containerRef, isVipRef, watermarkSelector = '.watermark-overlay') {
let observer = null;
let pollTimer = null;
const isDestroyed = ref(false);
const enforcePunishment = () => {
if (isDestroyed.value) return;
if (!containerRef.value) return;
// 锁定原始尺寸,防止清空内部元素(如撑起宽高的图片)后导致外层盒子塌陷
const width = containerRef.value.offsetWidth;
const height = containerRef.value.offsetHeight;
if (width && height) {
containerRef.value.style.width = width + 'px';
containerRef.value.style.height = height + 'px';
}
// 触发惩罚机制:将容器内容彻底清空并显示红色警告
isDestroyed.value = true;
containerRef.value.innerHTML = `
<div style="
position: absolute;
inset: 0;
background: rgba(245, 247, 250, 0.85);
backdrop-filter: blur(8px);
z-index: 999999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
text-align: center;
">
<div style="
background: #ffffff;
border-radius: 20px;
padding: 40px 24px;
box-shadow: 0 12px 32px rgba(139, 126, 250, 0.15);
width: 85%;
max-width: 320px;
display: flex;
flex-direction: column;
align-items: center;
">
<div style="
width: 64px;
height: 64px;
background: linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin-bottom: 20px;
box-shadow: 0 4px 12px rgba(142, 197, 252, 0.4);
">🛡</div>
<h3 style="color: #333333; font-size: 18px; font-weight: 600; margin: 0 0 12px 0;">预览已受保护</h3>
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0 0 28px 0;">检测到水印显示异常<br>为保护创作者权益预览已终止</p>
<button onclick="window.location.reload()" style="
background: linear-gradient(90deg, #9b81ec 0%, #7d65f5 100%);
color: white;
border: none;
border-radius: 24px;
padding: 10px 32px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
box-shadow: 0 4px 12px rgba(155, 129, 236, 0.3);
transition: transform 0.2s;
" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">
重新加载
</button>
</div>
</div>
`;
ElMessage.warning('由于水印显示异常,预览已被终止');
if (observer) {
observer.disconnect();
}
};
const checkWatermarkIntegrity = () => {
if (!containerRef.value || isDestroyed.value) return;
// 如果是 VIP不需要水印也不需要防篡改检查
if (isVipRef && isVipRef.value === true) return;
const watermarkNode = containerRef.value.querySelector(watermarkSelector);
// 检查 1节点是否被直接删除
if (!watermarkNode) {
enforcePunishment();
return;
}
// 检查 2是否被通过 style 修改导致隐藏
const styles = window.getComputedStyle(watermarkNode);
if (
styles.display === 'none' ||
styles.visibility === 'hidden' ||
styles.opacity === '0' ||
parseFloat(styles.opacity) < 0.1 ||
parseFloat(styles.width) < 10 ||
parseFloat(styles.height) < 10 ||
styles.position === 'static'
) {
enforcePunishment();
return;
}
};
onMounted(() => {
if (!containerRef.value) return;
// 初次检查 (延迟一下等节点渲染完毕)
setTimeout(() => {
checkWatermarkIntegrity();
}, 800);
// 监听容器内的节点变动
observer = new MutationObserver((mutations) => {
if (isVipRef && isVipRef.value === true) return;
let shouldCheck = false;
for (const mutation of mutations) {
// 如果是子节点树发生变化(可能删除了节点)
if (mutation.type === 'childList') {
shouldCheck = true;
break;
}
// 如果是属性发生了变化(包含自身和所有子节点的样式属性改变)
if (mutation.type === 'attributes') {
shouldCheck = true;
break;
}
}
if (shouldCheck) {
// 使用 setTimeout 避免在 DOM 更新周期中同步导致卡死,给恶意篡改一个反应时间
setTimeout(() => checkWatermarkIntegrity(), 50);
}
});
observer.observe(containerRef.value, {
childList: true, // 监听子节点增删
subtree: true, // 监听所有后代节点
attributes: true, // 监听属性变化
attributeFilter: ['style', 'class'] // 只关注样式和类的变化
});
// 终极防御:轮询检查(防止修改全局 CSS 绕过 MutationObserver
pollTimer = setInterval(() => {
checkWatermarkIntegrity();
}, 1500);
});
onBeforeUnmount(() => {
if (observer) {
observer.disconnect();
}
if (pollTimer) {
clearInterval(pollTimer);
}
});
return { isDestroyed };
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

View File

@ -87,6 +87,9 @@ export const useUserStore = defineStore('user', () => {
}
} catch (e) {
console.error("同步后台用户信息异常:", e)
// 关键防护:网络断开或异常时,强制降级高级权限,防止断网 + 篡改缓存白嫖
isVip.value = false
tokenValid.value = false
} finally {
fetchPromise = null
}

View File

@ -86,7 +86,7 @@
<div class="dialog-title">
<div class="bar"></div>全部订单
</div>
<div class="orders-list">
<div v-if="orders.length > 0" class="orders-list">
<div v-for="(o, index) in orders" :key="index" class="order-item">
<div class="order-left">
<span class="order-plan">{{ planLabel(o.goods_name) }}</span>
@ -100,6 +100,9 @@
</div>
</div>
</div>
<div v-else class="empty-state">
<img src="@/public/profile/empty.png" class="empty-img" />
</div>
</div>
</BaseDialog>
</div>
@ -147,7 +150,6 @@ onMounted(async () => {
Object.assign(userInfo, local)
} catch (e) { }
loadStats();
getOrders();
})
@ -383,6 +385,10 @@ function statusLabel(s) {
.section-container {
margin-bottom: 13px;
.order-content {
min-height: 240px;
}
.section-title {
display: flex;
align-items: center;
@ -450,6 +456,8 @@ function statusLabel(s) {
}
}
}
.empty-state {
display: flex;
justify-content: center;
@ -461,7 +469,6 @@ function statusLabel(s) {
object-fit: contain;
}
}
}
.logout-btn {
width: 100%;

View File

@ -11,7 +11,7 @@
</div>
<img class="stay-tuned-img" src="@/public/common/stay-tuned.png" alt="">
</div>
<div class="main-box">
<div class="main-box" ref="mainBoxRef">
<img class="main-bg" src="@/public/other/gongzidan/gongzidan.png" alt="">
<span class="item-info" style="left: 156px;bottom: 210px;">{{ formData.job }}</span>
<span class="item-info" style="left: 202px;bottom: 210px;">{{ formData.name }}</span>
@ -28,7 +28,7 @@
<span class="item-info" style="left: 646px;bottom: 210px;">{{ formData.netSalary }}</span>
<span class="item-info time" style="left: 434px; bottom: 298px;">{{ formData.year }}</span>
<span class="item-info time" style="left: 488px; bottom: 298px;">{{ formData.month }}</span>
<div class="watermark-overlay"></div>
<div v-if="!isVip" class="watermark-overlay"></div>
</div>
<!-- <div class="right-box"></div> -->
<div class="right-box content-box">
@ -102,11 +102,52 @@
<WarningFooter />
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import { ref, computed, watch, inject, onMounted } from 'vue'
import WarningFooter from '@/components/common/WarningFooter.vue'
import { all } from 'axios'
import { dayOrDaysToDate, ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import html2canvas from 'html2canvas'
import { useRoute, useRouter } from 'vue-router'
import { useWatermarkGuard } from '@/hooks/useWatermarkGuard'
const showPayment = inject('showPayment')
const route = useRoute()
const router = useRouter()
const workId = ref('')
const originalTitle = ref('')
onMounted(async () => {
const id = route.query.id || route.query.workId
if (!id) return
try {
let drafts = []
try {
drafts = JSON.parse(localStorage.getItem('local_drafts') || '[]')
if (!Array.isArray(drafts)) drafts = []
} catch (e) { }
const work = drafts.find(w => String(w.id) === String(id))
if (work) {
workId.value = id
originalTitle.value = work.title || ''
if (work.data_json) {
const configData = typeof work.data_json === 'string' ? JSON.parse(work.data_json) : work.data_json
Object.assign(formData.value, configData)
}
}
} catch (e) {
console.error('获取草稿失败:', e)
}
})
const userStore = useUserStore()
const { isVip } = storeToRefs(userStore)
const mainBoxRef = ref(null)
useWatermarkGuard(mainBoxRef, isVip)
const currentYear = new Date().getFullYear().toString()
const currentMonth = (new Date().getMonth() + 1).toString()
@ -178,11 +219,115 @@ const randomGenerate = () => {
// TODO:
}
const saveDraft = () => {
ElMessage.success('草稿已保存')
const saveDraft = async () => {
if (!localStorage.getItem('token')) {
ElMessage.warning('请先登录')
return
}
try {
const defaultTitle = originalTitle.value || '未命名工资单'
const { value: title } = await ElMessageBox.prompt('请输入作品名称', '保存草稿', {
confirmButtonText: '保存',
cancelButtonText: '取消',
inputValue: defaultTitle,
inputPlaceholder: '输入名称(同名将提示覆盖,异名将新建)',
})
if (!title) return
let drafts = []
try {
drafts = JSON.parse(localStorage.getItem('local_drafts') || '[]')
if (!Array.isArray(drafts)) drafts = []
} catch (e) {
drafts = []
}
let draftId = ''
let isOverwriteOther = false
if (title !== originalTitle.value) {
const existIndex = drafts.findIndex(w => w.title === title && w.type === 'gongzidan')
if (existIndex !== -1) {
await ElMessageBox.confirm(`已存在同名作品「${title}」,是否覆盖该作品?`, '提示', {
confirmButtonText: '覆盖',
cancelButtonText: '取消',
type: 'warning'
})
draftId = drafts[existIndex].id
isOverwriteOther = true
} else {
draftId = 'draft_' + Date.now()
}
} else {
draftId = workId.value || ('draft_' + Date.now())
}
let coverImg = ''
const el = document.querySelector('.main-box')
if (el) {
try {
const canvas = await html2canvas(el, {
scale: 300 / el.offsetWidth, // 150px
useCORS: true
})
coverImg = canvas.toDataURL('image/jpeg', 0.6)
} catch (err) {
console.error('截图生成失败', err)
}
}
const payload = {
id: draftId,
title: title,
type: 'gongzidan',
page_router: '/gongzidan',
data_json: JSON.stringify(formData.value),
cover_img: coverImg,
update_time: Date.now()
}
let isSelfUpdate = false
if (isOverwriteOther) {
const existIndex = drafts.findIndex(w => String(w.id) === String(draftId))
if (existIndex !== -1) drafts[existIndex] = payload
} else {
const selfIndex = drafts.findIndex(w => String(w.id) === String(draftId))
if (selfIndex !== -1) {
drafts[selfIndex] = payload
isSelfUpdate = true
} else {
drafts.unshift(payload)
}
}
localStorage.setItem('local_drafts', JSON.stringify(drafts))
workId.value = payload.id
originalTitle.value = payload.title
if (!route.query.id || isOverwriteOther) {
router.replace({ query: { id: draftId } })
}
let msg = '已新增草稿'
if (isOverwriteOther) msg = '草稿已覆盖保存'
else if (isSelfUpdate) msg = '草稿已更新'
ElMessage.success(msg)
} catch (e) {
if (e !== 'cancel') {
console.error(e)
ElMessage.error('保存失败')
}
}
}
const downloadPaper = async () => {
if (!localStorage.getItem('token')) { ElMessage.warning('请先登录后再下载'); return }
if (!userStore.isVip) {
showPayment?.()
return
}
const el = document.querySelector('.main-box')
if (!el) {
ElMessage.error('预览未就绪,请稍后重试')
@ -342,7 +487,7 @@ const downloadPaper = async () => {
background-size: 125px 125px;
background-repeat: repeat;
pointer-events: none;
z-index: 999;
z-index: 1;
}
.item-info {

View File

@ -29,16 +29,19 @@
<!-- 悬浮遮罩与操作按钮 -->
<div class="hover-overlay">
<div class="close-btn">
<div class="close-btn" @click.stop="handleDelete(item.id)">
<img style="width: 18px;height: 18px;" src="@/public/header/close.png" alt="">
</div>
<div class="work-title">
<span class="title-text" :title="item.title || '未命名作品'">{{ item.title || '未命名作品' }}</span>
</div>
<div class="action-btns">
<button class="action-btn edit-btn">
<button class="action-btn edit-btn" @click.stop="handleEdit(item)">
<img class="icon-img" src="@/public/edit.png" alt=""> 编辑
</button>
<button class="action-btn download-btn">
<!-- <button class="action-btn download-btn" @click.stop="handleDownload(item)">
<img class="icon-img" src="@/public/direct-inbox.png" alt=""> 下载
</button>
</button> -->
</div>
</div>
</div>
@ -51,38 +54,89 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { Search, Close, EditPen, Download } from '@element-plus/icons-vue'
import WarningFooter from '@/components/common/WarningFooter.vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const searchQuery = ref('')
const tabs = ['全部', '微信模拟', '娱乐模拟']
const activeTab = ref('全部')
//
const masonryItems = ref([
{ id: 1, height: 100, img: "https://fuss10.elemecdn.com/8/27/f01c15bb73e1ef3793e64e6b7bbccjpeg.jpeg" },
{ id: 2, height: 260, img: "https://cube.elemecdn.com/6/94/4d3ea53c084bad6931a56d5158a48jpeg.jpeg" },
{ id: 3, height: 560, img: "https://fuss10.elemecdn.com/3/28/bbf893f792f03a54408b3b7a7ebf0jpeg.jpeg" },
{ id: 4, height: 320, img: "https://fastly.picsum.photos/id/167/300/200.jpg?hmac=2eeD-Byn4LjWFNUeCaFtQHSCpKs0vaozPXwFdkpzOP0" },
{ id: 1, height: 100, img: "https://fuss10.elemecdn.com/8/27/f01c15bb73e1ef3793e64e6b7bbccjpeg.jpeg" },
{ id: 5, height: 380, img: "https://fastly.picsum.photos/id/513/200/200.jpg?hmac=xMRZhdrttvlfIvOf0Qm9J4texbmA0HS2pBNVM-Pho-U" },
{ id: 1, height: 100, img: "https://fuss10.elemecdn.com/8/27/f01c15bb73e1ef3793e64e6b7bbccjpeg.jpeg" },
{ id: 2, height: 260, img: "https://cube.elemecdn.com/6/94/4d3ea53c084bad6931a56d5158a48jpeg.jpeg" },
{ id: 3, height: 560, img: "https://fuss10.elemecdn.com/3/28/bbf893f792f03a54408b3b7a7ebf0jpeg.jpeg" },
{ id: 4, height: 320, img: "https://fastly.picsum.photos/id/167/300/200.jpg?hmac=2eeD-Byn4LjWFNUeCaFtQHSCpKs0vaozPXwFdkpzOP0" },
{ id: 5, height: 380, img: "https://fastly.picsum.photos/id/513/200/200.jpg?hmac=xMRZhdrttvlfIvOf0Qm9J4texbmA0HS2pBNVM-Pho-U" },
{ id: 1, height: 100, img: "https://fuss10.elemecdn.com/8/27/f01c15bb73e1ef3793e64e6b7bbccjpeg.jpeg" },
{ id: 11, height: 270, img: "https://fastly.picsum.photos/id/513/200/200.jpg?hmac=xMRZhdrttvlfIvOf0Qm9J4texbmA0HS2pBNVM-Pho-U" },
{ id: 1, height: 100, img: "https://fuss10.elemecdn.com/8/27/f01c15bb73e1ef3793e64e6b7bbccjpeg.jpeg" },
{ id: 2, height: 260, img: "https://cube.elemecdn.com/6/94/4d3ea53c084bad6931a56d5158a48jpeg.jpeg" },
{ id: 3, height: 560, img: "https://fuss10.elemecdn.com/3/28/bbf893f792f03a54408b3b7a7ebf0jpeg.jpeg" },
{ id: 4, height: 320, img: "https://fastly.picsum.photos/id/167/300/200.jpg?hmac=2eeD-Byn4LjWFNUeCaFtQHSCpKs0vaozPXwFdkpzOP0" },
{ id: 5, height: 380, img: "https://fastly.picsum.photos/id/513/200/200.jpg?hmac=xMRZhdrttvlfIvOf0Qm9J4texbmA0HS2pBNVM-Pho-U" },
{ id: 4, height: 320, img: "https://fastly.picsum.photos/id/167/300/200.jpg?hmac=2eeD-Byn4LjWFNUeCaFtQHSCpKs0vaozPXwFdkpzOP0" },
{ id: 2, height: 260, img: "https://cube.elemecdn.com/6/94/4d3ea53c084bad6931a56d5158a48jpeg.jpeg" },
{ id: 3, height: 560, img: "https://fuss10.elemecdn.com/3/28/bbf893f792f03a54408b3b7a7ebf0jpeg.jpeg" },
])
const localDrafts = ref([])
onMounted(() => {
loadDrafts()
})
function loadDrafts() {
try {
const drafts = JSON.parse(localStorage.getItem('local_drafts') || '[]')
localDrafts.value = Array.isArray(drafts) ? drafts : []
} catch (e) {
localDrafts.value = []
}
}
//
const masonryItems = computed(() => {
let list = localDrafts.value
if (activeTab.value === '微信模拟') {
list = list.filter(item => item.type && item.type.startsWith('wx-'))
} else if (activeTab.value === '娱乐模拟') {
list = list.filter(item => item.type && !item.type.startsWith('wx-'))
}
if (searchQuery.value) {
const keyword = searchQuery.value.toLowerCase()
list = list.filter(item => item.title && item.title.toLowerCase().includes(keyword))
}
return list.map(item => {
return {
...item,
id: item.id,
img: item.cover_img || '',
height: 'auto'
}
})
})
function handleDelete(id) {
ElMessageBox.confirm('确定要删除该草稿吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const drafts = localDrafts.value.filter(item => item.id !== id)
localStorage.setItem('local_drafts', JSON.stringify(drafts))
localDrafts.value = drafts
ElMessage.success('已删除')
}).catch(() => { })
}
function handleEdit(item) {
if (item.type === 'wx-balance' || item.type === 'gongzidan' || item.type === 'wx-chat') {
router.push(`${item.page_router}?id=${item.id}`)
} else {
//
ElMessage.info('敬请期待')
}
}
function handleDownload(item) {
if (!item.cover_img) {
ElMessage.warning('没有可下载的预览图')
return
}
const link = document.createElement('a')
link.download = `${item.title || '草稿截图'}.png`
link.href = item.cover_img
link.click()
}
</script>
<style lang="scss" scoped>
@ -241,6 +295,31 @@ const masonryItems = ref([
}
}
.work-title {
flex: 1;
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 8px;
width: 100%;
.title-text {
color: #ffffff;
font-size: 12px;
font-weight: 500;
text-align: center;
letter-spacing: 1px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.7);
padding: 0 10px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
word-break: break-all;
}
}
.action-btns {
display: flex;
justify-content: center;
@ -253,7 +332,7 @@ const masonryItems = ref([
align-items: center;
justify-content: center;
width: 44px;
height: 18px;
padding: 4px 0;
gap: 2px;
color: #FFFFFF;
background: linear-gradient(215deg, #A679F4 7.14%, #778CF5 100%);
@ -264,7 +343,7 @@ const masonryItems = ref([
transition: all 0.2s;
&:hover {
transform: scale(1.1);
transform: scale(1.05);
}
.icon-img {

View File

@ -150,16 +150,39 @@ const workId = ref(null)
const originalTitle = ref('')
onMounted(async () => {
const id = route.query.workId
const id = route.query.id || route.query.workId
if (!id) return
try {
const work = await api.works.get(id)
let drafts = []
try {
drafts = JSON.parse(localStorage.getItem('local_drafts') || '[]')
if (!Array.isArray(drafts)) drafts = []
} catch (e) { }
const work = drafts.find(w => String(w.id) === String(id))
if (work) {
workId.value = id
originalTitle.value = work.title || ''
if (work.data_json) {
Object.assign(balanceSettings, JSON.parse(work.data_json))
const configData = typeof work.data_json === 'string' ? JSON.parse(work.data_json) : work.data_json
Object.assign(balanceSettings, configData)
}
return
}
const remoteWork = await api.works.get(id)
if (remoteWork) {
workId.value = id
originalTitle.value = remoteWork.title || ''
if (remoteWork.data_json) {
const configData = typeof remoteWork.data_json === 'string' ? JSON.parse(remoteWork.data_json) : remoteWork.data_json
Object.assign(balanceSettings, configData)
}
}
} catch (e) {
console.error('获取草稿失败:', e)
}
} catch { }
})
async function handleSave() {
@ -170,36 +193,120 @@ async function handleSave() {
confirmButtonText: '保存',
cancelButtonText: '取消',
inputValue: defaultTitle,
inputPlaceholder: '输入名称,同名作品将覆盖',
inputPlaceholder: '输入名称(同名将提示覆盖,异名将新建)',
})
if (!title) return
const payload = { type: 'balance', data_json: JSON.stringify(balanceSettings), title }
//
if (workId.value) {
await api.works.update(workId.value, payload)
originalTitle.value = title
ElMessage.success('草稿已保存')
return
let drafts = []
try {
drafts = JSON.parse(localStorage.getItem('local_drafts') || '[]')
if (!Array.isArray(drafts)) drafts = []
} catch (e) {
drafts = []
}
//
const listRes = await api.works.list({ keyword: title, type: 'balance', per_page: 1 })
const existing = (listRes.items || []).find(w => w.title === title)
let draftId = ''
let isOverwriteOther = false
if (existing) {
await ElMessageBox.confirm(`已存在同名作品「${title}」,是否覆盖?`, '提示', { confirmButtonText: '覆盖', cancelButtonText: '取消' })
await api.works.update(existing.id, payload)
ElMessage.success('草稿已覆盖保存')
// /
if (title !== originalTitle.value) {
const existIndex = drafts.findIndex(w => w.title === title && w.type === 'wx-balance')
if (existIndex !== -1) {
await ElMessageBox.confirm(`已存在同名作品「${title}」,是否覆盖该作品?`, '提示', {
confirmButtonText: '覆盖',
cancelButtonText: '取消',
type: 'warning'
})
draftId = drafts[existIndex].id
isOverwriteOther = true
} else {
await api.works.create(payload)
ElMessage.success('草稿已保存')
// 稿
draftId = 'draft_' + Date.now()
}
} else {
// 稿
draftId = workId.value || ('draft_' + Date.now())
}
let coverImg = ''
const el = document.getElementById('capture-frame') || previewRef.value?.$el
if (el) {
try {
const canvas = await html2canvas(el, {
scale: 300 / el.offsetWidth, // 150px
useCORS: true,
onclone: (clonedDoc) => {
const box = clonedDoc.querySelector('.phone-box')
if (box) box.style.transform = 'none'
const frame = clonedDoc.getElementById('capture-frame')
if (frame) {
frame.style.borderRadius = '0'
if (!userStore.isVip) {
const watermark = clonedDoc.querySelector('.watermark-overlay')
if (watermark) {
watermark.style.borderRadius = '0'
frame.appendChild(watermark)
}
}
}
}
})
coverImg = canvas.toDataURL('image/jpeg', 0.6)
} catch (err) {
console.error('截图生成失败', err)
}
}
const payload = {
id: draftId,
title: title,
type: 'wx-balance',
page_router: '/balance',
data_json: JSON.stringify(balanceSettings),
cover_img: coverImg,
update_time: Date.now()
}
let isSelfUpdate = false
if (isOverwriteOther) {
const existIndex = drafts.findIndex(w => String(w.id) === String(draftId))
if (existIndex !== -1) drafts[existIndex] = payload
} else {
const selfIndex = drafts.findIndex(w => String(w.id) === String(draftId))
if (selfIndex !== -1) {
drafts[selfIndex] = payload
isSelfUpdate = true
} else {
drafts.unshift(payload)
}
}
localStorage.setItem('local_drafts', JSON.stringify(drafts))
workId.value = payload.id
originalTitle.value = payload.title
let msg = '已新增草稿'
if (isOverwriteOther) msg = '草稿已覆盖保存'
else if (isSelfUpdate) msg = '草稿已更新'
ElMessage.success(msg)
} catch (e) {
if (e !== 'cancel') {
console.error(e)
ElMessage.error('保存失败')
}
}
} catch (e) { }
}
async function handleExport() {
if (!localStorage.getItem('token')) { ElMessage.warning('请先登录后再下载'); return }
//
try {
if (userStore.fetchProfile) {
await userStore.fetchProfile()
}
} catch (e) { }
if (!userStore.isVip) {
showPayment?.()
return

View File

@ -419,7 +419,7 @@
<script setup>
import { ref, reactive, computed, inject, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Top, Bottom, Delete, EditPen, ArrowDown } from '@element-plus/icons-vue'
import ChatPreview from '@/components/chat/ChatPreview.vue'
@ -432,6 +432,10 @@ import { checkUserVip } from '@/utils/appUtils'
const userStore = useUserStore()
const showPayment = inject('showPayment')
const route = useRoute()
const router = useRouter()
const workId = ref('')
const originalTitle = ref('')
const layoutRef = ref()
const previewRef = ref()
@ -508,8 +512,6 @@ const selectedMsg = computed(() => {
return null
})
const workId = ref(null)
const originalTitle = ref('')
const typeLabels = { text: '文字', time: '时间', redpacket: '红包', transfer: '转账', system: '系统提示语', call: '通话', voice: '语音' }
const getTypeLabel = (msg) => {
@ -531,18 +533,39 @@ function handleSystemQuickAdd(command) {
}
onMounted(async () => {
const id = route.query.workId
const id = route.query.id || route.query.workId
if (!id) return
try {
let drafts = []
try {
drafts = JSON.parse(localStorage.getItem('local_drafts') || '[]')
} catch (e) { }
const localWork = drafts.find(w => String(w.id) === String(id))
if (localWork) {
workId.value = id
originalTitle.value = localWork.title || ''
if (localWork.data_json) {
const data = typeof localWork.data_json === 'string' ? JSON.parse(localWork.data_json) : localWork.data_json
if (data.settings) Object.assign(settings, data.settings)
// map(m => ({ ...m, id: msgIdCounter++ })) ID
if (data.messages) messages.value = data.messages.map(m => ({ ...m, id: msgIdCounter++ }))
}
return
}
const work = await api.works.get(id)
workId.value = id
originalTitle.value = work.title || ''
if (work.data_json) {
const data = JSON.parse(work.data_json)
const data = typeof work.data_json === 'string' ? JSON.parse(work.data_json) : work.data_json
if (data.messages) messages.value = data.messages.map(m => ({ ...m, id: msgIdCounter++ }))
if (data.settings) Object.assign(settings, data.settings)
}
} catch { }
} catch (e) {
console.error('加载草稿异常:', e)
}
})
function getMessageBrief(msg) {
@ -722,28 +745,110 @@ async function handleSave() {
if (!localStorage.getItem('token')) { ElMessage.warning('请先登录'); return }
try {
const defaultTitle = originalTitle.value || settings.title || '未命名作品'
const { value: title } = await ElMessageBox.prompt('请输入作品名称', '保存草稿', { confirmButtonText: '保存', cancelButtonText: '取消', inputValue: defaultTitle })
const { value: title } = await ElMessageBox.prompt('请输入作品名称', '保存草稿', {
confirmButtonText: '保存',
cancelButtonText: '取消',
inputValue: defaultTitle,
inputPlaceholder: '输入名称(同名将提示覆盖,异名将新建)',
})
if (!title) return
const payload = { type: 'chat', data_json: JSON.stringify({ messages: messages.value, settings }), title }
if (workId.value) {
await api.works.update(workId.value, payload)
originalTitle.value = title
ElMessage.success('草稿已保存')
return
let drafts = []
try {
drafts = JSON.parse(localStorage.getItem('local_drafts') || '[]')
if (!Array.isArray(drafts)) drafts = []
} catch (e) {
drafts = []
}
const listRes = await api.works.list({ keyword: title, type: 'chat', per_page: 1 })
const existing = (listRes.items || []).find(w => w.title === title)
if (existing) {
await ElMessageBox.confirm(`已存在同名作品「${title}」,是否覆盖?`, '提示', { confirmButtonText: '覆盖', cancelButtonText: '取消' })
await api.works.update(existing.id, payload)
ElMessage.success('草稿已覆盖保存')
let draftId = ''
let isOverwriteOther = false
// /
if (title !== originalTitle.value) {
const existIndex = drafts.findIndex(w => w.title === title && w.type === 'wx-chat')
if (existIndex !== -1) {
await ElMessageBox.confirm(`已存在同名作品「${title}」,是否覆盖该作品?`, '提示', {
confirmButtonText: '覆盖',
cancelButtonText: '取消',
type: 'warning'
})
draftId = drafts[existIndex].id
isOverwriteOther = true
} else {
await api.works.create(payload)
ElMessage.success('草稿已保存')
// 稿
draftId = 'draft_' + Date.now()
}
} else {
// 稿
draftId = workId.value || ('draft_' + Date.now())
}
let coverImg = ''
const el = document.getElementById('capture-frame') || previewRef.value?.$el
if (el) {
try {
const canvas = await html2canvas(el, {
scale: 300 / el.offsetWidth, //
useCORS: true,
onclone: (clonedDoc) => {
const box = clonedDoc.querySelector('.phone-box')
if (box) box.style.transform = 'none'
const frame = clonedDoc.getElementById('capture-frame')
if (frame) {
frame.style.borderRadius = '0'
if (!userStore.isVip) {
const watermark = clonedDoc.querySelector('.watermark-overlay')
if (watermark) {
watermark.style.borderRadius = '0'
frame.appendChild(watermark)
}
}
}
}
})
coverImg = canvas.toDataURL('image/jpeg', 0.8)
} catch (err) {
console.error('生成封面失败', err)
}
}
const payload = {
id: draftId,
type: 'wx-chat',
page_router: '/chat',
title,
cover_img: coverImg,
data_json: JSON.stringify({ messages: messages.value, settings }),
updated_at: new Date().toISOString(),
created_at: new Date().toISOString()
}
const existingIndex = drafts.findIndex(d => d.id === draftId)
if (existingIndex !== -1) {
//
payload.created_at = drafts[existingIndex].created_at
drafts[existingIndex] = payload
} else {
drafts.unshift(payload)
}
localStorage.setItem('local_drafts', JSON.stringify(drafts))
//
originalTitle.value = title
if (!workId.value || isOverwriteOther) {
workId.value = draftId
router.replace({ query: { id: draftId } })
}
ElMessage.success('草稿保存成功')
} catch (e) {
if (e !== 'cancel') {
console.error(e)
ElMessage.error('保存失败')
}
}
} catch (e) { }
}
async function handleExport() {