新增对接接口,文章

This commit is contained in:
tangxinyue 2026-07-24 14:15:00 +08:00
parent 264abf002e
commit 55cd86a45f
38 changed files with 1343 additions and 946 deletions

View File

@ -12,8 +12,8 @@
<script setup>
import { ref, provide, onMounted } from 'vue'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
import AppHeader from '@/components/common/AppHeader.vue'
import AppFooter from '@/components/common/AppFooter.vue'
import PaymentDialog from '@/components/PaymentDialog.vue'
import { useUserStore } from '@/stores/user'
import api from '@/api'

View File

@ -31,7 +31,7 @@ export default {
/**
* 3. 获取微信扫码登录二维码
*/
async getLoginCode(data = {}) {
async getLoginCode(data) {
return await postJson(appConfig.baseURL, "/weixin/qrcode", data);
},
@ -39,9 +39,9 @@ export default {
* 4. 轮询微信扫码状态
* @param {Object} data - { key: 'xxx' }
*/
async askLoginCode(data) {
return await get(appConfig.baseURL, "/weixin/qrcode", data);
},
// async askLoginCode(data) {
// return await get(appConfig.baseURL, "/weixin/qrcode", data);
// },
/**
* 5. 获取当前登录用户信息
@ -68,5 +68,22 @@ export default {
return await postJson(appConfig.baseURL, "/order", data);
},
/**
* 查询订单
* @param {*} data
* @returns
*/
async getOrder(data = {}) {
return await get(appConfig.baseURL, "/order", data);
},
/**
* 获取app二维码
* @param {*} data
* @returns
*/
async getAppCode(data) {
return await get(appConfig.baseURL, "/app/code", data);
}
};

View File

@ -73,7 +73,7 @@ import { Close } from '@element-plus/icons-vue'
import api from '@/api'
import authService from '@/api/authService'
import { useUserStore } from '@/stores/user'
import BaseDialog from './BaseDialog.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
const emit = defineEmits(['success'])
const userStore = useUserStore()
@ -233,12 +233,13 @@ async function handleLogin() {
const res = await authService.login(loginPayload)
if (res && res.data && res.data.token) {
localStorage.setItem('token', res.data.token)
userStore.setUser(res.data, form.phone)
console.log("登录成功", res);
ElMessage.success('登录成功')
visible.value = false
emit('success')
// window.location.reload()
const userInfo = (await authService.getUserInfo()).data
userStore.setUser(userInfo, form.phone)
window.location.reload()
} else {
showError.value = true
}

View File

@ -3,13 +3,14 @@
<div class="recharge-dialog">
<!-- 头部用户信息 -->
<div class="user-header">
<el-avatar :size="45" :src="userInfo.avatar_url || undefined">
{{ userInfo.username?.charAt(0)?.toUpperCase() }}
<el-avatar :size="45"
:src="userInfo.avater || userInfo.avatar || userInfo.avatar_url || userStore.avatarUrl || undefined">
{{ (userInfo.name || userInfo.username || userStore.username || 'U').charAt(0).toUpperCase() }}
</el-avatar>
<div class="user-meta">
<span class="username">{{ userInfo.username || '用户' }}</span>
<span class="username">{{ userInfo.name || userInfo.username || userStore.username || '用户' }}</span>
<span class="vip-status" :class="{ expired: !isVip }">
{{ isVip ? `会员 ${formatExpire(userInfo.membership_expire_at)}到期` : '未开通会员' }}
{{ isVip ? `会员 ${userInfo.membership_expire_at}到期` : '未开通会员' }}
</span>
</div>
</div>
@ -17,8 +18,10 @@
<div class="dialog-body">
<!-- 左侧面板 -->
<div class="left-panel">
<div v-if="!loading && productList.length" class="plans-scroll" ref="scrollContainer" @mousedown="onMouseDown"
@mouseleave="onMouseLeave" @mouseup="onMouseUp" @mousemove="onMouseMove">
<div v-if="!loading && productList.length" class="plans-scroll" :class="{ 'is-disabled': paying }"
ref="scrollContainer" @mousedown="onMouseDown" @mouseleave="onMouseLeave" @mouseup="onMouseUp"
@mousemove="onMouseMove">
<div v-for="(item, idx) in productList" :key="item.goods_id" class="plan-card"
:class="{ active: selectedProductId === item.goods_id }" @click="handleProductChange(item.goods_id)">
<div class="plan-name">{{ item.goods_name }}</div>
@ -32,6 +35,15 @@
</el-icon>
<span>加载商品...</span>
</div>
<!-- 手写切换加载蒙层 -->
<transition name="fade">
<div v-if="switchingProduct" class="custom-loading-mask">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span>切换中...</span>
</div>
</transition>
<!-- 会员权益 -->
<div class="privilege-section">
@ -87,11 +99,17 @@
<div class="qr-box">
<img v-if="qrCode" :src="qrCode" alt="支付二维码" class="qr-img" />
<div v-else class="qr-placeholder" @click="handlePay">
<el-icon v-if="paying" class="is-loading">
<div v-else-if="paying || loading" class="qr-placeholder loading">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span v-else>点击获取支付码</span>
<span>生成支付码...</span>
</div>
<div v-else class="qr-placeholder error" @click="handlePay">
<el-icon>
<RefreshRight />
</el-icon>
<span>获取失败点击重试</span>
</div>
</div>
@ -119,10 +137,10 @@
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { ref, computed, watch, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import BaseDialog from './BaseDialog.vue'
import { Loading, Download, ChatDotRound, Tickets, Postcard, Position, Van, ShoppingBag, Wallet } from '@element-plus/icons-vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { Loading, RefreshRight, Download, ChatDotRound, Tickets, Postcard, Position, Van, ShoppingBag, Wallet } from '@element-plus/icons-vue'
import authService from '@/api/authService.js'
import { useUserStore } from '@/stores/user'
@ -132,11 +150,21 @@ const userStore = useUserStore()
const visible = ref(false)
const selectedProductId = ref(null)
const paying = ref(false)
const switchingProduct = ref(false)
const qrCode = ref('')
const loading = ref(false)
const productList = ref([])
const payMethod = ref('weixin')
let pollTimer = null
let orderVersion = 0
let activeOrderNo = ''
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
//
const scrollContainer = ref(null)
@ -162,10 +190,14 @@ const currentOriginalPrice = computed(() => {
})
function open() {
stopPolling()
orderVersion++
activeOrderNo = ''
visible.value = true
selectedProductId.value = null
qrCode.value = ''
paying.value = false
switchingProduct.value = false
loadProducts()
}
@ -177,16 +209,23 @@ async function loadProducts() {
productList.value = Array.isArray(res.data) ? res.data : []
if (productList.value.length > 0) {
//
//
selectedProductId.value = productList.value[0].goods_id
} else {
ElMessage.warning('暂无可售商品')
}
if (!productList.value.length) ElMessage.warning('暂无可售商品')
} catch (e) {
ElMessage.error(e?.response?.data?.error || '加载商品失败')
productList.value = []
} finally {
// Loading
loading.value = false
}
//
if (selectedProductId.value) {
handlePay()
}
}
/**
@ -224,8 +263,16 @@ function getQrCodeUrl(resData) {
}
async function handlePay() {
if (!selectedProductId.value) return
if (!selectedProductId.value || paying.value) return
stopPolling()
orderVersion++
const currentVersion = orderVersion
paying.value = true
qrCode.value = ''
activeOrderNo = ''
const data = {
"goods_id": selectedProductId.value, //
"pay_type": payMethod.value, // weixin/alipay/apple/bank/balance
@ -238,6 +285,13 @@ async function handlePay() {
try {
const res = await authService.createOrder(data)
console.log('创建订单响应结果:', res)
//
if (currentVersion !== orderVersion) {
console.warn('丢弃已失效过时的订单响应')
return
}
const orderData = res.data || res
if (payMethod.value === 'alipay') {
@ -247,32 +301,35 @@ async function handlePay() {
qrCode.value = getQrCodeUrl(orderData)
}
const orderNo = res.order_no || orderData.order_no || orderData.orderId
const orderNo = res.order_no || orderData.order_no || orderData.orderId || orderData.order_id
if (orderNo) {
startPolling(orderNo)
activeOrderNo = orderNo
startPolling(orderNo, currentVersion)
}
} catch (e) {
ElMessage.error(e?.response?.data?.error || '创建订单失败')
if (currentVersion === orderVersion) {
qrCode.value = ''
ElMessage.error(e?.response?.data?.error || '创建订单失败,请点击重试')
}
} finally {
if (currentVersion === orderVersion) {
paying.value = false
switchingProduct.value = false
}
}
}
function handlePayMethodChange(method) {
if (payMethod.value === method) return
if (paying.value || payMethod.value === method) return
payMethod.value = method
if (qrCode.value) {
handlePay()
}
}
function handleProductChange(id) {
if (hasDragged) return
if (selectedProductId.value === id) return
if (hasDragged || paying.value || selectedProductId.value === id) return
selectedProductId.value = id
if (qrCode.value) {
switchingProduct.value = true
handlePay()
}
}
//
@ -306,40 +363,48 @@ function onMouseMove(e) {
scrollContainer.value.scrollLeft = scrollLeft.value - walk
}
function startPolling(orderNo) {
if (pollTimer) clearInterval(pollTimer)
function startPolling(orderNo, currentVersion) {
stopPolling()
let count = 0
pollTimer = setInterval(async () => {
//
if (currentVersion !== orderVersion || orderNo !== activeOrderNo) {
stopPolling()
return
}
count++
try {
const res = await api.payment.getOrder(orderNo)
if (res.status === 'paid') {
clearInterval(pollTimer)
const res = await authService.getOrder({ order_id: orderNo })
console.log('轮询查询订单响应结果:', res, '订单号', orderNo)
if (res.code == 0 && res?.data?.status ==
2) {
stopPolling()
ElMessage.success('支付成功,会员已开通')
setTimeout(() => {
visible.value = false
emit('success')
}, 1500)
}
} catch { }
} catch (e) {
console.error('轮询查询订单失败:', e)
}
if (count > 120) {
clearInterval(pollTimer)
stopPolling()
ElMessage.warning('轮询超时,请刷新页面查看')
}
}, 3000)
}
function formatDate(d) {
if (!d) return '-'
return new Date(d).toLocaleString('zh-CN')
}
function formatExpire(d) {
if (!d) return ''
return new Date(d).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
}
watch(visible, (val) => {
if (!val) {
stopPolling()
orderVersion++
activeOrderNo = ''
}
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
stopPolling()
})
defineExpose({ open })
@ -404,20 +469,54 @@ defineExpose({ open })
}
.left-panel {
position: relative;
flex-shrink: 0;
width: 677px;
height: 493px;
padding: 44px 46px;
box-sizing: border-box;
.custom-loading-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(245, 245, 255, 0.75);
backdrop-filter: blur(2px);
border-radius: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
z-index: 10;
color: #8B70FF;
font-size: 14px;
font-weight: 500;
pointer-events: auto;
.el-icon {
font-size: 24px;
}
}
}
.plans-scroll {
position: relative;
display: flex;
gap: 16px;
overflow-x: auto;
padding-bottom: 2px;
cursor: grab;
user-select: none;
border-radius: 16px;
&.is-disabled {
pointer-events: none;
}
&::-webkit-scrollbar {
display: none;
@ -670,13 +769,40 @@ defineExpose({ open })
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #999;
font-size: 14px;
cursor: pointer;
font-size: 12px;
background: #f5f5f5;
border-radius: 10px;
padding: 8px;
box-sizing: border-box;
text-align: center;
&.error {
cursor: pointer;
color: #ff4d4f;
transition: background-color 0.2s;
&:hover {
background-color: #ffeef0;
}
.el-icon {
font-size: 20px;
}
}
&.loading {
cursor: wait;
color: #8B70FF;
.el-icon {
font-size: 22px;
}
}
}
}
@ -736,4 +862,14 @@ defineExpose({ open })
right: -32px !important;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -7,7 +7,7 @@
<div class="banner-content">
<span class="main-text"> 装样大师 APP已上线</span>
<span class="sub-text">各大应用市场均可下载</span>
<img class="down-app-img" src="@/public/header/down-app-btn.png" alt="">
<img class="down-app-img" src="@/public/header/down-app-btn.png" alt="" @click="openAppCodeDialog">
</div>
<!-- <img class="close-icon" src="@/public/header/close.png" alt=""> -->
</div>
@ -38,6 +38,7 @@
<div class="right-box">
<img v-if="!userStore.isVip && isLoggedIn" @click="handleCommand('vip')" class="vip-logo"
src="@/public/header/kaitonghuiyuan.png" mode="heightFix" />
<img v-else-if="isLoggedIn" class="vip-logo" src="@/public/header/zunguihuiyuan.png" mode="heightFix" />
<img v-if="!isLoggedIn" @click="openLoginDialog" class="vip-logo" src="@/public/header/login-btn.png"
mode="heightFix" />
<div v-else class="dropdown-wrapper">
@ -70,6 +71,11 @@
</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="">
</div>
</BaseDialog>
</header>
</template>
@ -78,6 +84,8 @@ import { computed, inject, ref, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import LoginDialog from '@/components/LoginDialog.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import authService from '@/api/authService.js'
import { ElMessage } from 'element-plus'
const router = useRouter()
@ -85,6 +93,25 @@ const route = useRoute()
const userStore = useUserStore()
const showPayment = inject('showPayment')
const loginDialogRef = ref(null)
const appCodeRef = ref(null)
const showAppCode = ref(false)
const appCodeImg = ref('')
async function openAppCodeDialog() {
showAppCode.value = true
if (!appCodeImg.value) {
try {
const res = await authService.getAppCode()
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 : '')
} catch (e) {
console.log(e.message)
}
}
}
onMounted(() => {
if (localStorage.getItem('token')) {
@ -132,10 +159,14 @@ const navList = [
]
},
{ name: '作品库', key: 'works', path: '/works' },
{ name: '关于我们', key: 'about', path: '/about' }
{ name: '关于我们', key: 'about', path: '/about' },
{ name: '文章', key: 'article', path: '/article' }
]
function handleNavClick(item) {
if (isItemActive(item)) return
if (item.key === 'works' && !isLoggedIn.value) {
loginDialogRef.value?.open()
return
@ -147,6 +178,7 @@ function handleNavClick(item) {
function isItemActive(item) {
if (item.path === route.path) return true
if (item.key === 'article' && route.path.startsWith('/article')) return true
if (item.children) {
return item.children.some(child => child.path === route.path)
}
@ -526,4 +558,12 @@ function openLoginDialog() {
cursor: pointer;
height: 100%;
}
.qrCode-box {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 20px 0;
}
</style>

View File

@ -1,6 +1,5 @@
<template>
<div class="base-dialog">
<el-dialog class="base-dialog" v-model="visible" :width="width" align-center>
<img src="@/public/header/close.png" class="close" @click="handleClose" />
<slot></slot>
@ -44,6 +43,11 @@ function handleClose() {
top: -34px;
right: -34px;
z-index: 9999;
&:hover {
transform: scale(1.1);
transition: transform 0.3s;
}
}
.el-dialog__headerbtn {

View File

@ -24,7 +24,7 @@
justify-content: center;
align-items: center;
gap: 8px;
z-index: 100;
z-index: 1;
pointer-events: none;
.warning-icon {

View File

@ -4,18 +4,24 @@
<div class="iphone-frame" :class="theme" id="capture-frame">
<slot></slot>
</div>
<div class="watermark-overlay"></div>
<div v-if="!isVip" class="watermark-overlay"></div>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
defineProps({
theme: {
type: String,
default: 'light',
}
})
const userStore = useUserStore()
const { isVip } = storeToRefs(userStore)
</script>
<style lang="scss" scoped>

View File

@ -44,7 +44,7 @@
<script setup>
import { ref } from 'vue'
import { FolderAdd, Download } from '@element-plus/icons-vue'
import WarningFooter from '@/components/WarningFooter.vue'
import WarningFooter from '@/components/common/WarningFooter.vue'
import PhoneMockup from '@/components/simulator/PhoneMockup.vue'
import StatusBar from '@/components/simulator/StatusBar.vue'

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
src/public/article/date.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 B

BIN
src/public/article/eye.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 B

BIN
src/public/article/hot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
src/public/article/left.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -3,15 +3,13 @@ import { useUserStore } from '@/stores/user'
const routes = [
{ path: '/', name: 'index', component: () => import('@/views/index/index.vue') },
// { path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
{ path: '/chat', name: 'Chat', component: () => import('@/views/ChatEdit.vue'), meta: { requiresAuth: false } },
{ path: '/balance', name: 'Balance', component: () => import('@/views/BalanceEdit.vue'), meta: { requiresAuth: false } },
{ path: '/chat', name: 'Chat', component: () => import('@/views/wxPage/ChatEdit.vue'), meta: { requiresAuth: false } },
{ path: '/balance', name: 'Balance', component: () => import('@/views/wxPage/BalanceEdit.vue'), meta: { requiresAuth: false } },
{ path: '/works', name: 'Works', component: () => import('@/views/work/works.vue'), meta: { requiresAuth: true } },
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
{ path: '/register', name: 'Register', component: () => import('@/views/Register.vue') },
{ path: '/about', name: 'About', component: () => import('@/views/About.vue') },
{ path: '/profile', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { requiresAuth: true } },
{ path: '/payment/result', name: 'PaymentResult', component: () => import('@/views/PaymentResult.vue') },
{ path: '/article', name: 'Article', component: () => import('@/views/article/list.vue') },
{ path: '/article-detail', name: 'ArticleDetail', component: () => import('@/views/article/detail.vue') },
{ path: '/gongzidan', name: 'GongZiDan', component: () => import('@/views/other/gongzidan.vue') },
]

View File

@ -8,6 +8,7 @@ export const useUserStore = defineStore('user', () => {
const isVip = ref(false)
const avatarUrl = ref('')
const membershipExpireAt = ref(null)
const userInfoRaw = ref(null) // 响应式保存最新完整的用户原始数据
const tokenChecked = ref(false) // 是否已完成 Token 校验
const tokenValid = ref(false) // Token 是否真实有效
@ -20,6 +21,12 @@ export const useUserStore = defineStore('user', () => {
function setUser(data, fallbackPhone = '') {
if (!data) return
const userObj = data.user || data
userInfoRaw.value = userObj
// 写入本地缓存,供快照加载与 checkUserVip 自动使用
try {
localStorage.setItem('user_info', JSON.stringify(userObj))
} catch (e) { }
// 游客账号特征判断temp 为 true或角色 role 为 '8',或名称包含 '游客'
const isGuest = userObj.temp === true || userObj.role === '8' || userObj.name === '游客' || userObj.nickname === '游客'
@ -36,6 +43,7 @@ export const useUserStore = defineStore('user', () => {
username.value = userObj.name || '已登录用户'
isVip.value = checkUserVip(userObj)
avatarUrl.value = userObj.avater || userObj.avatar || ''
membershipExpireAt.value = userObj.vip_expire || null
tokenChecked.value = true
tokenValid.value = true
}
@ -46,7 +54,7 @@ export const useUserStore = defineStore('user', () => {
const token = localStorage.getItem('token')
if (!token) {
reset()
return
return null
}
// 1. 本地快照秒开:瞬间解析 user_info 填充响应式数据0毫秒延迟
@ -63,21 +71,22 @@ export const useUserStore = defineStore('user', () => {
return fetchPromise
}
// 3. 后台非阻塞异步校验:将真实 API 鉴权放入后台处理
// 3. 实时后台接口校验:精准兼容多种 Api 返回数据结构并更新 store 与缓存
fetchPromise = (async () => {
try {
const res = await authService.getUserInfo()
if (res && (res.code === 0 || res.data)) {
if (res.data) setUser(res.data)
console.log("用户信息", res.data)
return res.data
} else {
// 如果后端明确返回 Token 过期/无效,清空登录态
// 兼容 res.data 结构以及直接返回用户对象的结构
const userData = (res && res.data) ? res.data : (res && (res.vip !== undefined || res.user_id || res.name) ? res : null)
if (userData) {
setUser(userData)
console.log("最新后台用户信息已同步:", userData)
return userData
} else if (res && res.code && res.code !== 0) {
reset()
}
} catch (e) {
// Token 过期或鉴权失败,强制清空缓存并置为未登录
reset()
console.error("同步后台用户信息异常:", e)
} finally {
fetchPromise = null
}
@ -91,11 +100,12 @@ export const useUserStore = defineStore('user', () => {
isVip.value = false
avatarUrl.value = ''
membershipExpireAt.value = null
userInfoRaw.value = null
tokenChecked.value = true
tokenValid.value = false
localStorage.removeItem('token')
localStorage.removeItem('user_info')
}
return { username, isVip, avatarUrl, membershipExpireAt, tokenChecked, tokenValid, isLoggedIn, setUser, fetchProfile, reset, checkVip: checkUserVip }
return { username, isVip, avatarUrl, membershipExpireAt, userInfoRaw, tokenChecked, tokenValid, isLoggedIn, setUser, fetchProfile, reset, checkVip: checkUserVip }
})

View File

@ -1,27 +1,53 @@
import { useUserStore } from '@/stores/user'
/**
* 公共方法根据用户信息判断用户是否为 VIP 会员
* @param {Object} userObj - 用户信息对象
* @param {Object} [userObj] - 用户信息对象可选未传时自动同步 Pinia/localStorage 数据
* @returns {Boolean} - 是否为有效 VIP 会员
*/
export function checkUserVip(userObj) {
if (!userObj || typeof userObj !== 'object') return false;
let targetUser = userObj;
// 0. 若未传入有效对象,优先从 userStore 的响应式数据读取,保证接口更新后 Vue 视图可响应式刷新
if (!targetUser || typeof targetUser !== 'object' || Object.keys(targetUser).length === 0) {
try {
const userStore = useUserStore();
if (userStore && userStore.userInfoRaw) {
targetUser = userStore.userInfoRaw;
}
} catch (e) { }
// 若 store 暂无数据,降级从 localStorage 读取缓存
if (!targetUser || typeof targetUser !== 'object' || Object.keys(targetUser).length === 0) {
try {
const localData = localStorage.getItem('user_info');
if (localData) {
targetUser = JSON.parse(localData);
}
} catch (e) {
console.error('解析本地用户信息失败:', e);
}
}
}
if (!targetUser || typeof targetUser !== 'object') return false;
// 1. 过滤游客/临时账号
if (userObj.temp === true || userObj.role === '8' || userObj.name === '游客' || userObj.nickname === '游客') {
if (targetUser.temp === true || targetUser.role === '8' || targetUser.name === '游客' || targetUser.nickname === '游客') {
return false;
}
// 2. VIP 标记判定 (兼容 vip > 1、vip 为 true、is_vip 为 true 等字段结构)
const isVipFlag = (typeof userObj.vip === 'number' ? userObj.vip > 1 : !!userObj.vip) ||
!!userObj.is_vip ||
userObj.role === 'vip' ||
userObj.role === '1';
const isVipFlag = (typeof targetUser.vip === 'number' ? targetUser.vip > 1 : !!targetUser.vip) ||
!!targetUser.is_vip ||
targetUser.role === 'vip' ||
targetUser.role === '1';
if (!isVipFlag) return false;
// 3. 校验过期时间 (如果包含 vip_expire)
if (userObj.vip_expire) {
const expireTime = new Date(userObj.vip_expire).getTime();
if (targetUser.vip_expire) {
const expireTime = new Date(targetUser.vip_expire).getTime();
if (!isNaN(expireTime) && expireTime < Date.now()) {
return false; // 已过期
}

View File

@ -36,22 +36,18 @@ const setheader = (dataType) => {
} else {
header["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8";
}
header["x-channel"] = "official";
header["x-version"] = "1.0.0";
header["x-platform"] = "pc";
header["x-platform"] = "web";
let deviceId = localStorage.getItem("device_id");
if (!deviceId) {
deviceId = appUtils.generateUUID();
localStorage.setItem("device_id", deviceId);
}
header["x-device-id"] = deviceId;
header["x-mobile-brand"] = "pc";
header["x-mobile-model"] = "pc";
header["x-package"] = "com.devcon.flaunt";
const token = localStorage.getItem("token");
if (token) {
header["x-token"] = token;

View File

@ -13,7 +13,7 @@
</div>
</template>
<script setup>
import AppFooter from '@/components/AppFooter.vue'
import AppFooter from '@/components/common/AppFooter.vue'
</script>
<style lang="scss" scoped>

View File

@ -1,128 +0,0 @@
<template>
<div class="home-page">
<!-- Hero Banner -->
<section class="hero">
<el-carousel :interval="4000" height="360px" v-if="banners.length">
<el-carousel-item v-for="b in banners" :key="b.id">
<div class="banner-item" :style="{ backgroundImage: `url(${b.image_url})` }" @click="goLink(b.link_url)">
<h2 class="banner-title">{{ b.title }}</h2>
</div>
</el-carousel-item>
</el-carousel>
<div class="hero-default" v-else>
<h1>微信截图生成器</h1>
<p>高度还原微信聊天界面 & 余额详情所见即所得一键导出高清截图</p>
</div>
</section>
<!-- Feature Cards -->
<section class="features">
<div class="container">
<h2 class="section-title">核心功能</h2>
<div class="feature-cards">
<router-link to="/chat" class="feature-card">
<div class="card-icon">💬</div>
<h3>聊天记录模拟</h3>
<p>支持文字/红包/转账等多种消息类型自定义头像昵称像素级还原真实效果</p>
<el-button type="primary" plain>立即体验 </el-button>
</router-link>
<router-link to="/balance" class="feature-card">
<div class="card-icon">💰</div>
<h3>微信余额模拟</h3>
<p>还原零钱页面余额/零钱通/交易记录自由定制支持深色主题切换</p>
<el-button type="primary" plain>立即体验 </el-button>
</router-link>
</div>
</div>
</section>
<!-- How it works -->
<section class="steps-section">
<div class="container">
<h2 class="section-title">使用步骤</h2>
<div class="steps">
<div class="step"><span class="step-num">1</span><p>选择功能类型</p></div>
<div class="step-arrow"></div>
<div class="step"><span class="step-num">2</span><p>编辑内容细节</p></div>
<div class="step-arrow"></div>
<div class="step"><span class="step-num">3</span><p>实时预览效果</p></div>
<div class="step-arrow"></div>
<div class="step"><span class="step-num">4</span><p>导出高清截图</p></div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
const banners = ref([])
onMounted(async () => {
try { banners.value = await api.public.banners() } catch {}
})
function goLink(url) { if (url) window.open(url, '_blank') }
</script>
<style lang="scss" scoped>
.home-page { background: #fff; }
.hero {
.banner-item {
width: 100%; height: 100%;
background-size: cover; background-position: center;
display: flex; align-items: center; justify-content: center; cursor: pointer;
.banner-title { color: #fff; font-size: 28px; text-shadow: 0 1px 4px rgba(0,0,0,.3); }
}
.hero-default {
height: 360px; display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: linear-gradient(135deg, #07c160 0%, #06ad56 100%);
color: #fff;
h1 { font-size: 36px; margin-bottom: 12px; }
p { font-size: 16px; opacity: .9; }
}
}
.features { padding: 60px 0; background: #f8f9fa; }
.container { max-width: 1100px; margin: 0 auto; padding: 0 20px; }
.section-title {
text-align: center; font-size: 28px; font-weight: bold; margin-bottom: 40px;
}
.feature-cards {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 30px;
.feature-card {
background: #fff; border-radius: 16px; padding: 40px 32px;
text-align: center; box-shadow: 0 4px 20px rgba(0,0,0,.06);
transition: transform .25s, box-shadow .25s;
&:hover { transform: translateY(-4px); box-shadow: 0 8px 30px rgba(0,0,0,.10); }
.card-icon { font-size: 48px; margin-bottom: 16px; }
h3 { font-size: 20px; margin-bottom: 10px; color: #333; }
p { color: #666; line-height: 1.7; margin-bottom: 20px; font-size: 14px; }
}
}
.steps-section { padding: 50px 0; }
.steps {
display: flex; align-items: center; justify-content: center; gap: 16px;
.step {
display: flex; flex-direction: column; align-items: center; gap: 8px;
.step-num {
width: 44px; height: 44px; border-radius: 50%;
background: #07c160; color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: bold;
}
p { font-size: 13px; color: #666; }
}
.step-arrow { font-size: 24px; color: #ccc; }
}
</style>

View File

@ -1,174 +0,0 @@
<template>
<div class="auth-page">
<div class="auth-card">
<h2>账号登录</h2>
<el-form :model="form" :rules="rules" ref="formRef" @keyup.enter="handleLogin">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名或邮箱" prefix-icon="User" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" size="large" show-password />
</el-form-item>
<el-form-item prop="captcha">
<div style="display:flex;gap:10px;width:100%">
<el-input v-model="form.captcha" placeholder="验证码(不区分大小写)" size="large" style="flex:1" />
<div class="captcha-img" @click="loadCaptcha" title="点击刷新">
<img v-if="captchaData.image" :src="captchaData.image" alt="" />
<span v-else-if="captchaData.text_mode" class="captcha-text">点击刷新</span>
<span v-else class="captcha-text loading-text">加载中...</span>
</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="loading" style="width:100%" @click="handleLogin"> </el-button>
</el-form-item>
<!-- 微信扫码登录 -->
<el-divider><span style="color:#999;font-size:12px">其他登录方式</span></el-divider>
<div class="wechat-login-area">
<button
type="button"
class="wechat-login-btn"
:disabled="wechatLoading"
@click="handleWechatLogin"
>
<svg viewBox="0 0 24 24" fill="#07c160" width="20" height="20"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.343a.722.722 0 01.598.082l1.584.926a.272.272 0 00.14.045c.134 0 .24-.111.24-.245 0-.06-.023-.118-.038-.177l-.327-1.233a.582.582 0 01-.023-.156.49.49 0 01.201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89l-.007-.03zm-2.574 2.74c.535 0 .969.44.969.982a.976.976 0 01-.969.983.976.976 0 01-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 01-.97.983.976.976 0 01-.968-.983c0-.542.434-.982.969-.982z"/></svg>
{{ wechatLoading ? '获取中...' : '微信扫码登录' }}
</button>
</div>
</el-form>
<p class="switch-text">
还没有账号<router-link to="/register">立即注册</router-link>
</p>
<p class="forgot-text">忘记密码请联系管理员重置</p>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import api from '@/api'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const formRef = ref()
const loading = ref(false)
const wechatLoading = ref(false)
const captchaKey = ref('')
const captchaData = ref({ image: '', text_mode: false })
//
if (localStorage.getItem('token')) {
router.replace(route.query.redirect || '/')
}
// token URL
if (route.query.wechat_login && route.query.token) {
localStorage.setItem('token', route.query.token)
// URL
const cleanUrl = window.location.pathname + window.location.hash.split('?')[0]
window.history.replaceState({}, document.title, cleanUrl)
//
userStore.fetchProfile().then(() => {
ElMessage.success('微信登录成功')
router.push(route.query.redirect || '/')
}).catch(() => {
// token
localStorage.removeItem('token')
})
}
const form = reactive({ username: '', password: '', captcha: '' })
const rules = {
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
captcha: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
}
onMounted(() => loadCaptcha())
async function handleWechatLogin() {
wechatLoading.value = true
try {
const res = await api.auth.wechatLoginUrl()
if (res.oauth_url) {
window.location.href = res.oauth_url
}
} catch (err) {
ElMessage.warning('微信登录暂不可用,请联系管理员')
} finally {
wechatLoading.value = false
}
}
async function loadCaptcha() {
try {
const data = await api.auth.captcha()
captchaData.value = data
captchaKey.value = data.captcha_key || ''
} catch { ElMessage.warning('验证码加载失败,请点击刷新') }
}
async function handleLogin() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const payload = { ...form, captcha_key: captchaKey.value }
const res = await api.auth.login(payload)
localStorage.setItem('token', res.token)
userStore.username = res.user.username
userStore.isVip = res.user.is_vip
userStore.tokenChecked = true
userStore.tokenValid = true
ElMessage.success('登录成功')
router.push(route.query.redirect || '/')
} catch (err) {
const msg = err?.response?.data?.error || '登录失败,请稍后重试'
ElMessage.error(msg)
loadCaptcha()
form.captcha = ''
} finally { loading.value = false }
}
</script>
<style lang="scss" scoped>
.auth-page {
min-height: calc(100vh - 120px);
display: flex; align-items: center; justify-content: center;
padding: 40px 20px;
}
.auth-card {
width: 420px; padding: 40px; border-radius: 12px;
background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,.08);
h2 { text-align: center; margin-bottom: 28px; font-size: 22px; }
.captcha-img {
width: 120px; height: 40px; border: 1px solid #dcdfe6; border-radius: 4px;
cursor: pointer; overflow: hidden; display: flex; align-items: center; justify-content: center;
img { width: 100%; height: 100%; object-fit: cover; }
.captcha-text { font-family: monospace; font-size: 18px; letter-spacing: 6px; font-weight: bold; color: #333; }
.loading-text { font-size: 12px; letter-spacing: normal; color: #999; }
}
.switch-text { text-align: center; color: #999; font-size: 13px; a { color: #07c160; } }
.forgot-text { text-align: center; color: #bbb; font-size: 12px; margin-top: 4px; }
:deep(.el-divider__text) { background-color: transparent; }
.wechat-login-area {
display: flex; justify-content: center; padding: 8px 0;
}
.wechat-login-btn {
display:flex; align-items:center; gap:6px; padding:10px 32px;
border:1px solid #e0e0e0; border-radius:22px;
background:#fff; cursor:pointer; font-size:14px; color:#333;
transition:all .2s; outline:none;
}
.wechat-login-btn:hover:not(:disabled) {
border-color:#07c160; color:#07c160; background:#f0faf0;
}
.wechat-login-btn:disabled { opacity:.5; cursor:not-allowed; }
}
</style>

View File

@ -1,142 +0,0 @@
<template>
<div class="payment-result-page">
<div class="result-card" :class="statusClass">
<div class="result-icon">{{ iconText }}</div>
<h2 class="result-title">{{ title }}</h2>
<p class="result-desc">{{ desc }}</p>
<!-- 订单信息 -->
<template v-if="orderNo">
<div class="order-info">
<div class="info-row">
<span class="info-label">订单编号</span>
<span class="info-value mono">{{ orderNo }}</span>
</div>
<div v-if="tradeNo" class="info-row">
<span class="info-label">支付宝交易号</span>
<span class="info-value mono">{{ tradeNo }}</span>
</div>
<div v-if="amount" class="info-row">
<span class="info-label">支付金额</span>
<span class="info-value amount">¥{{ amount }}</span>
</div>
</div>
</template>
<div class="result-actions">
<el-button type="primary" size="large" @click="goHome">返回首页</el-button>
<el-button size="large" @click="goProfile">查看订单</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '@/api'
const route = useRoute()
const router = useRouter()
const orderNo = ref(route.query.out_trade_no || '')
const tradeNo = ref(route.query.trade_no || '')
const amount = ref(route.query.total_amount || '')
const payStatus = ref('') // 'success' | 'pending' | 'failed'
const statusClass = computed(() => {
if (payStatus.value === 'success') return 'card-success'
if (payStatus.value === 'failed') return 'card-failed'
return 'card-pending'
})
const iconText = computed(() => {
if (payStatus.value === 'success') return '✅'
if (payStatus.value === 'failed') return '❌'
return '⏳'
})
const title = computed(() => {
if (payStatus.value === 'success') return '支付成功'
if (payStatus.value === 'failed') return '支付失败'
return '确认中...'
})
const desc = computed(() => {
if (payStatus.value === 'success') return '会员已开通,感谢您的购买!'
if (payStatus.value === 'failed') return '支付未完成,请稍后在我的订单中查看'
return '正在确认支付结果,请稍候...'
})
async function verifyOrder() {
// trade_status
const qsTradeStatus = route.query.trade_status
if (qsTradeStatus === 'TRADE_SUCCESS' || qsTradeStatus === 'TRADE_FINISHED') {
payStatus.value = 'success'
return
}
//
if (orderNo.value) {
try {
const res = await api.payment.checkResult(orderNo.value)
payStatus.value = res.status === 'paid' ? 'success' : 'pending'
} catch {
//
payStatus.value = qsTradeStatus ? 'failed' : 'pending'
}
} else {
payStatus.value = 'pending'
}
}
function goHome() {
router.push('/')
}
function goProfile() {
router.push('/profile')
}
onMounted(() => {
verifyOrder()
})
</script>
<style scoped>
.payment-result-page {
display:flex; align-items:center; justify-content:center;
min-height:80vh; padding:20px; background:#f5f7fa;
}
.result-card {
width:460px; max-width:90vw; border-radius:16px;
padding:40px 32px 32px; text-align:center;
box-shadow:0 8px 30px rgba(0,0,0,.08);
}
.card-success { background:#fff; border-top:4px solid #52c41a; }
.card-failed { background:#fff; border-top:4px solid #ff4d4f; }
.card-pending { background:#fff; border-top:4px solid #1890ff; }
.result-icon { font-size:56px; margin-bottom:16px; }
.result-title { font-size:22px; font-weight:600; color:#333; margin:0 0 10px; }
.result-desc { font-size:14px; color:#888; margin:0 0 24px; line-height:1.6; }
.order-info {
text-align:left; background:#fafbfc; border-radius:10px;
padding:16px 20px; margin-bottom:24px;
}
.info-row {
display:flex; justify-content:space-between; align-items:center;
padding:8px 0; border-bottom:1px solid #f0f0f0;
}
.info-row:last-child { border-bottom:none; }
.info-label { color:#999; font-size:13px; }
.info-value { color:#333; font-size:13px; font-weight:500; }
.mono { font-family:'Courier New',Consolas,monospace; word-break:break-all; }
.amount { color:#ff4d4f; font-size:18px !important; font-weight:700; }
.result-actions {
display:flex; gap:12px; justify-content:center;
}
</style>

View File

@ -6,18 +6,20 @@
<div class="profile-header">
<div class="left-info">
<div class="avatar-box">
<el-avatar :size="80" :src="userInfo.avatar_url || undefined">
{{ userInfo.username?.charAt(0)?.toUpperCase() || 'U' }}
<el-avatar :size="80"
:src="userInfo.avater || userInfo.avatar || userInfo.avatar_url || userStore.avatarUrl || undefined">
{{ (userInfo.name || userInfo.username || userStore.username || 'U').charAt(0).toUpperCase() }}
</el-avatar>
</div>
<div class="info-content">
<div class="name-row">
<span class="name">{{ userInfo.username || '用户' }}</span>
<div v-if="checkUserVip(userInfo)" class="vip-tag">尊贵会员</div>
<span class="name">{{ userInfo.name || userInfo.username || userStore.username || '用户' }}</span>
<div v-if="userStore.isVip" class="vip-tag">尊贵会员</div>
<div v-else class="normal-tag">普通用户</div>
</div>
<div class="email">
<span v-if="checkUserVip(userInfo)">会员到期:{{ userInfo.vip_expire }}</span>
<span v-if="userStore.isVip">会员到期:{{ userInfo.vip_expire || userStore.membershipExpireAt || '生效中'
}}</span>
<span v-else>开通会员解锁全部功能</span>
</div>
</div>
@ -53,14 +55,14 @@
<div v-if="orders.length > 0" class="order-list">
<div v-for="o in orders.slice(0, 3)" :key="o.id" class="order-item">
<div class="order-left">
<span class="order-plan">{{ planLabel(o.plan_type) }}</span>
<span class="order-time">{{ formatDate(o.created_at) }}</span>
<span class="order-plan">{{ planLabel(o.goods_name) }}</span>
<span class="order-time">{{ formatDate(o.pay_time) }}</span>
</div>
<div class="order-right">
<span class="order-amount"><span style="font-size: 12px;">¥</span>{{ o.amount }}</span>
<el-tag :type="o.status === '已过期' ? 'info' : 'success'" size="small">
<span class="order-amount"><span style="font-size: 12px;">¥</span>{{ o.total_fee }}</span>
<!-- <el-tag :type="o.status === '已过期' ? 'info' : 'success'" size="small">
{{ statusLabel(o.status) }}
</el-tag>
</el-tag> -->
</div>
</div>
</div>
@ -83,14 +85,14 @@
<div 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.plan_type) }}</span>
<span class="order-time">{{ formatDate(o.created_at) }}</span>
<span class="order-plan">{{ planLabel(o.goods_name) }}</span>
<span class="order-time">{{ formatDate(o.pay_time) }}</span>
</div>
<div class="order-right">
<span class="order-amount"><span class="symbol">¥</span>{{ o.amount }}</span>
<el-tag :type="o.status === '已过期' ? 'info' : 'success'" size="small">
<span class="order-amount"><span class="symbol">¥</span>{{ o.total_fee }}</span>
<!-- <el-tag :type="o.status === '已过期' ? 'info' : 'success'" size="small">
{{ statusLabel(o.status) }}
</el-tag>
</el-tag> -->
</div>
</div>
</div>
@ -107,8 +109,9 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useUserStore } from '@/stores/user'
import { checkUserVip } from '@/utils/appUtils'
import BaseDialog from '@/components/BaseDialog.vue'
import WarningFooter from '@/components/WarningFooter.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import WarningFooter from '@/components/common/WarningFooter.vue'
import authService from '@/api/authService.js'
const router = useRouter()
const userStore = useUserStore()
@ -119,38 +122,7 @@ const userInfo = reactive({
membership_expire_at: null, created_at: ''
})
const defaultOrder = [
{
plan_type: '月卡会员',
created_at: '2026.6.1-2026.7.1',
amount: '19.90',
status: '进行中'
},
{
plan_type: '月卡会员',
created_at: '2026.1.1-2026.2.1',
amount: '19.90',
status: '已过期'
},
{
plan_type: '月卡会员',
created_at: '2026.1.1-2026.2.1',
amount: '19.90',
status: '已过期'
},
{
plan_type: '月卡会员',
created_at: '2026.1.1-2026.2.1',
amount: '19.90',
status: '已过期'
},
{
plan_type: '月卡会员',
created_at: '2026.1.1-2026.2.1',
amount: '19.90',
status: '已过期'
},
]
const defaultOrder = []
const isVip = ref(false)
const dialogVisible = ref(false)
const worksCount = ref(1)
@ -158,26 +130,35 @@ const ordersCount = ref(0)
const orders = ref(defaultOrder)
const productMap = ref({}) // productId -> productName
onMounted(() => {
// userStore /
if (userStore.username) {
userInfo.username = userStore.username
userInfo.avatar_url = userStore.avatarUrl || ''
isVip.value = userStore.isVip
// email/created_at
api.auth.profileSilent().then(res => {
Object.assign(userInfo, res)
isVip.value = !!res.is_vip
}).catch(() => { })
} else {
api.auth.profileSilent().then(res => {
Object.assign(userInfo, res)
isVip.value = !!res.is_vip
}).catch(() => { })
onMounted(async () => {
try {
const data = await userStore.fetchProfile()
if (data) {
Object.assign(userInfo, data)
}
loadStats()
} catch (e) { }
// userInfo
try {
const local = JSON.parse(localStorage.getItem('user_info') || '{}')
Object.assign(userInfo, local)
} catch (e) { }
loadStats();
getOrders();
})
async function getOrders() {
try {
const res = await authService.getOrder({ status: 2 })
if (res.code == 0 && res.data) {
console.log("订单列表", res.data)
orders.value = res.data
ordersCount.value = res.data.length
}
} catch (error) { }
}
async function loadStats() {
// try {
// const [wRes, oRes, pRes] = await Promise.all([

View File

@ -1,168 +0,0 @@
<template>
<div class="auth-page">
<div class="auth-card">
<h2>注册新账号</h2>
<el-form :model="form" :rules="rules" ref="formRef" @keyup.enter="handleRegister">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名3-20位字母数字下划线" prefix-icon="User" size="large" />
</el-form-item>
<el-form-item prop="email">
<el-input v-model="form.email" placeholder="邮箱" prefix-icon="Message" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码至少8位" prefix-icon="Lock" size="large" show-password @input="checkStrength" />
<div v-if="form.password" class="pwd-strength">
<span :class="['strength-bar', strengthLevel]"></span>
<span class="strength-label">{{ strengthLabel }}</span>
</div>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" placeholder="确认密码" prefix-icon="Lock" size="large" show-password />
</el-form-item>
<el-form-item prop="captcha">
<div style="display:flex;gap:10px;width:100%">
<el-input v-model="form.captcha" placeholder="验证码(不区分大小写)" size="large" style="flex:1" />
<div class="captcha-img" @click="loadCaptcha" title="点击刷新">
<img v-if="captchaData.image" :src="captchaData.image" alt="" />
<span v-else-if="captchaData.text_mode" class="captcha-text">点击刷新</span>
<span v-else class="captcha-text loading-text">加载中...</span>
</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="loading" style="width:100%" @click="handleRegister"> </el-button>
</el-form-item>
</el-form>
<p class="switch-text">已有账号<router-link to="/login">去登录</router-link></p>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import api from '@/api'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref()
const loading = ref(false)
const captchaKey = ref('')
const captchaData = ref({ image: '', text_mode: false })
const form = reactive({ username: '', email: '', password: '', confirmPassword: '', captcha: '' })
const pwdScore = ref(0)
function checkStrength() {
const p = form.password
let s = 0
if (p.length >= 8) s++
if (/[a-z]/.test(p)) s++
if (/[A-Z]/.test(p)) s++
if (/\d/.test(p)) s++
if (/[^a-zA-Z\d]/.test(p)) s++
//
if (p.length >= 12) s++
pwdScore.value = s
}
const strengthLevel = computed(() => {
if (pwdScore.value <= 2) return 'weak'
if (pwdScore.value <= 4) return 'medium'
return 'strong'
})
const strengthLabel = computed(() => {
const map = { weak: '弱', medium: '中', strong: '强' }
return `密码强度:${map[strengthLevel.value]}` || ''
})
const validateConfirm = (rule, value, cb) => {
if (value !== form.password) cb(new Error('两次密码不一致'))
else cb()
}
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '3-20位字符', trigger: 'blur' },
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, message: '至少8位', trigger: 'blur' },
],
confirmPassword: [{ validator: validateConfirm, trigger: 'blur' }],
captcha: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
}
onMounted(loadCaptcha)
async function loadCaptcha() {
try {
const data = await api.auth.captcha()
captchaData.value = data
captchaKey.value = data.captcha_key || ''
} catch { ElMessage.warning('验证码加载失败,请点击刷新') }
}
async function handleRegister() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const payload = { ...form, captcha_key: captchaKey.value }
delete payload.confirmPassword
const res = await api.auth.register(payload)
localStorage.setItem('token', res.token)
userStore.username = res.user.username
userStore.isVip = res.user.is_vip
userStore.tokenChecked = true
userStore.tokenValid = true
ElMessage.success('注册成功')
router.push('/works')
} catch (err) {
console.error('注册失败:', err)
loadCaptcha()
form.captcha = ''
} finally { loading.value = false }
}
</script>
<style lang="scss" scoped>
.auth-page {
min-height: calc(100vh - 120px);
display: flex; align-items: center; justify-content: center;
padding: 40px 20px;
}
.auth-card {
width: 440px; padding: 36px; border-radius: 12px;
background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,.08);
h2 { text-align: center; margin-bottom: 24px; font-size: 22px; }
.captcha-img {
width: 120px; height: 40px; border: 1px solid #dcdfe6; border-radius: 4px;
cursor: pointer; overflow: hidden; display: flex; align-items: center; justify-content: center;
img { width: 100%; height: 100%; object-fit: cover; }
.captcha-text { font-family: monospace; font-size: 18px; letter-spacing: 6px; font-weight: bold; color: #333; }
.loading-text { font-size: 12px; letter-spacing: normal; color: #999; }
}
.switch-text { text-align: center; color: #999; font-size: 13px; a { color: #07c160; } }
/* 密码强度条 */
.pwd-strength {
display: flex; align-items: center; gap: 8px; margin-top: 6px;
.strength-bar {
width: 80px; height: 4px; border-radius: 2px; background: #e0e0e0; transition: background .3s;
&.weak { background: #f56c6c; }
&.medium { background: #e6a23c; }
&.strong { background: #67c23a; }
}
.strength-label { font-size: 12px; }
.weak + .strength-label { color: #f56c6c; }
.medium + .strength-label { color: #e6a23c; }
.strong + .strength-label { color: #67c23a; }
}
}
</style>

View File

@ -1,139 +0,0 @@
<template>
<div class="works-page">
<div class="container">
<h2>我的作品</h2>
<div class="toolbar">
<el-input v-model="keyword" placeholder="搜索作品标题..." prefix-icon="Search" clearable style="width:260px" />
<el-radio-group v-model="filterType" size="small">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button value="chat">聊天</el-radio-button>
<el-radio-button value="balance">余额</el-radio-button>
</el-radio-group>
</div>
<div class="work-list" v-loading="loading">
<div v-for="work in worksList" :key="work.id" class="work-card">
<div class="card-thumb" :class="work.type">
<span>{{ work.type === 'chat' ? '💬' : '💰' }}</span>
</div>
<div class="card-info">
<h4>{{ work.title }}</h4>
<p class="meta">{{ work.type === 'chat' ? '聊天记录' : '余额截图' }} · {{ formatDate(work.updated_at) }}</p>
</div>
<div class="card-actions">
<el-button size="small" @click="renameWork(work)">改名</el-button>
<el-button size="small" type="primary" plain @click="editWork(work)">编辑</el-button>
<el-button size="small" type="danger" plain @click="deleteWork(work)">删除</el-button>
</div>
</div>
<el-empty v-if="!loading && !worksList.length" description="暂无作品" />
</div>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
background
layout="total, prev, pager, next"
:total="total"
:page-size="10"
:current-page="currentPage"
@current-change="onPageChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
const router = useRouter()
const loading = ref(false)
const keyword = ref('')
const filterType = ref('')
const worksList = ref([])
const currentPage = ref(1)
const total = ref(0)
onMounted(loadWorks)
watch([keyword, filterType], () => { currentPage.value = 1; loadWorks() })
async function loadWorks() {
loading.value = true
try {
const res = await api.works.list({
keyword: keyword.value,
type: filterType.value || undefined,
page: currentPage.value,
per_page: 10,
})
worksList.value = res.items
total.value = res.total
} finally { loading.value = false }
}
function onPageChange(page) {
currentPage.value = page
loadWorks()
}
function editWork(work) {
if (work.type === 'chat') router.push(`/chat?workId=${work.id}`)
else router.push(`/balance?workId=${work.id}`)
}
async function deleteWork(work) {
try { await ElMessageBox.confirm(`确定删除「${work.title}」?`, '确认', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }) }
catch { return }
await api.works.delete(work.id)
ElMessage.success('已删除')
loadWorks()
}
async function renameWork(work) {
try {
const { value: newTitle } = await ElMessageBox.prompt('请输入新名称', '修改名称', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: work.title,
inputPlaceholder: '输入新作品名称',
})
if (!newTitle || newTitle === work.title) return
await api.works.update(work.id, { title: newTitle })
ElMessage.success('已修改')
loadWorks()
} catch {}
}
function formatDate(iso) { return iso?.slice(0, 16).replace('T', ' ') || '' }
</script>
<style lang="scss" scoped>
.works-page { min-height: calc(100vh - 120px); padding: 30px 20px; }
.container { max-width: 900px; margin: 0 auto; h2 { margin-bottom: 20px; } }
.toolbar { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
.work-list { display: flex; flex-direction: column; gap: 10px; }
.work-card {
display: flex; align-items: center; gap: 16px;
padding: 16px 20px; background: #fff; border-radius: 10px;
box-shadow: 0 1px 6px rgba(0,0,0,.04); transition: box-shadow .2s;
&:hover { box-shadow: 0 3px 12px rgba(0,0,0,.08); }
.card-thumb {
width: 48px; height: 48px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; font-size: 22px;
&.chat { background: #e8f5e9; }
&.balance { background: #fff8e1; }
}
.card-info { flex: 1; h4 { font-size: 15px; margin-bottom: 2px; } .meta { font-size: 12px; color: #999; } }
.card-actions { display: flex; gap: 6px; }
}
.pagination-wrap {
display: flex; justify-content: center;
padding: 20px 0 10px;
}
</style>

View File

@ -0,0 +1,653 @@
<template>
<div class="article-detail-wrapper">
<div class="main-container">
<!-- 左侧核心内容区 -->
<div class="article-left-content">
<!-- 顶部返回按钮 -->
<div class="back-btn-box">
<div class="back-btn" @click="handleBack">返回列表</div>
</div>
<!-- 主文章卡片 -->
<div class="article-main-card">
<!-- 标题 -->
<h1 class="article-title">{{ currentArticle.title }}</h1>
<!-- 发布日期与浏览量 -->
<div class="article-meta">
<div class="meta-item">
<img class="meta-icon" src="@/public/article/date.png" alt="日期">
<span>{{ currentArticle.date }}</span>
</div>
<div class="meta-item">
<img class="meta-icon" src="@/public/article/eye.png" alt="浏览数">
<span>{{ currentArticle.views }}</span>
</div>
</div>
<!-- 文章导读区 -->
<div class="article-intro-box">
<div class="intro-header">
<img class="star-icon" src="@/public/article/start.png" />
<span>文章导读</span>
</div>
<p class="intro-content">{{ currentArticle.intro || currentArticle.description }}</p>
</div>
<!-- 正文区块 -->
<div class="article-body-content">
<template v-if="currentArticle.sections && currentArticle.sections.length">
<div v-for="(sec, idx) in currentArticle.sections" :key="idx" class="section-block">
<h3 class="section-title">{{ sec.heading }}</h3>
<p class="section-text">{{ sec.content }}</p>
</div>
</template>
<template v-else>
<div class="section-block">
<h3 class="section-title">功能介绍</h3>
<p class="section-text">
我们不做假图我们做的是比真图更真的模拟器无论是你需要一张转账截图一套朋友圈互动记录还是一整份零钱账单流水这款模拟器都能帮你瞬间完成
</p>
</div>
</template>
<!-- 文章插图 -->
<div class="article-image-box">
<img v-if="currentArticle.image" :src="currentArticle.image" alt="文章插图"
class="article-img" />
<img v-else src="https://picsum.photos/800/320?random=1" alt="演示图" class="article-img" />
</div>
</div>
<!-- 上一篇 / 下一篇 导航卡片 -->
<div class="article-nav-section">
<div class="nav-card prev-card" @click="handleSwitchArticle(prevArticle)">
<div class="nav-arrow left-arrow">
<img style="width:100%;height:100%;" src="@/public/article/left.png" alt="">
</div>
<div class="nav-info">
<span class="nav-label">上一篇</span>
<span class="nav-title" :title="prevArticle?.title">{{ prevArticle?.title || '没有上一篇了'
}}</span>
</div>
</div>
<div class="nav-card next-card" @click="handleSwitchArticle(nextArticle)">
<div class="nav-info align-right">
<span class="nav-label">下一篇</span>
<span class="nav-title" :title="nextArticle?.title">{{ nextArticle?.title || '没有下一篇了'
}}</span>
</div>
<div class="nav-arrow right-arrow">
<img style="width:100%;height:100%;" src="@/public/article/right.png" alt="">
</div>
</div>
</div>
</div>
</div>
<!-- 右侧相关推荐侧边栏 -->
<div class="recommend-sidebar">
<div class="sidebar-card">
<div class="sidebar-header">
<img src="@/public/article/tuijian.png" class="recommend-icon" />
<span class="sidebar-title">相关推荐</span>
</div>
<div class="recommend-list">
<div v-for="(rec, index) in recommendList" :key="rec.id" class="recommend-item"
@click="handleSwitchArticle(rec)">
<div class="rec-rank" :class="{ 'top-rank': index < 3, 'four-rank': index == 3 }">{{ index +
1
}}</div>
<div class="rec-content">
<h4 class="rec-title" :title="rec.title">{{ rec.title }}</h4>
<div class="rec-meta">
<div class="meta-sub">
<img class="meta-sub-icon" src="@/public/article/date.png" alt="日期">
<span>{{ rec.date }}</span>
</div>
<div class="meta-sub">
<img class="meta-sub-icon" src="@/public/article/eye.png" alt="浏览">
<span>{{ rec.views }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 底部警告组件 -->
<WarningFooter />
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import WarningFooter from '@/components/common/WarningFooter.vue'
const router = useRouter()
const route = useRoute()
// ID
const currentId = ref(Number(route.query.id) || 1)
watch(() => route.query.id, (newId) => {
if (newId) {
currentId.value = Number(newId)
}
})
// Mock
const articleDatabase = ref([
{
id: 1,
title: '微商模拟神器-节假日活动必备玩法',
date: '2026-7-20',
views: '11.5K',
intro: '我们不做“假图”,我们做的是“比真图更真的模拟器”。无论是你需要一张转账截图、一套朋友圈互动记录,还是一整份零钱账单流水,这款模拟器都能帮你瞬间完成。',
sections: [
{
heading: '一、功能介绍',
content: '我们不做“假图”,我们做的是“比真图更真的模拟器”。无论是你需要一张转账截图、一套朋友圈互动记录,还是一整份零钱账单流水,这款模拟器都能帮你瞬间完成。'
}
],
image: 'https://picsum.photos/800/320?random=10'
},
{
id: 2,
title: '微商模拟神器-节假日活动必备玩法超超超推荐',
date: '2026-7-20',
views: '11.5K',
intro: '全网首创的高逼真恶搞模拟工具,支持自定义头像、对话内容、账单金额与交易状态,各种场景真实不穿帮。',
sections: [
{
heading: '一、核心亮点',
content: '一键生成高清对话截图,像素级细节还原,支持无限修改,帮助你轻松制作搞笑段子与社交互动图。'
}
],
image: 'https://picsum.photos/800/320?random=11'
},
{
id: 3,
title: '微商模拟神器-节假日活动必备玩法超超超推荐HHHHH...',
date: '2026-7-20',
views: '11.5K',
intro: '无论是发朋友圈互动还是朋友间轻松恶搞,装样大师都能提供全套的模拟支持。',
sections: [
{
heading: '一、快速上手',
content: '无需复杂的操作步骤,选择模板填入文字即可一键导出无水印图片。'
}
],
image: 'https://picsum.photos/800/320?random=12'
},
{
id: 4,
title: '微信神器转账支付怎么制作?装样大师新手教程',
date: '2026-7-20',
views: '11.5K',
intro: '新手必看!如何三步快速生成高清逼真的转账与零钱余额截图。',
sections: [
{
heading: '一、教程步骤',
content: '打开微信模拟功能,输入期望金额与收付款人信息,点击保存按钮导出图片。'
}
],
image: 'https://picsum.photos/800/320?random=13'
},
{
id: 5,
title: '工资单与机票模拟生成器全面上线说明',
date: '2026-7-20',
views: '11.5K',
intro: '全新升级娱乐模拟玩法,支持模拟机票、高铁票及个人工资单记录。',
sections: [
{
heading: '一、更新公告',
content: '优化底纹防伪效果,提升了图片生成的画质与加载速度。'
}
],
image: 'https://picsum.photos/800/320?random=14'
}
])
// ID
const currentArticle = computed(() => {
return articleDatabase.value.find(item => item.id === currentId.value) || articleDatabase.value[0]
})
//
const currentIndex = computed(() => {
return articleDatabase.value.findIndex(item => item.id === currentArticle.value.id)
})
const prevArticle = computed(() => {
if (currentIndex.value > 0) {
return articleDatabase.value[currentIndex.value - 1]
}
return null
})
const nextArticle = computed(() => {
if (currentIndex.value < articleDatabase.value.length - 1) {
return articleDatabase.value[currentIndex.value + 1]
}
return null
})
// 5
const recommendList = computed(() => articleDatabase.value)
function handleBack() {
router.push('/article')
}
function handleSwitchArticle(articleItem) {
if (!articleItem) return
router.push({
path: '/article-detail',
query: { id: articleItem.id }
})
}
</script>
<style lang="scss" scoped>
.article-detail-wrapper {
position: relative;
min-height: calc(100vh - 112px);
background-color: #F8F9FB;
padding: 36px 20px 80px;
box-sizing: border-box;
}
.main-container {
width: 800px;
position: relative;
margin: 0 auto;
}
/* 左侧主体内容:相对于整个页面水平居中 */
.article-left-content {
width: 800px;
}
/* 右侧侧边栏:固定在居中主卡片的右侧,且不跟随页面滚动 */
.recommend-sidebar {
width: 310px;
position: fixed;
top: 201px;
left: calc(50% + 424px);
/* 正好定位在 800px 居中卡片右侧 24px 处 */
z-index: 10;
}
@media (max-width: 1480px) {
.recommend-sidebar {
left: auto;
right: 20px;
}
}
@media (max-width: 1180px) {
.recommend-sidebar {
display: none;
/* 屏幕过窄时隐藏侧边栏避免挡住主卡片 */
}
}
/* 返回按钮 */
.back-btn-box {
margin-bottom: 18px;
}
.back-btn {
width: 116px;
height: 36px;
background-image: url('/src/public/article/back-bg.png');
background-size: 100% 100%;
border: none;
cursor: pointer;
transition: all 0.2s ease;
text-align: right;
padding-right: 18px;
font-weight: 500;
font-size: 16px;
color: #FFFFFF;
line-height: 36px;
&:hover {
opacity: 0.92;
transform: translateY(-1px);
}
}
/* 主卡片 */
.article-main-card {
background: #FFFFFF;
border-radius: 16px;
padding: 34px 30px 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
}
.article-title {
margin: 0 0 18px 0;
font-weight: 700;
font-size: 18px;
color: #1A1A1A;
line-height: 20px;
}
.article-meta {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 16px;
.meta-item {
height: 20px;
display: flex;
align-items: center;
gap: 6px;
font-size: 18px;
color: #AAAAAA;
line-height: 18px;
text-align: left;
.meta-icon {
width: 20px;
height: 20px;
}
}
}
/* 导读区 */
.article-intro-box {
background-color: #F8F6FF;
border-radius: 12px;
padding: 16px 16px 12px;
margin-bottom: 12px;
.intro-header {
display: flex;
align-items: center;
font-size: 16px;
color: #965DF8;
line-height: 16px;
font-weight: 700;
margin-bottom: 6px;
.star-icon {
width: 16px;
height: 16px;
margin-right: 6px;
}
}
.intro-content {
margin: 0;
font-size: 16px;
color: #767676;
line-height: 26px;
}
}
/* 正文 */
.article-body-content {
.section-block {
margin-bottom: 30px;
}
.section-title {
font-size: 16px;
line-height: 26px;
font-weight: 700;
color: #1A1A1A;
}
.section-text {
font-size: 16px;
color: #1A1A1A;
line-height: 26px;
}
.article-image-box {
width: 100%;
height: 320px;
border-radius: 4px;
overflow: hidden;
margin-bottom: 30px;
.article-img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
/* 上一篇/下一篇 导航卡片 */
.article-nav-section {
display: flex;
gap: 20px;
margin-top: 32px;
.nav-card {
flex: 1;
flex-shrink: 0;
display: flex;
align-items: center;
padding: 8px 16px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
box-sizing: border-box;
background: #FFFFFF;
border: 1px solid #EAEAEA;
&.prev-card {
&:hover {
background: #F6F3FD;
}
}
&.next-card {
&:hover {
background: #F0EBFF;
}
}
.nav-arrow {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(139, 112, 255, 0.12);
color: #8B70FF;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
}
}
.nav-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
padding: 0 16px;
&.align-right {
text-align: right;
}
.nav-label {
font-size: 16px;
font-weight: 700;
color: #1A1A1A;
}
.nav-title {
font-size: 12px;
color: #767676;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
/* 右侧侧边栏 */
.recommend-sidebar {
width: 230px;
flex-shrink: 0;
}
.sidebar-card {
background: #FFFFFF;
border-radius: 18px;
padding: 0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
}
.sidebar-header {
position: relative;
height: 46px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
color: #8B70FF;
&::after {
content: '';
position: absolute;
width: 100%;
left: 0;
bottom: 0;
height: 1px;
background-color: #D8D8D8;
transform: scaleY(0.5);
}
.recommend-icon {
width: 22px;
height: 22px;
}
.sidebar-title {
font-weight: 700;
font-size: 16px;
color: #1A1A1A;
line-height: 16px;
}
}
.recommend-list {
display: flex;
flex-direction: column;
}
.recommend-item {
height: 86px;
padding-left: 18px;
display: flex;
gap: 8px;
align-items: center;
cursor: pointer;
&:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 0;
}
&:hover {
.rec-title {
color: #8B70FF;
}
}
.rec-rank {
font-size: 18px;
font-weight: 700;
color: #D8D8D8;
width: 18px;
&.top-rank {
color: #965DF8;
}
&.four-rank {
color: #AAAAAA;
}
}
.rec-content {
display: flex;
justify-content: center;
height: 100%;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
border-bottom: 1px solid #F0F0F0;
padding-right: 10px;
gap: 12px;
}
.rec-title {
margin: 0;
font-size: 14px;
font-weight: 500;
color: #1A1A1A;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.2s;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.rec-meta {
display: flex;
align-items: center;
gap: 12px;
.meta-sub {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #AAAAAA;
.meta-sub-icon {
width: 12px;
height: 12px;
}
}
}
}
@media (max-width: 960px) {
.main-container {
flex-direction: column;
}
.recommend-sidebar {
width: 100%;
}
}
</style>

310
src/views/article/list.vue Normal file
View File

@ -0,0 +1,310 @@
<template>
<div class="article-page-wrapper">
<div class="article-list-container">
<div v-for="item in articleList" :key="item.id" class="article-card" @click="handleCardClick(item)">
<!-- 卡片头部信息 -->
<div class="card-header">
<div class="header-left">
<h2 class="article-title" :title="item.title">{{ item.title }}</h2>
<div v-if="item.isHot" class="hot-badge">
<img class="hot-icon" src="@/public/article/hot.png" alt="">
</div>
</div>
<div class="header-meta">
<div class="meta-item">
<img class="meta-icon" src="@/public/article/date.png" alt="">
<span>{{ item.date }}</span>
</div>
<div class="meta-item">
<img class="meta-icon" src="@/public/article/eye.png" alt="">
<span>{{ item.views }}</span>
</div>
</div>
</div>
<!-- 卡片主体内容 -->
<div class="card-body">
<div class="cover-thumb">
<img v-if="item.cover" :src="item.cover" :alt="item.title" class="cover-img" />
<div v-else class="cover-placeholder"></div>
</div>
<div class="content-right">
<p class="article-desc">{{ item.description }}</p>
<div class="action-box">
<button class="detail-btn" @click.stop="handleDetail(item)">查看详情</button>
</div>
</div>
</div>
</div>
</div>
<!-- 底部警告说明组件 -->
<WarningFooter />
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import WarningFooter from '@/components/common/WarningFooter.vue'
const router = useRouter()
//
const articleList = ref([
{
id: 1,
title: '微商模拟神器',
isHot: true,
date: '2026-7-20',
views: '11.5K',
cover: '',
description: '我们不做“假图”,我们做的是“比真图更真的模拟器”。无论是你需要一张转账截图、一套朋友圈互动记录,还是一整份零钱账单流水,这款模拟器都能帮你瞬间完成。我们不做“假图”,我们做的是“比真图更真的模拟器”。无论是你需要一张转账截图、一套朋友圈互动记录,还是一整份零钱账单流水,这款模拟器都能帮你瞬间完成。'
},
{
id: 2,
title: '微商模拟神器哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈好...',
isHot: true,
date: '2026-7-20',
views: '11.5K',
cover: '',
description: '我们不做“假图”,我们做的是“比真图更真的模拟器”。无论是你需要一张转账截图、一套朋友圈互动记录,还是一整份零钱账单流水,这款模拟器都能帮你瞬间完成。哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈...'
},
{
id: 1,
title: '微商模拟神器',
isHot: true,
date: '2026-7-20',
views: '11.5K',
cover: '',
description: '我们不做“假图”,我们做的是“比真图更真的模拟器”。无论是你需要一张转账截图、一套朋友圈互动记录,还是一整份零钱账单流水,这款模拟器都能帮你瞬间完成。'
},
{
id: 2,
title: '微商模拟神器哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈好哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈',
isHot: true,
date: '2026-7-20',
views: '11.5K',
cover: '',
description: '我们不做“假图”,我们做的是“比真图更真的模拟器”。无论是你需要一张转账截图、一套朋友圈互动记录,还是一整份零钱账单流水,这款模拟器都能帮你瞬间完成。哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈...'
}
])
function handleDetail(item) {
router.push({
path: '/article-detail',
query: { id: item.id }
})
}
function handleCardClick(item) {
handleDetail(item)
}
</script>
<style lang="scss" scoped>
.article-page-wrapper {
min-height: calc(100vh - 112px);
background-color: #F8F9FB;
padding: 40px 20px 80px;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
.article-list-container {
width: 100%;
max-width: 820px;
display: flex;
flex-direction: column;
gap: 30px;
}
.article-card {
width: 800px;
background: #FFFFFF;
border-radius: 16px;
padding: 34px;
cursor: pointer;
box-sizing: border-box;
box-shadow: 0px 0px 12px 0px rgba(0, 0, 0, 0.1);
&:hover {
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.07);
transform: scale(1.005);
}
}
/* 卡片头部样式 */
.card-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 20px;
gap: 16px;
margin-bottom: 20px;
&::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 1px;
background-color: #D8D8D8;
transform: scaleY(0.5);
}
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
/* 允许标题自适应缩减触发 ellipsis */
}
.article-title {
max-width: 468px;
margin: 0;
font-weight: 700;
font-size: 18px;
color: #1A1A1A;
line-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* HOT 标签样式 */
.hot-badge {
width: 41px;
height: 18px;
img {
width: 100%;
height: 100%;
}
}
.header-meta {
display: flex;
align-items: center;
gap: 16px;
color: #999999;
font-size: 14px;
flex-shrink: 0;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 18px;
color: #AAAAAA;
line-height: 18px;
.meta-icon {
width: 20px;
height: 20px;
}
}
/* 卡片主体内容布局 */
.card-body {
display: flex;
gap: 28px;
align-items: flex-start;
}
.cover-thumb {
width: 170px;
height: 125px;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
width: 100%;
height: 100%;
background-color: #D8D8D8;
}
}
.content-right {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
min-height: 125px;
}
.article-desc {
margin: 0;
font-weight: 400;
font-size: 16px;
color: #767676;
line-height: 26px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.action-box {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.detail-btn {
width: 100px;
height: 36px;
background: linear-gradient(215deg, #A679F4 7.14%, #778CF5 100%);
color: #FFFFFF;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(120, 84, 255, 0.25);
&:hover {
opacity: 0.92;
box-shadow: 0 6px 16px rgba(120, 84, 255, 0.35);
}
&:active {
transform: translateY(0);
}
}
@media (max-width: 640px) {
.card-body {
flex-direction: column;
}
.cover-thumb {
width: 100%;
height: 160px;
}
.content-right {
min-height: auto;
}
}
</style>

View File

@ -55,7 +55,7 @@
</template>
<script setup>
import AppFooter from '@/components/AppFooter.vue'
import AppFooter from '@/components/common/AppFooter.vue'
import ToolCard from '@/components/ToolCard.vue'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()

View File

@ -103,7 +103,7 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import WarningFooter from '@/components/WarningFooter.vue'
import WarningFooter from '@/components/common/WarningFooter.vue'
import { all } from 'axios'
import { dayOrDaysToDate, ElMessage } from 'element-plus'
import html2canvas from 'html2canvas'

View File

@ -53,7 +53,7 @@
<script setup>
import { ref } from 'vue'
import { Search, Close, EditPen, Download } from '@element-plus/icons-vue'
import WarningFooter from '@/components/WarningFooter.vue'
import WarningFooter from '@/components/common/WarningFooter.vue'
const searchQuery = ref('')
const tabs = ['全部', '微信模拟', '娱乐模拟']

View File

@ -112,6 +112,7 @@ import SimulatorLayout from '@/components/simulator/SimulatorLayout.vue'
import api from '@/api'
import { useUserStore } from '@/stores/user'
import html2canvas from 'html2canvas'
import { checkUserVip } from '@/utils/appUtils'
const userStore = useUserStore()
const showPayment = inject('showPayment')
@ -122,14 +123,6 @@ const layoutRef = ref()
const previewAreaRef = computed(() => layoutRef.value?.previewAreaRef)
watch(() => userStore.isVip, (isVip) => {
if (isVip) {
removeWatermark()
} else {
createWatermark()
}
})
const balanceSettings = reactive({
statusTime: new Date().toTimeString().slice(0, 5),
phoneModel: 'ios',
@ -157,10 +150,6 @@ const workId = ref(null)
const originalTitle = ref('')
onMounted(async () => {
if (!userStore.isVip) {
createWatermark()
}
const id = route.query.workId
if (!id) return
try {
@ -212,13 +201,6 @@ async function handleSave() {
async function handleExport() {
if (!localStorage.getItem('token')) { ElMessage.warning('请先登录后再下载'); return }
if (!userStore.isVip) {
try {
await ElMessageBox.confirm(
'下载高清截图需要VIP会员权限是否前往开通',
'会员限制',
{ confirmButtonText: '去开通', cancelButtonText: '取消', type: 'warning' }
)
} catch { return }
showPayment?.()
return
}

View File

@ -427,6 +427,7 @@ import SimulatorLayout from '@/components/simulator/SimulatorLayout.vue'
import api from '@/api'
import { useUserStore } from '@/stores/user'
import html2canvas from 'html2canvas'
import { checkUserVip } from '@/utils/appUtils'
const userStore = useUserStore()
const showPayment = inject('showPayment')
@ -436,14 +437,6 @@ const layoutRef = ref()
const previewRef = ref()
const previewAreaRef = computed(() => layoutRef.value?.previewAreaRef)
watch(() => userStore.isVip, (isVip) => {
if (isVip) {
removeWatermark()
} else {
createWatermark()
}
})
const tabs = ref([
{ label: '聊天设置', value: 'edit' },
{ label: '外观设置', value: 'appearance' }
@ -538,10 +531,6 @@ function handleSystemQuickAdd(command) {
}
onMounted(async () => {
if (!userStore.isVip) {
createWatermark()
}
const id = route.query.workId
if (!id) return
try {
@ -760,7 +749,6 @@ async function handleSave() {
async function handleExport() {
if (!localStorage.getItem('token')) { ElMessage.warning('请先登录后再下载'); return }
if (!userStore.isVip) {
try { await ElMessageBox.confirm('下载高清截图需要VIP会员权限是否前往开通', '会员限制', { confirmButtonText: '去开通', cancelButtonText: '取消', type: 'warning' }) } catch { return }
showPayment?.()
return
}