Merge branch 'main' of http://18.167.251.121:10003/aidlab/Aida_Purchaser_Front
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
VITE_APP_URL = http://192.168.31.82:10094
|
||||
# VITE_APP_URL = http://192.168.31.82:10094
|
||||
VITE_APP_URL = https://www.develop-ms.api.aida.com.hk
|
||||
|
||||
# WebSocket 主机地址
|
||||
VITE_WS_HOST = 18.167.251.121:10094
|
||||
@@ -1,2 +1,4 @@
|
||||
VITE_APP_URL = http://192.168.31.82:10094
|
||||
VITE_APP_URL = https://www.develop-ms.api.aida.com.hk
|
||||
# WebSocket 主机地址
|
||||
VITE_WS_HOST = www.develop-ms.api.aida.com.hk
|
||||
36
src/App.vue
36
src/App.vue
@@ -7,13 +7,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, onBeforeUnmount } from 'vue'
|
||||
import { computed, onMounted, ref, onBeforeUnmount, watch } from 'vue'
|
||||
import RouteCache from '@/components/RouteCache.vue'
|
||||
import MainHeader from '@/views/main-header.vue'
|
||||
import LoginDialog from '@/views/login/login-dialog.vue'
|
||||
import { useGlobalStore } from '@/stores'
|
||||
import { useGlobalStore, useUserInfoStore } from '@/stores'
|
||||
import ShoppingDrawer from '@/views/shopping-drawer.vue'
|
||||
import { wsManager } from '@/utils/websocket'
|
||||
|
||||
const globalStore = useGlobalStore()
|
||||
const userInfoStore = useUserInfoStore()
|
||||
const loading = computed(() => globalStore.state.loading)
|
||||
globalStore.setLoading(false)
|
||||
const viewRef = ref()
|
||||
@@ -26,12 +29,41 @@
|
||||
viewStyle.value['--app-view-width'] = width + 'px'
|
||||
viewStyle.value['--app-view-height'] = height + 'px'
|
||||
})
|
||||
|
||||
// 监听 token 变化,建立或关闭 WebSocket 连接
|
||||
watch(
|
||||
() => userInfoStore.state.token,
|
||||
(newToken, oldToken) => {
|
||||
if (newToken && newToken !== oldToken) {
|
||||
// 用户登录,建立 WebSocket 连接
|
||||
console.log('用户已登录,建立 WebSocket 连接')
|
||||
wsManager.connect(newToken)
|
||||
} else if (!newToken && oldToken) {
|
||||
// 用户退出登录,关闭 WebSocket 连接
|
||||
console.log('用户已退出,关闭 WebSocket 连接')
|
||||
wsManager.close()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
observer.observe(viewRef.value)
|
||||
|
||||
// 如果已经有 token,立即建立连接
|
||||
const token = userInfoStore.state.token
|
||||
if (token) {
|
||||
console.log('应用启动时检测到 token,建立 WebSocket 连接')
|
||||
wsManager.connect(token)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer.disconnect()
|
||||
// 组件卸载时关闭 WebSocket 连接
|
||||
wsManager.close()
|
||||
})
|
||||
|
||||
window['onClickPrivacy'] = () => {
|
||||
const e = window.event || event
|
||||
e.stopPropagation()
|
||||
|
||||
40
src/api/notification.ts
Normal file
40
src/api/notification.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
interface Page {
|
||||
page: number
|
||||
size: number
|
||||
type?: number
|
||||
isRead?: 0 | 1 // 0未读1已读
|
||||
keyword?: string // 关键词搜索标题Ï
|
||||
}
|
||||
export const fetchAllMessageList = (data) => {
|
||||
return request({
|
||||
url: '/buyer/buyer/message/page',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 获取所有未读消息数量
|
||||
export const fetchAllUnreadMessage = () => {
|
||||
return request({
|
||||
url: '/buyer/buyer/message/unread-count',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 标记单条消息已读
|
||||
export const markMessageAsRead = (id: number | string) => {
|
||||
return request({
|
||||
url: `/buyer/buyer/message/${id}/read`,
|
||||
method: 'put'
|
||||
})
|
||||
}
|
||||
|
||||
// 标记全部消息已读
|
||||
export const markAllMessagesAsRead = () => {
|
||||
return request({
|
||||
url: '/buyer/buyer/message/read-all',
|
||||
method: 'put'
|
||||
})
|
||||
}
|
||||
@@ -22,7 +22,6 @@ const checkList = computed(()=>{
|
||||
})
|
||||
const handleChange = (val) => {
|
||||
let data = val.filter(item => item !== 'all')
|
||||
if(val.length == 0)return
|
||||
if(data.length == props.list.length){
|
||||
data = ['all']
|
||||
}else{
|
||||
@@ -39,9 +38,11 @@ const handleCheckAllChange = (val) => {
|
||||
if(val && props.selected[0] !== 'all'){
|
||||
data = ['all']
|
||||
// data = props.list.map(item => item.value)
|
||||
emit('update:selected', data)
|
||||
emit('change', data)
|
||||
}else{
|
||||
data = []
|
||||
}
|
||||
emit('update:selected', data)
|
||||
emit('change', data)
|
||||
}
|
||||
let data = reactive({
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserInfoStore } from '@/stores/userInfo'
|
||||
import { useGlobalStore } from '@/stores/global'
|
||||
import { getUserLanguage } from '@/api/user'
|
||||
import { fetchAllUnreadMessage } from '@/api/notification'
|
||||
import i18n from '@/lang/index'
|
||||
|
||||
// 语言映射:后端格式 -> i18n 格式
|
||||
const backendToI18nLanguage: Record<string, string> = {
|
||||
'en': 'ENGLISH',
|
||||
en: 'ENGLISH',
|
||||
'zh-CN': 'CHINESE_SIMPLIFIED'
|
||||
}
|
||||
|
||||
@@ -19,122 +21,126 @@ let languageSynced = false
|
||||
* 3. 路由的name默认是文件名,如果文件名与name不一致,通过defineOptions({ name: 'componentName' })来设置
|
||||
*/
|
||||
const router = createRouter({
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../views/home/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/collectionStory',
|
||||
name: 'collectionStory',
|
||||
component: () => import('../views/collectionStory/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/brand',
|
||||
name: 'brand',
|
||||
component: () => import('../views/brand/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/brand/:id',
|
||||
name: 'brandDetail',
|
||||
component: () => import('../views/brandDetail/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/digitalItem',
|
||||
name: 'digitalItem',
|
||||
component: () => import('../views/digitalItem/index.vue'),
|
||||
meta: { cache: true }
|
||||
},
|
||||
{
|
||||
path: '/digitalItem/:id',
|
||||
name: 'digitalItemDetail',
|
||||
component: () => import('../views/digitalDetail/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/setting/index.vue'),
|
||||
meta: { cache: true }
|
||||
},
|
||||
{
|
||||
path: '/shoppingCart', // 购物车
|
||||
name: 'shoppingCart',
|
||||
component: () => import('@/views/shoppingCart/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/notifications',
|
||||
name: 'notifications',
|
||||
component: () => import('@/views/notifications/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/wardrobe',
|
||||
name: 'wardrobe',
|
||||
component: () => import('@/views/wardrobe/index.vue')
|
||||
},
|
||||
{
|
||||
path:'/account',
|
||||
name:'account',
|
||||
component:()=>import('@/views/account/index.vue')
|
||||
},
|
||||
{
|
||||
path:'/pay',
|
||||
name:'pay',
|
||||
component:()=>import('@/views/pay/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)',
|
||||
name: '404',
|
||||
component: () => import('../views/404.vue')
|
||||
}
|
||||
],
|
||||
history: createWebHistory('/')
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../views/home/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/collectionStory',
|
||||
name: 'collectionStory',
|
||||
component: () => import('../views/collectionStory/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/brand',
|
||||
name: 'brand',
|
||||
component: () => import('../views/brand/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/brand/:id',
|
||||
name: 'brandDetail',
|
||||
component: () => import('../views/brandDetail/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/digitalItem',
|
||||
name: 'digitalItem',
|
||||
component: () => import('../views/digitalItem/index.vue'),
|
||||
meta: { cache: true }
|
||||
},
|
||||
{
|
||||
path: '/digitalItem/:id',
|
||||
name: 'digitalItemDetail',
|
||||
component: () => import('../views/digitalDetail/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/setting/index.vue'),
|
||||
meta: { cache: true }
|
||||
},
|
||||
{
|
||||
path: '/shoppingCart', // 购物车
|
||||
name: 'shoppingCart',
|
||||
component: () => import('@/views/shoppingCart/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/notifications',
|
||||
name: 'notifications',
|
||||
component: () => import('@/views/notifications/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/wardrobe',
|
||||
name: 'wardrobe',
|
||||
component: () => import('@/views/wardrobe/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
name: 'account',
|
||||
component: () => import('@/views/account/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/pay',
|
||||
name: 'pay',
|
||||
component: () => import('@/views/pay/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)',
|
||||
name: '404',
|
||||
component: () => import('../views/404.vue')
|
||||
}
|
||||
],
|
||||
history: createWebHistory('/')
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
next()
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach(async () => {
|
||||
// 检查用户是否已登录
|
||||
const userInfoStore = useUserInfoStore()
|
||||
const token = userInfoStore.state.token
|
||||
|
||||
|
||||
if (!token) {
|
||||
languageSynced = false // 未登录时重置同步状态
|
||||
languageSynced = false
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经同步过,跳过
|
||||
|
||||
const globalStore = useGlobalStore()
|
||||
fetchAllUnreadMessage().then((res) => {
|
||||
console.log(res)
|
||||
globalStore.setUnredCount(res.totalUnread)
|
||||
})
|
||||
|
||||
if (languageSynced) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 获取用户语言设置
|
||||
const response = await getUserLanguage()
|
||||
const userLanguage = (response as any)?.language // 后端返回 'en' 或 'zh-CN'
|
||||
|
||||
|
||||
if (!userLanguage) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 转换为 i18n 格式
|
||||
const i18nLanguage = backendToI18nLanguage[userLanguage]
|
||||
|
||||
|
||||
if (!i18nLanguage) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 获取当前 i18n 语言
|
||||
const currentLocale = i18n.global.locale.value
|
||||
|
||||
|
||||
// 如果用户语言和本地 i18n 不一致,更新 i18n
|
||||
if (i18nLanguage !== currentLocale) {
|
||||
i18n.global.locale.value = i18nLanguage as 'ENGLISH' | 'CHINESE_SIMPLIFIED'
|
||||
localStorage.setItem('language', i18nLanguage)
|
||||
}
|
||||
|
||||
|
||||
// 标记已同步
|
||||
languageSynced = true
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,13 +2,20 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
export const useGlobalStore = defineStore('global', () => {
|
||||
const state = ref({
|
||||
loading: false
|
||||
loading: false,
|
||||
unReadMessageCount: 0
|
||||
})
|
||||
|
||||
const setLoading = (v: boolean) => { state.value.loading = v }
|
||||
const setLoading = (v: boolean) => {
|
||||
state.value.loading = v
|
||||
}
|
||||
|
||||
const setUnredCount = (number: number) => {
|
||||
state.value.unReadMessageCount = number
|
||||
}
|
||||
return {
|
||||
state,
|
||||
setLoading,
|
||||
setUnredCount
|
||||
}
|
||||
})
|
||||
|
||||
252
src/utils/websocket.ts
Normal file
252
src/utils/websocket.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* WebSocket 管理类
|
||||
*/
|
||||
import { useGlobalStore } from '@/stores'
|
||||
|
||||
class WebSocketManager {
|
||||
private ws: WebSocket | null = null
|
||||
private url: string = ''
|
||||
private reconnectTimer: number | null = null
|
||||
private reconnectAttempts: number = 0
|
||||
private maxReconnectAttempts: number = 5
|
||||
private reconnectInterval: number = 3000
|
||||
private heartbeatTimer: number | null = null
|
||||
private heartbeatInterval: number = 30000
|
||||
private isManualClose: boolean = false
|
||||
|
||||
/**
|
||||
* 连接 WebSocket
|
||||
* @param token 用户 token
|
||||
*/
|
||||
connect(token: string) {
|
||||
if (!token) {
|
||||
console.warn('WebSocket: token 为空,无法建立连接')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经有连接在进行中,先关闭
|
||||
if (this.ws) {
|
||||
if (this.ws.readyState === WebSocket.CONNECTING) {
|
||||
console.log('WebSocket 正在连接中,跳过重复连接')
|
||||
return
|
||||
}
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
console.log('WebSocket 已连接,跳过重复连接')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 根据当前页面协议自动选择 ws 或 wss
|
||||
// const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const protocol = 'wss:'
|
||||
|
||||
// 从环境变量获取 WebSocket 主机地址
|
||||
// const wsHost = import.meta.env.VITE_WS_HOST || '18.167.251.121:10094'
|
||||
const wsHost = 'www.develop-ms.api.aida.com.hk'
|
||||
|
||||
this.url = `${protocol}//${wsHost}/ws?token=${token}`
|
||||
this.isManualClose = false
|
||||
|
||||
console.log('WebSocket 开始连接:', this.url.replace(/token=.+/, 'token=***'))
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
this.initEventHandlers()
|
||||
} catch (error) {
|
||||
console.error('WebSocket 连接失败:', error)
|
||||
this.reconnect(token)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化事件处理器
|
||||
*/
|
||||
private initEventHandlers() {
|
||||
if (!this.ws) return
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('✅ WebSocket 连接成功')
|
||||
this.reconnectAttempts = 0
|
||||
this.startHeartbeat()
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
console.log('📨 WebSocket 收到消息:', event.data)
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
this.handleMessage(data)
|
||||
} catch (error) {
|
||||
console.error('WebSocket 消息解析失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('❌ WebSocket 错误:', error)
|
||||
console.error('可能的原因:')
|
||||
console.error('1. 服务器未运行或地址错误')
|
||||
console.error('2. Token 无效或已过期')
|
||||
console.error('3. 网络连接问题')
|
||||
console.error('4. 服务器拒绝连接')
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
const closeReasons: Record<number, string> = {
|
||||
1000: '正常关闭',
|
||||
1001: '端点离开',
|
||||
1002: '协议错误',
|
||||
1003: '不支持的数据类型',
|
||||
1006: '连接异常关闭(可能是网络问题或服务器未响应)',
|
||||
1007: '数据格式错误',
|
||||
1008: '违反策略',
|
||||
1009: '消息过大',
|
||||
1010: '扩展协商失败',
|
||||
1011: '服务器错误',
|
||||
1015: 'TLS 握手失败'
|
||||
}
|
||||
|
||||
const reason = closeReasons[event.code] || '未知原因'
|
||||
console.log(`🔌 WebSocket 连接关闭 [代码: ${event.code}] ${reason}`)
|
||||
if (event.reason) {
|
||||
console.log('关闭原因:', event.reason)
|
||||
}
|
||||
|
||||
this.stopHeartbeat()
|
||||
|
||||
// 如果是异常关闭(非正常关闭码),才尝试重连
|
||||
if (!this.isManualClose && event.code !== 1000) {
|
||||
const token = this.extractTokenFromUrl()
|
||||
if (token) {
|
||||
this.reconnect(token)
|
||||
}
|
||||
} else if (event.code === 1000) {
|
||||
console.log('连接正常关闭,不进行重连')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理接收到的消息
|
||||
* @param data 消息数据
|
||||
*/
|
||||
private handleMessage(data: any) {
|
||||
// 这里可以根据消息类型进行不同的处理
|
||||
// 例如:通知、消息推送等
|
||||
if (data.type === 'unread') {
|
||||
// 处理通知消息
|
||||
const info = data.data
|
||||
// console.log('收到通知-----:', info)
|
||||
useGlobalStore().setUnredCount(info.unreadCount)
|
||||
// 可以触发自定义事件或调用回调函数
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* @param data 要发送的数据
|
||||
*/
|
||||
send(data: any) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const message = typeof data === 'string' ? data : JSON.stringify(data)
|
||||
this.ws.send(message)
|
||||
} else {
|
||||
console.warn('WebSocket 未连接,无法发送消息')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重连
|
||||
* @param token 用户 token
|
||||
*/
|
||||
private reconnect(token: string) {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('❌ WebSocket 重连次数已达上限,停止重连')
|
||||
console.error('请检查:')
|
||||
console.error('1. 服务器是否正常运行')
|
||||
console.error('2. Token 是否有效')
|
||||
console.error('3. 网络连接是否正常')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
const delay = this.reconnectInterval * this.reconnectAttempts // 递增延迟
|
||||
console.log(
|
||||
`🔄 WebSocket 将在 ${delay / 1000} 秒后尝试重连 (${this.reconnectAttempts}/${
|
||||
this.maxReconnectAttempts
|
||||
})`
|
||||
)
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.connect(token)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始心跳
|
||||
*/
|
||||
private startHeartbeat() {
|
||||
this.stopHeartbeat()
|
||||
this.heartbeatTimer = window.setInterval(() => {
|
||||
this.send({ type: 'heartbeat', timestamp: Date.now() })
|
||||
}, this.heartbeatInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
private stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 中提取 token
|
||||
*/
|
||||
private extractTokenFromUrl(): string | null {
|
||||
if (!this.url) return null
|
||||
const match = this.url.match(/token=([^&]+)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接
|
||||
*/
|
||||
close() {
|
||||
this.isManualClose = true
|
||||
this.stopHeartbeat()
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
console.log('WebSocket 已手动关闭')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
getReadyState(): number {
|
||||
return this.ws ? this.ws.readyState : WebSocket.CLOSED
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已连接
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const wsManager = new WebSocketManager()
|
||||
@@ -44,19 +44,19 @@ const {} = toRefs(data);
|
||||
<div class="icon">
|
||||
<svg-icon name="brand-email" size="24" />
|
||||
</div>
|
||||
<div>{{ designerDetail.email }}</div>
|
||||
<div class="text">{{ designerDetail.email }}</div>
|
||||
</div>
|
||||
<div class="phone label">
|
||||
<div class="icon">
|
||||
<svg-icon name="brand-call" size="24" />
|
||||
</div>
|
||||
<div>{{ designerDetail.mobile }}</div>
|
||||
<div class="text">{{ designerDetail.mobile }}</div>
|
||||
</div>
|
||||
<div class="address label" v-for="value in JSON.parse(designerDetail.socialLinks)">
|
||||
<div class="icon">
|
||||
<svg-icon name="brand-link" size="24" />
|
||||
</div>
|
||||
<div>{{value}}</div>
|
||||
<div class="text">{{value}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about">
|
||||
@@ -118,9 +118,20 @@ const {} = toRefs(data);
|
||||
font-size: 1.4rem;
|
||||
line-height: 100%;
|
||||
color: #585858;
|
||||
align-items: center;
|
||||
|
||||
|
||||
&:last-child{
|
||||
margin-bottom: 0;
|
||||
}
|
||||
> .text{
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
// word-break: break-word;
|
||||
// white-space: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
> .about{
|
||||
|
||||
@@ -49,6 +49,8 @@ const addShopping = () => {
|
||||
|
||||
const goShopping = () => {
|
||||
if(!detail.value.price) return ElMessage.warning(t('brandDetail.addShoppingTip'))
|
||||
// console.log(detail.value)
|
||||
// return
|
||||
let data = {
|
||||
listingId: detail.value.id, //资产ID
|
||||
title: detail.value.title, //标题
|
||||
@@ -56,7 +58,7 @@ const goShopping = () => {
|
||||
sellerId: detail.value.sellerId, //店铺ID
|
||||
cover: detail.value.images.cover[0], //封面
|
||||
amount: detail.value.price, //价格
|
||||
// status: v.status, //状态
|
||||
status: 1, //状态
|
||||
// date: v.addTime, //添加时间
|
||||
// tags: v.productCategory, //标签
|
||||
// salesVolume: v.salesVolume, //销售量
|
||||
@@ -163,7 +165,7 @@ defineExpose({})
|
||||
<div class="detail">
|
||||
<div class="name" @click="gobrand">{{ detail.shopName }}</div>
|
||||
<div class="release-time">
|
||||
<span>{{ $t('digitalDetail.ReleaseIn') }} {{ detail.updateTime }}</span>
|
||||
<span>{{ $t('digitalDetail.ReleaseIn') }} {{ new Date(detail.updateTime).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,6 +334,7 @@ defineExpose({})
|
||||
gap: 1.4rem;
|
||||
align-items: center;
|
||||
width: min-content;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
> span{
|
||||
font-weight: 500;
|
||||
|
||||
@@ -41,6 +41,11 @@ const reset = () => {
|
||||
getListingListData.isShowMark = false
|
||||
getListingListData.isNoData = false
|
||||
}
|
||||
const clearData = () => {
|
||||
commodityList.value = []
|
||||
getListingListData.pageNum = 1
|
||||
getListingListData.isNoData = true
|
||||
}
|
||||
|
||||
const getListingMallList = ()=>{
|
||||
getListingListData.isShowMark = true
|
||||
@@ -86,7 +91,7 @@ onMounted(()=>{
|
||||
})
|
||||
onUnmounted(()=>{
|
||||
})
|
||||
defineExpose({reset,commodityList,getListingListData})
|
||||
defineExpose({reset,clearData,commodityList,getListingListData})
|
||||
const {} = toRefs(data);
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -56,7 +56,11 @@ const openDetail = (item) => {
|
||||
const handleChange = (val) => {
|
||||
categories.value = val.categories
|
||||
gender.value = val.gender
|
||||
commodityListRef.value.reset()
|
||||
if(categories.value.length == 0 || gender.value.length == 0){
|
||||
commodityListRef.value.clearData()
|
||||
}else{
|
||||
commodityListRef.value.reset()
|
||||
}
|
||||
}
|
||||
const updateSort = () => {
|
||||
commodityListRef.value.reset()
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
v-if="userInfo.userId"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="icon"><svg-icon name="user_0" size="22" /></div>
|
||||
<el-badge :value="unReadCount" :hidden="unReadCount < 1">
|
||||
<div class="icon"><svg-icon name="user_0" size="22" /></div>
|
||||
</el-badge>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="profile-content">
|
||||
@@ -55,10 +57,17 @@
|
||||
<div class="icon"><svg-icon name="my_wardrobe" size="18" /></div>
|
||||
<div class="label">{{ $t('MainHeader.MyWardrobe') }}</div>
|
||||
</div>
|
||||
<div class="nav-item" @click="onNotifications">
|
||||
<div class="icon"><svg-icon name="notifications" size="14" /></div>
|
||||
<div class="label">{{ $t('MainHeader.Notifications') }}</div>
|
||||
</div>
|
||||
<el-badge
|
||||
:value="unReadCount"
|
||||
class="nav-item badge"
|
||||
:offset="[10, 0]"
|
||||
:hidden="unReadCount < 1"
|
||||
>
|
||||
<div class="nav-item flex" @click="onNotifications">
|
||||
<div class="icon"><svg-icon name="notifications" size="14" /></div>
|
||||
<div class="label">{{ $t('MainHeader.Notifications') }}</div>
|
||||
</div>
|
||||
</el-badge>
|
||||
<div class="nav-item" @click="onSettings">
|
||||
<div class="icon"><svg-icon name="settings" size="16" /></div>
|
||||
<div class="label">{{ $t('MainHeader.Settings') }}</div>
|
||||
@@ -87,14 +96,17 @@
|
||||
import myEvent from '@/utils/myEvent'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserInfoStore } from '@/stores/userInfo'
|
||||
import { useGlobalStore } from '@/stores/global'
|
||||
import { setUserLanguage } from '@/api/user'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const userInfoStore = useUserInfoStore()
|
||||
const GLOABL = useGlobalStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userInfo = computed(() => userInfoStore.state.userInfo)
|
||||
const activePath = computed(() => route.path)
|
||||
const unReadCount = computed(() => GLOABL.state.unReadMessageCount)
|
||||
const navList1 = ref([
|
||||
{
|
||||
name: 'MainHeader.Home',
|
||||
@@ -221,7 +233,7 @@
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
> .nav-item {
|
||||
.nav-item {
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -278,7 +290,7 @@
|
||||
margin: 1.2rem 1rem;
|
||||
border-top: 0.1rem solid #c4c4c4;
|
||||
}
|
||||
> .nav-item {
|
||||
.nav-item {
|
||||
margin-left: 2rem;
|
||||
margin-bottom: 1.2rem;
|
||||
display: flex;
|
||||
@@ -286,10 +298,14 @@
|
||||
height: 2rem;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
column-gap: 1rem;
|
||||
&.badge {
|
||||
margin: 0;
|
||||
}
|
||||
> .icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin-right: 1rem;
|
||||
// margin-right: 1rem;
|
||||
}
|
||||
> .label {
|
||||
font-family: KaiseiOpti-Regular;
|
||||
|
||||
@@ -1,166 +1,206 @@
|
||||
<template>
|
||||
<div class="notifications-view mini-scrollbar">
|
||||
<section class="notifications-hero">
|
||||
<div class="notifications-hero__content">
|
||||
<h1 class="notifications-hero__title">Notifications</h1>
|
||||
<p class="notifications-hero__subtitle">System announcements and updates</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="notifications-view mini-scrollbar" @scroll="handleScroll">
|
||||
<section class="notifications-hero">
|
||||
<div class="notifications-hero__content">
|
||||
<h1 class="notifications-hero__title">Notifications</h1>
|
||||
<p class="notifications-hero__subtitle">System announcements and updates</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="notifications-content">
|
||||
<NotificationsList
|
||||
:items="notifications"
|
||||
:unread-count="unreadCount"
|
||||
@toggle-item="handleToggleItem"
|
||||
@mark-all-as-read="handleMarkAllAsRead"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<section class="notifications-content">
|
||||
<NotificationsList
|
||||
:items="notifications"
|
||||
:unread-count="unreadCount"
|
||||
@toggle-item="handleToggleItem"
|
||||
@mark-all-as-read="handleMarkAllAsRead"
|
||||
/>
|
||||
<div v-if="loading" class="loading-indicator">Loading...</div>
|
||||
<div v-if="!hasMore && notifications.length > 0" class="no-more-data">No more notifications</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import NotificationsList from './components/NotificationsList.vue'
|
||||
import type { NotificationRecord } from './types'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import NotificationsList from './components/NotificationsList.vue'
|
||||
import type { NotificationRecord } from './types'
|
||||
import { fetchAllMessageList, markMessageAsRead, markAllMessagesAsRead } from '@/api/notification'
|
||||
|
||||
const notifications = ref<NotificationRecord[]>([
|
||||
{
|
||||
id: 'maintenance-mar-10-primary',
|
||||
title: 'Platform Maintenance Notice - Mar 10, 2026',
|
||||
date: 'Mar 6, 2026',
|
||||
content:
|
||||
'We will perform scheduled platform maintenance on Mar 10, 2026 to improve checkout stability and notification delivery. During this window, a few account features may respond more slowly than usual.',
|
||||
isUnread: true,
|
||||
isExpanded: false
|
||||
},
|
||||
{
|
||||
id: 'maintenance-mar-10-reminder',
|
||||
title: 'Platform Maintenance Notice - Mar 10, 2026',
|
||||
date: 'Feb 28, 2026',
|
||||
content:
|
||||
'This is an early reminder for the Mar 10 maintenance window. Please avoid making urgent profile or order updates right before the scheduled service period.',
|
||||
isUnread: true,
|
||||
isExpanded: false
|
||||
},
|
||||
{
|
||||
id: 'terms-mar-1',
|
||||
title: 'Updated Terms of Service - Effective Mar 1, 2026',
|
||||
date: 'Feb 20, 2026',
|
||||
content:
|
||||
'We updated our Terms of Service to clarify digital item ownership, payment processing responsibilities, and account conduct expectations. Please review the new terms before your next purchase.',
|
||||
isUnread: true,
|
||||
isExpanded: false
|
||||
},
|
||||
{
|
||||
id: 'welcome',
|
||||
title: 'Welcome to Stylish Parade',
|
||||
date: 'Jan 4, 2026',
|
||||
content:
|
||||
'Thanks for joining Stylish Parade. Explore brand stories, save your favorite pieces, and keep your profile updated so we can recommend the right collections for you.',
|
||||
isUnread: false,
|
||||
isExpanded: false
|
||||
},
|
||||
{
|
||||
id: 'holiday-support',
|
||||
title: 'Platform Maintenance Notice - Mar 10, 2026',
|
||||
date: 'Dec 20, 2025',
|
||||
content:
|
||||
'Our customer support team will have limited availability during the holiday season from Dec 24 to Jan 2. Response times may be longer than usual. The platform will remain fully operational throughout this period. We wish you a wonderful holiday season.',
|
||||
isUnread: false,
|
||||
isExpanded: true
|
||||
}
|
||||
])
|
||||
const notifications = ref<NotificationRecord[]>([])
|
||||
const loading = ref(false)
|
||||
const hasMore = ref(true)
|
||||
|
||||
const unreadCount = computed(() => notifications.value.filter((item) => item.isUnread).length)
|
||||
const unreadCount = computed(() => notifications.value.filter((item) => item.isUnread).length)
|
||||
|
||||
const handleToggleItem = (id: NotificationRecord['id']) => {
|
||||
const targetItem = notifications.value.find((item) => item.id === id)
|
||||
const handleToggleItem = async (id: NotificationRecord['id']) => {
|
||||
const targetItem = notifications.value.find((item) => item.id === id)
|
||||
|
||||
if (!targetItem) return
|
||||
if (!targetItem) return
|
||||
|
||||
const nextExpanded = !targetItem.isExpanded
|
||||
const nextExpanded = !targetItem.isExpanded
|
||||
|
||||
notifications.value = notifications.value.map((item) => ({
|
||||
...item,
|
||||
isUnread: item.id === id ? false : item.isUnread,
|
||||
isExpanded: item.id === id ? nextExpanded : false
|
||||
}))
|
||||
}
|
||||
// 如果消息是未读状态,调用API标记为已读
|
||||
if (targetItem.isUnread) {
|
||||
try {
|
||||
await markMessageAsRead(id)
|
||||
} catch (error) {
|
||||
console.error('Failed to mark message as read:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkAllAsRead = () => {
|
||||
notifications.value = notifications.value.map((item) => ({
|
||||
...item,
|
||||
isUnread: false
|
||||
}))
|
||||
}
|
||||
notifications.value = notifications.value.map((item) => ({
|
||||
...item,
|
||||
isUnread: item.id === id ? false : item.isUnread,
|
||||
isExpanded: item.id === id ? nextExpanded : false
|
||||
}))
|
||||
}
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
try {
|
||||
await markAllMessagesAsRead()
|
||||
notifications.value = notifications.value.map((item) => ({
|
||||
...item,
|
||||
isUnread: false
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to mark all messages as read:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const params = ref({
|
||||
page: 1,
|
||||
size: 15
|
||||
})
|
||||
|
||||
const handleFetchMessageList = async () => {
|
||||
if (loading.value || !hasMore.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchAllMessageList(params.value)
|
||||
|
||||
// Transform API data to match NotificationRecord interface
|
||||
const newNotifications: NotificationRecord[] = res.content.map((item: any) => ({
|
||||
id: String(item.id),
|
||||
title: item.title,
|
||||
date: item.createTime,
|
||||
content: item.content,
|
||||
isUnread: item.isRead === 0,
|
||||
isExpanded: false
|
||||
}))
|
||||
|
||||
if (params.value.page === 1) {
|
||||
notifications.value = newNotifications
|
||||
} else {
|
||||
notifications.value = [...notifications.value, ...newNotifications]
|
||||
}
|
||||
|
||||
// Check if there are more pages
|
||||
hasMore.value = params.value.page < res.pages
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notifications:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const scrollTop = target.scrollTop
|
||||
const scrollHeight = target.scrollHeight
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
// Load more when scrolled to 80% of the content
|
||||
if (scrollTop + clientHeight >= scrollHeight * 0.8 && !loading.value && hasMore.value) {
|
||||
params.value.page++
|
||||
handleFetchMessageList()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleFetchMessageList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.notifications-view {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: #ffffff;
|
||||
}
|
||||
.notifications-view {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.notifications-hero {
|
||||
min-height: 14.8rem;
|
||||
padding: 3.2rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, rgba(245, 243, 240, 0.92) 0%, rgba(250, 249, 246, 0.95) 100%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(227, 221, 212, 0.28) 0%,
|
||||
rgba(255, 255, 255, 0.24) 50%,
|
||||
rgba(227, 221, 212, 0.28) 100%
|
||||
);
|
||||
border-bottom: 0.05rem solid #dfd8d1;
|
||||
}
|
||||
.notifications-hero {
|
||||
min-height: 14.8rem;
|
||||
padding: 3.2rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(245, 243, 240, 0.92) 0%,
|
||||
rgba(250, 249, 246, 0.95) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(227, 221, 212, 0.28) 0%,
|
||||
rgba(255, 255, 255, 0.24) 50%,
|
||||
rgba(227, 221, 212, 0.28) 100%
|
||||
);
|
||||
border-bottom: 0.05rem solid #dfd8d1;
|
||||
}
|
||||
|
||||
.notifications-hero__content {
|
||||
text-align: center;
|
||||
}
|
||||
.notifications-hero__content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notifications-hero__title {
|
||||
margin: 0;
|
||||
color: #232323;
|
||||
font-size: 4rem;
|
||||
line-height: 1;
|
||||
font-family: 'KaiseiOpti-Bold';
|
||||
}
|
||||
.notifications-hero__title {
|
||||
margin: 0;
|
||||
color: #232323;
|
||||
font-size: 4rem;
|
||||
line-height: 1;
|
||||
font-family: 'KaiseiOpti-Bold';
|
||||
}
|
||||
|
||||
.notifications-hero__subtitle {
|
||||
margin: 1.2rem 0 0;
|
||||
color: #585858;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.4;
|
||||
font-family: 'KaiseiOpti-Regular';
|
||||
}
|
||||
.notifications-hero__subtitle {
|
||||
margin: 1.2rem 0 0;
|
||||
color: #585858;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.4;
|
||||
font-family: 'KaiseiOpti-Regular';
|
||||
}
|
||||
|
||||
.notifications-content {
|
||||
width: min(108rem, calc(100% - 4rem));
|
||||
margin: 0 auto;
|
||||
padding: 4rem 0 8rem;
|
||||
}
|
||||
.notifications-content {
|
||||
width: min(108rem, calc(100% - 4rem));
|
||||
margin: 0 auto;
|
||||
padding: 4rem 0 8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.notifications-hero {
|
||||
min-height: 12rem;
|
||||
padding: 2.8rem 1.6rem;
|
||||
}
|
||||
.loading-indicator,
|
||||
.no-more-data {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #979797;
|
||||
font-size: 1.4rem;
|
||||
font-family: 'KaiseiOpti-Regular';
|
||||
}
|
||||
|
||||
.notifications-hero__title {
|
||||
font-size: 3rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.notifications-hero {
|
||||
min-height: 12rem;
|
||||
padding: 2.8rem 1.6rem;
|
||||
}
|
||||
|
||||
.notifications-hero__subtitle {
|
||||
margin-top: 0.8rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.notifications-hero__title {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.notifications-content {
|
||||
width: calc(100% - 3.2rem);
|
||||
padding: 3.2rem 0 4.8rem;
|
||||
}
|
||||
}
|
||||
.notifications-hero__subtitle {
|
||||
margin-top: 0.8rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.notifications-content {
|
||||
width: calc(100% - 3.2rem);
|
||||
padding: 3.2rem 0 4.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -289,15 +289,15 @@
|
||||
title: item.listingName,
|
||||
brand: order.shopName,
|
||||
cover: item.thumbnailUrl,
|
||||
amount: item.price
|
||||
// status:order
|
||||
amount: item.price,
|
||||
status:1
|
||||
})
|
||||
})
|
||||
// 2060253365078691800
|
||||
// "https://checkout.stripe.com/c/pay/cs_test_a1zdyl7iR9sIEWArSQyUaOFIax6Pia0S7GJNXqvLOFfy2w57JVlVAV5Jlm#fidnandhYHdWcXxpYCc%2FJ2FgY2RwaXEnKSdpamZkaWAnPydgaycpJ2JwZGZkaGppYFNkd2xka3EnPydmamtxd2ppJyknZHVsTmB8Jz8ndW5acWB2cVowNFUxX19JNTdrNFFAfGF8S2lwbXEzU0F9a0dIakdyPVYwNzFqcX9pXVM2RkhGX0w9TWhuZlB3NkZOfD1fNWBUN1J2dlZPPHZEZF9rTFRPTDUxY0RXTU1PbDU1dWZMRzF2TlAnKSdjd2poVmB3c2B3Jz9xd3BgKSdnZGZuYndqcGthRmppancnPycmY2NjY2NjJyknaWR8anBxUXx1YCc%2FJ3Zsa2JpYFpscWBoJyknYGtkZ2lgVWlkZmBtamlhYHd2Jz9xd3BgeCUl"
|
||||
|
||||
console.log(list)
|
||||
const params = btoa(encodeURIComponent(JSON.stringify({ list })))
|
||||
const params = btoa(encodeURIComponent(JSON.stringify(list)))
|
||||
ROUTER.push({
|
||||
name: 'pay',
|
||||
query: {
|
||||
|
||||
Reference in New Issue
Block a user