chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="uts">
import userState, { initStore } from "@/stores/user.uts"
import appState, { initApp } from "@/stores/app.uts"
import messageService from "@/services/websocket/message-service.uts"
// #ifdef APP-ANDROID
UTSAndroid.setPrivacyAgree(true)
// #endif
// #ifdef APP-ANDROID || APP-HARMONY
let firstBackTime = 0
// #endif
const instance = getCurrentInstance()!.proxy!
onLaunch((options) => {
console.log('App onLaunch')
initStore();
// 注册 WebSocket 消息监听(即使未登录也先就绪)
messageService.init()
(async () => {
try {
await initApp()
// 其他异步初始化操作
} catch (e) {
console.error('初始化失败', e)
}
})()
})
onShow(() => {
console.log('App onShow')
const res = uni.getSystemInfoSync()
console.log(res)
})
onHide(() => {
console.log('App onHide')
})
// #ifdef APP-ANDROID || APP-HARMONY
onLastPageBackPress(() => {
console.log('App LastPageBackPress')
if (firstBackTime == 0) {
uni.showToast({
title: '再按一次退出应用',
position: 'bottom',
})
firstBackTime = Date.now()
setTimeout(() => {
firstBackTime = 0
}, 2000)
} else if (Date.now() - firstBackTime < 2000) {
firstBackTime = Date.now()
uni.exit()
}
})
// #endif
onExit(() => {
console.log('App Exit')
})
</script>
<style>
/*每个页面公共css */
@import url("@/static/common.scss");
</style>
@@ -0,0 +1,234 @@
// api-service.uts
// 缘友(wxchat)插件 前端 API 服务层
// 统一对接 addon/wxchat 后端接口。
//
// 约定:
// - 基础路径前缀:/wxchat (完整 URL 形如 /wxchat/<controller>/<action>
// - 真实网络客户端:@/utlis/http-api.uts 的 httpApi(已内置 token 注入与无感刷新)
// - 后端统一响应结构:{ code, message, data, timestamp }
// - 成功判定:code == 0success())或 code == 200(部分自定义接口),其余视为业务失败
//
// 使用前请确保 utlis/http-api.uts 中的 baseURL 指向正确的后端域名。
import httpApi, { type IResponse } from '@/utlis/http-api.uts'
import { setUserToken, setUserInfo, clearToken } from '@/stores/user.uts'
import { IUserInfo } from '@/types/user.uts'
// ===================== 业务异常 =====================
class ApiError extends Error {
public code : number
constructor(message : string, code : number) {
super(message)
this.name = 'ApiError'
this.code = code
}
}
// ===================== 基础请求封装 =====================
// 统一处理成功/失败:成功返回 data(UTSJSONObject),失败抛出 ApiError
function handleResponse(res : IResponse) : UTSJSONObject {
const code = res.code ?? 1
if (code == 0 || code == 200) {
return res.data as UTSJSONObject
}
throw new ApiError(res.message ?? '请求失败', code)
}
async function getJSON(url : string, data : any = {}, needToken : boolean = false) : Promise<UTSJSONObject> {
const res = await httpApi.get(url, data, { url: '', needToken: needToken })
return handleResponse(res)
}
async function postJSON(url : string, data : any = {}, needToken : boolean = false) : Promise<UTSJSONObject> {
const res = await httpApi.post(url, data, { url: '', needToken: needToken })
return handleResponse(res)
}
// 把任意对象安全序列化为目标类型(兼容 APP / 非APP 平台的 JSON 解析差异)
function toObject<T>(source : any) : T | null {
if (source == null) return null
// #ifndef APP
const str = JSON.stringify(source)
const obj = JSON.parse(str) as T
return obj
// #endif
// #ifdef APP
return source as T
// #endif
}
// ===================== 1. 认证模块(登录/注册) =====================
const auth = {
// 1.1 发送短信验证码(登录/注册场景,event: login|register
sendSmsCode(phone : string, event : string = 'login') : Promise<UTSJSONObject> {
return postJSON('/wxchat/api/login/sendSmsCode', { phone: phone, event: event }, false)
},
// 1.2 短信验证码登录
// 成功后后端返回用户信息(code=0),此处持久化用户信息
async smsLogin(mobile : string, captcha : string, event : string = 'login') : Promise<IUserInfo | null> {
const data = await postJSON('/wxchat/api/login/smsLogin', { mobile: mobile, captcha: captcha, event: event }, false)
// 后端在响应顶层附带 access/refresh token,拦截器已自动持久化;这里仅解析 user_info
const info = toObject<IUserInfo>(data.get('user_info'))
if (info != null) {
setUserInfo(info)
}
return info
},
// 1.3 本机号码一键登录:携带预凭证 pre_token 换取登录态(token 由拦截器自动持久化)
async oneClickLogin(preToken : string) : Promise<UTSJSONObject> {
const data = await postJSON('/wxchat/api/login/oneClickLogin', { pre_token: preToken }, false)
const info = toObject<IUserInfo>(data.get('user_info'))
if (info != null) {
setUserInfo(info)
}
return data
},
// 1.3.1 获取一键登录预凭证(模拟运营商 SDK 下发的本机号码凭证)
getOneClickPreToken(phone : string) : Promise<UTSJSONObject> {
return postJSON('/wxchat/api/login/getOneClickPreToken', { phone: phone }, false)
},
// 1.4 完善用户信息(新用户首次登录后补全资料)
completeUserInfo(profile : any) : Promise<UTSJSONObject> {
return postJSON('/wxchat/api/login/completeUserInfo', profile, true)
},
// 1.5 退出登录(清理本地 token / 用户信息)
logout() : void {
clearToken()
}
}
// ===================== 2. 用户模块 =====================
const user = {
profile() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/user/profile', {}, true) },
// 查看他人公开资料(资料卡页)
detail(uid : number) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/user/detail', { uid: uid }, true) },
updateProfile(profile : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/user/updateProfile', profile, true) },
nearby(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/user/nearby', params, true) },
local() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/user/local', {}, true) },
online() : Promise<UTSJSONObject> { return postJSON('/wxchat/api/user/online', {}, true) },
heartbeat() : Promise<UTSJSONObject> { return postJSON('/wxchat/api/user/heartbeat', {}, true) },
privacy() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/user/privacy', {}, true) },
updatePrivacy(privacy : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/user/updatePrivacy', privacy, true) },
cancelAccount() : Promise<UTSJSONObject> { return postJSON('/wxchat/api/user/cancelAccount', {}, true) },
ranking(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/user/ranking', params, true) }
// 用户概要(首页/我的 初始化使用;后端若未实现该 action 会走失败,调用方需自行兜底)
summary() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/user/summary', {}, true) },
// 我的钱包(余额/金币/汇总/明细)
wallet() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/user/wallet', {}, true) }
}
// ===================== 3. 关注模块 =====================
const follow = {
// 切换关注状态(关注/取消)。
// 后端 Follow::toggleFollow 通过 uid(当前用户) + target_uid(目标) + action(1关注/0取消) 判定,
// 注意它从请求参数读取 uid,而非登录态,调用方需传入当前用户 uid。
toggle(uid : number, targetUid : number, action : number = 1) : Promise<UTSJSONObject> {
return postJSON('/wxchat/api/follow/toggleFollow', { uid: uid, target_uid: targetUid, action: action }, true)
},
followingList(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/follow/getFollowingList', params, true) },
followerList(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/follow/getFollowerList', params, true) },
mutualList(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/follow/getMutualList', params, true) }
}
// ===================== 4. 好友模块 =====================
const friend = {
list(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/friend', params, true) },
requests(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/friend/requests', params, true) },
create(data : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/friend/create', data, true) },
detail(id : number) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/friend/read', { id: id }, true) },
respond(id : number, action : number = 1) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/friend/save', { id: id, action: action }, true) },
update(id : number, data : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/friend/update', { id: id, ...data }, true) },
delete(id : number) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/friend/delete', { id: id }, true) }
}
// ===================== 5. 动态/朋友圈模块 =====================
const moment = {
// 动态广场列表:scope = recommend|follow|nearby|hot
list(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/moment/index', params, true) },
detail(id : number) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/moment/red', { id: id }, true) },
create(data : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/moment/create', data, true) },
comment(data : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/moment/comment', data, true) },
like(data : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/moment/like', data, true) },
collect(data : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/moment/collect', data, true) },
follow(data : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/moment/follow', data, true) },
collectedList(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/moment/collectedList', params, true) },
historyList(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/moment/historyList', params, true) },
mine(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/moment/mine', params, true) }
upload(data : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/moment/upload', data, true) }
}
// ===================== 6. 消息模块 =====================
const message = {
send(data : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/message/send', data, true) },
history(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/message/history', params, true) },
// 会话列表(聚合最新一条消息 + 未读 + 对方资料/在线)
conversations() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/message/conversations', {}, true) },
unread() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/message/unread', {}, true) },
recall(id : number) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/message/recall', { id: id }, true) }
}
// ===================== 7. 礼物模块 =====================
const gift = {
list(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/gift/index', params, true) },
send(data : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/gift/send', data, true) },
received(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/gift/received', params, true) }
}
// ===================== 8. 抽奖模块 =====================
const lottery = {
config() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/lottery/config', {}, true) },
draw() : Promise<UTSJSONObject> { return postJSON('/wxchat/api/lottery/draw', {}, true) }
}
// ===================== 8.1 每日任务模块 =====================
const task = {
// 任务列表(含真实进度与领取状态)
list() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/task/index', {}, true) },
// 领取任务奖励
receive(taskId : number) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/task/receive', { task_id: taskId }, true) }
}
// ===================== 9. 推荐模块 =====================
const recommend = {
index(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/recommend/index', params, true) }
}
// ===================== 10. 搜索模块 =====================
const search = {
index(params : any = {}) : Promise<UTSJSONObject> { return getJSON('/wxchat/api/search/index', params, true) }
}
// ===================== 11. 未读消息模块 =====================
const unread = {
count() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/unread/count', {}, true) },
read(data : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/unread/read', data, true) }
}
// ===================== 12. 应用基础配置模块 =====================
const app = {
appConf() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/index/appConf', {}, false) },
launchData() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/index/launchData', {}, false) },
baseData() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/index/baseData', {}, false) }
}
// ===================== 13. 意见反馈模块 =====================
const feedback = {
// 提交反馈
save(data : any) : Promise<UTSJSONObject> { return postJSON('/wxchat/api/feedback/save', data, true) },
// 我的反馈列表
list() : Promise<UTSJSONObject> { return getJSON('/wxchat/api/feedback/index', {}, true) }
}
// ===================== 统一导出 =====================
const Api = {
auth, user, follow, friend, moment, message, gift, lottery, task, recommend, search, unread, app, feedback
}
export default Api
export { Api, ApiError }
export type { IUserInfo }
@@ -0,0 +1,85 @@
<template>
<view class="avatar-container">
<image class="avatar" :src="user.avatar" mode="aspectFill"></image>
<view v-if="user.online" class="online-badge"></view>
<text v-if="user.vip" class="vip-badge">VIP</text>
<text v-if="badge !== null && badge > 0" class="message-badge">
{{ badge !== null && badge > 99 ? '99+' : badge }}
</text>
</view>
</template>
<script setup lang="uts">
import { ISender } from '@/types/chatType.uts'
type TUser = {
id : number
nickname : string
avatar : string
online : boolean
vip : boolean
}
const props = defineProps({
badge: {
type: Number,
required: true
},
user: {
type: Object as PropType<ISender>,
required: true
}
})
const { badge, user } = props
</script>
<style>
.avatar-container {
position: relative;
width: 100rpx;
height: 100rpx;
margin-right: 30rpx;
}
.avatar {
width: 100%;
height: 100%;
border-radius: 20rpx;
background-color: #f0f0f0;
}
.online-badge {
position: absolute;
right: 0;
bottom: 0;
width: 24rpx;
height: 24rpx;
background-color: #4CD964;
border-radius: 50%;
border: 4rpx solid #fff;
}
.vip-badge {
position: absolute;
left: 0;
top: 0;
padding: 4rpx 10rpx;
background: linear-gradient(to right, #FFD700, #FFA500);
color: #fff;
font-size: 20rpx;
border-radius: 0 0 20rpx 0;
}
.message-badge {
position: absolute;
top: -10rpx;
right: -10rpx;
min-width: 36rpx;
height: 36rpx;
line-height: 36rpx;
text-align: center;
background-color: #FF3B30;
color: #fff;
font-size: 24rpx;
border-radius: 18rpx;
padding: 0 8rpx;
}
</style>
@@ -0,0 +1,219 @@
<template>
<view class="card-panel">
<view class="card-panel-header">
<image :src="itemData?.avatar ?? '/static/logo.png'" class="dynamic-card-avatar" alt="用户头像" />
<view class="dynamic-card-user-info">
<text class="dynamic-card-username">{{ itemData.nickname }}</text>
<text class="dynamic-post-time">{{ formatTime(itemData.createTime) }}</text>
</view>
</view>
<view class="card-panel-body">
<text :class="['dynamic-text-content', { collapsed: itemData.expanded==true }]">
{{ itemData.content }}
</text>
<text v-if="itemData.content.length > 100" class="expand-btn" @click="toggleExpand(itemData)">
{{ itemData.expanded ==true ? '收起' : '展开全文' }}
</text>
<!-- 媒体内容 -->
<view v-if="itemData.media !=null && itemData.media.length>0" class="media-container">
<view v-for="(media, index) in itemData.media" :key="index" class="media-item">
<image :src="media.url" class="media-item-image" />
<view v-if="media.type === 'video'" class="video-indicator">
<uni-icons type="videocam" size="32"></uni-icons>
</view>
</view>
</view>
<!-- 位置标签 -->
<view v-if="itemData.location !=null" class="location-tag">
<uni-icons type="location" size="18"></uni-icons> <text
class="location-tag-text">{{ itemData.location }}</text>
</view>
</view>
<view class="card-panel-footer" style="justify-content: space-between;">
<view class="action-btn like-btn" :class="{ active: itemData.likede }" @click="toggleLike(itemData)">
<uni-icons :type="itemData.likede ? 'heart-filled' : 'heart'" size="22"
:color="itemData.likede ? '#f74c31' : ''"></uni-icons>
<text class="action-text">{{ itemData.like }}</text>
</view>
<view class="action-btn comment-btn">
<uni-icons type="chatbubble" size="22"></uni-icons>
<text class="action-text">{{ itemData.comment }}</text>
</view>
<view class="action-btn share-btn">
<uni-icons type="redo" size="22"></uni-icons>
<text class="action-text">{{ itemData.share }}</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { type IMoment } from '@/types/moment.uts'
const props = defineProps<{
moment : IMoment
}>()
const itemData = computed(() : IMoment => {
return props.moment
})
onPageShow(() => {
console.log('page-props-composition onPageShow')
console.log(itemData)
})
// 切换展开/收起
const toggleExpand = (item : IMoment) => {
item.expanded = item.expanded != null && !item.expanded;
};
// 切换点赞状态
const toggleLike = (item : IMoment) => {
item.likede = !item.likede;
item.like += item.likede ? 1 : -1;
};
// 格式化时间
const formatTime = (timestamp : number) => {
const now = Date.now();
const diff = now - timestamp;
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < hour) {
return `${Math.floor(diff / minute)}分钟前`;
} else if (diff < day) {
return `${Math.floor(diff / hour)}小时前`;
} else if (diff < 2 * day) {
return '昨天';
} else {
const date = new Date(timestamp);
return `${date.getMonth() + 1}月${date.getDate()}日`;
}
};
</script>
<style>
.dynamic-card-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 20rpx;
background-color: #f0f0f0;
}
.dynamic-card-user-info {
flex: 1;
}
.dynamic-card-username {
font-weight: 600;
font-size: 18px;
color: #333;
}
.dynamic-post-time {
font-size: 13px;
color: #999;
margin-top: 4px;
}
.dynamic-card-content {
padding: 16px;
}
.dynamic-text-content {
font-size: 16px;
line-height: 1.7;
color: #333;
margin-bottom: 16px;
}
/* .text-content.collapsed {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
} */
.expand-btn {
color: #576b95;
font-size: 14px;
margin-top: 8px;
}
.media-container {
display: flex;
flex-flow: row wrap;
overflow: hidden;
}
.media-item {
margin: 5rpx;
position: relative;
flex-basis: 210rpx;
height: 230rpx;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.media-item-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transition: transform 0.3s ease;
}
.video-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.location-tag {
display: flex;
flex-flow: row nowrap;
align-items: center;
align-self: flex-start;
background: #f0f8ff;
padding: 4px 10px;
border-radius: 20px;
margin-top: 8px;
}
.location-tag-text {
font-size: 13px;
color: #576b95;
}
.card-footer {
padding: 15rpx 25rpx;
border-top: 1px solid #f0f0f0;
display: flex;
flex-flow: row nowrap;
justify-content: space-around;
}
.action-btn {
display: flex;
align-items: center;
flex-flow: row nowrap;
transition: color 0.2s;
}
.action-text {
color: #666;
font-size: 24rpx;
}
</style>
@@ -0,0 +1,122 @@
<template>
<view class="content">
<view class="top-line">
<text class="nickname">{{ senderNickname }}</text>
<text class="time">{{ formatTime(messageTime) }}</text>
</view>
<view class="bottom-line">
<text class="message" :class="{ 'unread': hasUnread }">
{{ messageContent }}
</text>
<text v-if="hasUnread" class="unread-count">
{{ displayUnreadCount }}
</text>
</view>
</view>
</template>
<script setup lang="uts">
import { IMessage, ISender } from '@/types/chatType.uts'
const props = defineProps<{
message : IMessage
}>()
// 计算属性提取,避免模板中过多可选链
const senderNickname = computed(() => props.message?.sender?.nickname ?? '')
const messageContent = computed(() => props.message?.content ?? '')
const messageTime = computed(() => props.message?.timestamp ?? 0)
const unreadCount = computed(() => props.message?.unreadCount ?? 0)
const hasUnread = computed(() => unreadCount.value > 0)
const displayUnreadCount = computed(() => {
const count = unreadCount.value
return count > 99 ? '99+' : count.toString()
})
// 格式化时间
const formatTime = (timestamp : number) : string => {
const date = new Date(timestamp)
const now = new Date()
const diffHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60))
if (diffHours < 24) {
// 当天消息显示时间
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
} else if (diffHours < 48) {
// 昨天
return '昨天'
} else if (diffHours < 168) {
// 一周内
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return days[date.getDay()]
} else {
// 更早
return `${date.getMonth() + 1}月${date.getDate()}日`
}
}
</script>
<style>
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.top-line {
display: flex;
flex-flow: row;
justify-content: space-between;
margin-bottom: 10rpx;
}
.nickname {
font-size: 32rpx;
font-weight: 500;
color: #333;
max-width: 400rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
font-size: 24rpx;
color: #999;
}
.bottom-line {
display: flex;
flex-flow: row;
justify-content: space-between;
align-items: center;
}
.message {
font-size: 28rpx;
color: #666;
max-width: 500rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message.unread {
color: #333;
font-weight: 500;
}
.unread-count {
min-width: 36rpx;
height: 36rpx;
line-height: 36rpx;
text-align: center;
background-color: #FF3B30;
color: #fff;
font-size: 24rpx;
border-radius: 18rpx;
padding: 0 8rpx;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
import App from './App.uvue'
import { wsManager } from './services/websocket/websocket.uts';
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
app.config.globalProperties.$ws = wsManager;
return {
app
}
}
+112
View File
@@ -0,0 +1,112 @@
{
"name": "缘友",
"appid": "__UNI__B54FC01",
"description": "",
"versionName": "1.0.0",
"versionCode": "100",
"uni-app-x": {},
/* */
"quickapp": {},
/* */
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
},
"usingComponents": true
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3",
"app": {
"distribute": {
"icons": {
"android": {
"hdpi": "",
"xhdpi": "",
"xxhdpi": "",
"xxxhdpi": ""
}
}
}
},
"app-android": {
"distribute": {
"modules": {
"uni-location": {
"system": {}
},
"uni-map": {},
"uni-payment": {},
"uni-barcode-scanning": {}
},
"icons": {
"hdpi": "D:/User/Desktop/缘友/icon72.png",
"xhdpi": "D:/User/Desktop/缘友/icon96.png",
"xxhdpi": "D:/User/Desktop/缘友/icon144.png",
"xxxhdpi": "D:/User/Desktop/缘友/icon192.png"
},
"splashScreens": {
"default": {
"xhdpi": "D:/User/Desktop/缘友/缘友-launch.png"
},
"background": "#F4A5C5",
"icon": {
"xhdpi": "D:/User/Desktop/缘友/缘友-logo.png",
"xxhdpi": "D:/User/Desktop/缘友/缘友-logo.png",
"xxxhdpi": "D:/User/Desktop/缘友/缘友-logo.png"
},
"background@night": "#D19FD8"
},
"abiFilters": [
"armeabi-v7a",
"arm64-v8a",
"x86",
"x86_64"
],
"permissions": [
"<uses-permission android:name=\"com.unionpay.merchant.permission\" />"
]
}
},
"app-ios": {
"distribute": {
"modules": {},
"icons": {},
"splashScreens": {}
}
},
"web": {
"router": {
"mode": ""
}
},
"app-harmony": {
"distribute": {
"deviceTypes": [
"phone"
],
"bundleName": "cn.ywxapp.yuanyou",
"targetSdkVersion": "6.0.0(20)",
"compatibleSdkVersion": "6.0.0(20)",
"splashScreens": {
"startWindowIcon": "D:/User/Desktop/icon192.png"
},
"modules": {
"uni-location": {
"system": {}
}
}
}
}
}
+310
View File
@@ -0,0 +1,310 @@
{
"pages": [ //pages数组中第一项表示应用启动页,参考:https://doc.dcloud.net.cn/uni-app-x/collocation/pagesjson.html
{
"path": "pages/guide/guide",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "缘分",
"disableScroll": true,
"navigationStyle": "custom"
}
},
{
"path": "pages/moment/index",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
{
"path": "pages/ent/ent",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
{
"path": "pages/chat/index",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/index",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
{
"path": "pages/chat/chat",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
{
"path": "pages/user/profile",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/profile",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
{
"path": "pages/moment/publish",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
{
"path": "pages/login/register",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
{
"path": "pages/login/index",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/feedback",
"style": {
"navigationBarTitleText": "反馈",
"navigationStyle": "custom"
}
},
{
"path": "pages/notification/notification",
"style": {
"navigationBarTitleText": "消息通知",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/help",
"style": {
"navigationBarTitleText": "帮助",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/about",
"style": {
"navigationBarTitleText": "关于我们",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/task",
"style": {
"navigationBarTitleText": "任务",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/wallet",
"style": {
"navigationBarTitleText": "钱包",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/income",
"style": {
"navigationBarTitleText": "收入",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/collect",
"style": {
"navigationBarTitleText": "收藏",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/dynamic",
"style": {
"navigationBarTitleText": "我的动态",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/history",
"style": {
"navigationBarTitleText": "历史记录",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/gift",
"style": {
"navigationBarTitleText": "礼物",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/fans",
"style": {
"navigationBarTitleText": "粉丝团",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/ranking",
"style": {
"navigationBarTitleText": "排名",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/lottery",
"style": {
"navigationBarTitleText": "抽奖",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/security",
"style": {
"navigationBarTitleText": "安全",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/privacy",
"style": {
"navigationBarTitleText": "隐私",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/likes",
"style": {
"navigationBarTitleText": "喜欢",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/following",
"style": {
"navigationBarTitleText": "关注",
"navigationStyle": "custom"
}
},
{
"path": "pages/my/followers",
"style": {
"navigationBarTitleText": "粉丝",
"navigationStyle": "custom"
}
},
{
"path": "pages/friend/index",
"style": {
"navigationBarTitleText": "好友",
"navigationStyle": "custom"
}
},
{
"path": "pages/chatroom/index",
"style": {
"navigationBarTitleText": "心动聊天",
"navigationStyle": "custom"
}
},
{
"path": "pages/group-chat/index",
"style": {
"navigationBarTitleText": "免费群聊",
"navigationStyle": "custom"
}
},
{
"path": "pages/pet-social/index",
"style": {
"navigationBarTitleText": "神兽宠物",
"navigationStyle": "custom"
}
},
{
"path": "pages/real-match/index",
"style": {
"navigationBarTitleText": "真人匹配",
"navigationStyle": "custom"
}
},
{
"path": "pages/moment/details",
"style": {
"navigationBarTitleText": "",
"navigationStyle": "custom"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app x",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"condition": {
"current": 0,
"list": [{
"name": "引导页",
"path": "pages/guide/guide"
}]
},
"tabBar": {
"list": [{
"pagePath": "pages/index/index",
"iconPath": "/static/images/tabbar/index.png",
"selectedIconPath": "/static/images/tabbar/indexa.png",
"text": "缘分"
},
{
"pagePath": "pages/moment/index",
"iconPath": "/static/images/tabbar/dynamic.png",
"selectedIconPath": "/static/images/tabbar/dynamica.png",
"text": "动态"
}, {
"pagePath": "pages/ent/ent",
"iconPath": "/static/images/tabbar/ent.png",
"selectedIconPath": "/static/images/tabbar/enta.png",
"text": "娱乐"
}, {
"pagePath": "pages/chat/index",
"iconPath": "/static/images/tabbar/chat.png",
"selectedIconPath": "/static/images/tabbar/chata.png",
"text": "消息"
}, {
"pagePath": "pages/my/index",
"iconPath": "/static/images/tabbar/my.png",
"selectedIconPath": "/static/images/tabbar/mya.png",
"text": "我的"
}
]
},
"uniIdRouter": {}
}
@@ -0,0 +1,808 @@
<template>
<view class="page">
<!-- 顶部导航栏 -->
<view class="nav-bar">
<view class="nav-left" @click="goBack">
<uni-icons type="left" size="24" color="#333"></uni-icons>
</view>
<view class="nav-center">
<text class="nav-title">{{ contactName }}</text>
<text class="nav-sub">{{ contactOnline ? '在线' : '离线' }}</text>
</view>
<view class="nav-right" @click="onMoreClick">
<uni-icons type="more-filled" size="22" color="#333"></uni-icons>
</view>
</view>
<!-- 聊天消息区域 -->
<scroll-view class="msg-scroll" direction="vertical" :scroll-into-view="scrollToId"
:scroll-with-animation="true" id="msgScroll">
<view v-for="(msg, idx) in messages" :key="msg.id" :id="'msg-' + msg.id"
class="msg-row" :class="{ self: msg.senderId == myUid }">
<image class="msg-avatar" :src="msg.senderId == myUid ? myAvatar : contactAvatar" mode="aspectFill"></image>
<view class="msg-content">
<view v-if="msg.type == 'image'" class="img-bubble" @click="previewImage(msg.content)">
<image class="msg-img" :src="msg.content" mode="widthFix"></image>
</view>
<view v-else class="text-bubble" :class="{ self: msg.senderId == myUid }">
<text class="text-inner">{{ msg.content }}</text>
</view>
<text class="msg-time">{{ fmtClock(msg.timestamp) }}</text>
</view>
</view>
<view v-if="messages.length == 0" class="empty">
<text class="empty-text">还没有消息,打个招呼吧~</text>
</view>
</scroll-view>
<!-- 底部输入区域 -->
<view class="input-bar">
<view class="action-btn" @click="toggleEmoji">
<uni-icons :type="showEmoji ? 'keyboard' : 'smile'" size="26" color="#666"></uni-icons>
</view>
<view class="input-box">
<textarea v-model="inputText" class="text-input" placeholder="说点什么..." :auto-height="true"
:adjust-position="true" @focus="closePanels" @confirm="sendText"></textarea>
</view>
<view class="action-btn" @click="toggleMore">
<uni-icons type="plus" size="26" color="#666"></uni-icons>
</view>
<text v-if="inputText.trim().length > 0" class="send-btn" @click="sendText">发送</text>
</view>
<!-- 表情面板 -->
<view v-if="showEmoji" class="emoji-panel">
<scroll-view direction="vertical" class="emoji-scroll">
<view class="emoji-list">
<text v-for="(e, i) in emojis" :key="i" class="emoji-item" @click="insertEmoji(e)">{{ e }}</text>
</view>
</scroll-view>
</view>
<!-- 更多面板 -->
<view v-if="showMore" class="more-panel">
<view class="more-grid">
<view class="more-item" @click="chooseImage">
<uni-icons type="image" size="30" color="#666"></uni-icons>
<text class="more-txt">图片</text>
</view>
<view class="more-item" @click="chooseImage(true)">
<uni-icons type="camera" size="30" color="#666"></uni-icons>
<text class="more-txt">拍摄</text>
</view>
<view class="more-item" @click="toast('位置功能开发中')">
<uni-icons type="location" size="30" color="#666"></uni-icons>
<text class="more-txt">位置</text>
</view>
<view class="more-item" @click="toast('红包功能开发中')">
<uni-icons type="redpacket" size="30" color="#FF6B6B"></uni-icons>
<text class="more-txt">红包</text>
</view>
<view class="more-item" @click="openGift">
<uni-icons type="gift" size="30" color="#FF9500"></uni-icons>
<text class="more-txt">礼物</text>
</view>
</view>
</view>
<!-- 送礼弹窗 -->
<view v-if="showGift" class="gift-mask" @click="closeGift">
<view class="gift-sheet" @click.stop>
<view class="gift-sheet-head">
<text class="gift-sheet-title">赠送礼物</text>
<uni-icons type="close" size="22" color="#999" @click="closeGift"></uni-icons>
</view>
<scroll-view class="gift-sheet-list" direction="vertical" :show-scrollbar="false">
<view v-for="(g, i) in gifts" :key="g.id" class="gift-sheet-cell"
:class="{ active: selectedGift != null && selectedGift.id == g.id }" @click="selectGift(g)">
<text class="gift-sheet-name">{{ g.name }}</text>
<text class="gift-sheet-price">{{ g.price }}金币</text>
</view>
<view v-if="gifts.length == 0" class="gift-sheet-empty">
<text class="empty-text">暂无可赠送的礼物</text>
</view>
</scroll-view>
<view class="gift-sheet-count">
<text class="count-label">数量</text>
<view class="count-ctrl">
<text class="count-btn" @click="giftCount > 1 ? giftCount = giftCount - 1 : null">-</text>
<text class="count-num">{{ giftCount }}</text>
<text class="count-btn" @click="giftCount = giftCount + 1">+</text>
</view>
</view>
<view class="gift-sheet-send" @click="sendGift">
<text class="send-text">赠送({{ giftTotal }}金币)</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onLoad, onMounted, onUnmounted } from 'vue'
import Api from '@/common/api-service.uts'
import { getUserInfo } from '@/stores/user.uts'
type ChatMessage = {
id : number
senderId : number
receiverId : number
content : string
timestamp : number
type : 'text' | 'image'
read : boolean
sending ?: boolean
}
const myUid = ref<number>(0)
const myAvatar = ref<string>('')
const contactUid = ref<number>(0)
const contactName = ref<string>('')
const contactAvatar = ref<string>('')
const contactOnline = ref<boolean>(false)
const messages = ref<ChatMessage[]>([])
const inputText = ref<string>('')
const showEmoji = ref<boolean>(false)
const showMore = ref<boolean>(false)
const scrollToId = ref<string>('')
let pollTimer : number | null = null
const emojis = ref<string[]>([
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇',
'🙂', '😉', '😍', '🥰', '😘', '😋', '😜', '🤪', '🤩', '🥳',
'😎', '🤔', '🤗', '😴', '😭', '😡', '👍', '👏', '💕', '🎉',
'🌹', '❤️', '🔥', '✨', '🍻', '🎁', '🌟', '😏', '🙄', '💔'
])
const findIdx = (id : number) : number => {
for (let i = 0; i < messages.value.length; i++) {
if (messages.value[i].id == id) return i
}
return -1
}
// 提交真实消息:若轮询已拉到同一条(真实 id 已存在),则丢弃临时消息避免重复
const commitMessage = (tempId : number, realId : number, content : string, type : 'text' | 'image', createAt : number) => {
const tempIdx = findIdx(tempId)
const realIdx = findIdx(realId)
if (realIdx >= 0) {
if (tempIdx >= 0) messages.value.splice(tempIdx, 1)
return
}
if (tempIdx >= 0) {
messages.value[tempIdx] = {
id : realId,
senderId : myUid.value,
receiverId : contactUid.value,
content : content,
timestamp : createAt,
type : type,
read : true
} as ChatMessage
}
}
const loadContact = () => {
Api.user.detail(contactUid.value)
.then((res : UTSJSONObject) => {
contactName.value = String(res.get('nickname') ?? '')
contactAvatar.value = String(res.get('avatar') ?? '')
contactOnline.value = Boolean(res.get('is_online') ?? false)
})
.catch(() => {
contactName.value = '用户' + contactUid.value
})
}
const mapMsg = (item : UTSJSONObject) : ChatMessage => {
const ct = Number(item.get('content_type') ?? 0)
return {
id : Number(item.get('id')),
senderId : Number(item.get('sender_id')),
receiverId : Number(item.get('receiver_id')),
content : String(item.get('content') ?? ''),
timestamp : Number(item.get('create_at') ?? 0) * 1000,
type : ct == 1 ? 'image' : 'text',
read : true
} as ChatMessage
}
// 初始化历史(倒序返回 -> 反转成正序)
const loadHistory = () => {
Api.message.history({ contact_id: contactUid.value, contact_type: 0, page: 1, limit: 50 })
.then((res : UTSJSONObject) => {
const list = res.get('list') as UTSJSONObject[] | null
if (list == null) return
const arr : ChatMessage[] = []
for (const it of list) {
arr.push(mapMsg(it))
}
arr.reverse()
messages.value = arr
scrollToBottom()
})
.catch(() => {})
}
// 轮询:仅追加缺失的新消息
const pollNew = () => {
Api.message.history({ contact_id: contactUid.value, contact_type: 0, page: 1, limit: 50 })
.then((res : UTSJSONObject) => {
const list = res.get('list') as UTSJSONObject[] | null
if (list == null) return
let changed = false
for (const it of list) {
const id = Number(it.get('id'))
if (findIdx(id) >= 0) continue
messages.value.push(mapMsg(it))
changed = true
}
if (changed) scrollToBottom()
})
.catch(() => {})
}
const scrollToBottom = () => {
if (messages.value.length == 0) return
const lastId = messages.value[messages.value.length - 1].id
// 延迟一帧确保渲染完成
setTimeout(() => {
scrollToId.value = 'msg-' + lastId
}, 50)
}
let tempCounter = 0
const sendText = () => {
const text = inputText.value.trim()
if (text.length == 0) return
inputText.value = ''
closePanels()
const tempId = pushOptimistic(text, 'text')
Api.message.send({ receiver_id: contactUid.value, receiver_type: 0, content: text, content_type: 0 })
.then((msgObj : UTSJSONObject) => {
commitMessage(tempId, Number(msgObj.get('id')), text, 'text', Number(msgObj.get('create_at') ?? 0) * 1000)
})
.catch(() => {
uni.showToast({ title: '发送失败', icon: 'none' })
})
}
const pushOptimistic = (content : string, type : 'text' | 'image') : number => {
tempCounter--
const tempId = tempCounter
messages.value.push({
id : tempId,
senderId : myUid.value,
receiverId : contactUid.value,
content : content,
timestamp : Date.now(),
type : type,
read : true,
sending : true
} as ChatMessage)
scrollToBottom()
return tempId
}
const chooseImage = (camera : boolean = false) => {
closePanels()
uni.chooseImage({
count : 1,
sourceType : camera ? ['camera'] : ['album'],
success : (res) => {
const path = res.tempFilePaths[0]
const tempId = pushOptimistic(path, 'image')
Api.message.send({ receiver_id: contactUid.value, receiver_type: 0, content: path, content_type: 1 })
.then((msgObj : UTSJSONObject) => {
commitMessage(tempId, Number(msgObj.get('id')), path, 'image', Number(msgObj.get('create_at') ?? 0) * 1000)
})
.catch(() => {
uni.showToast({ title: '图片发送失败', icon: 'none' })
})
}
})
}
const previewImage = (url : string) => {
uni.previewImage({ urls: [url], current: url })
}
const toggleEmoji = () => {
showEmoji.value = !showEmoji.value
showMore.value = false
}
const toggleMore = () => {
showMore.value = !showMore.value
showEmoji.value = false
}
const closePanels = () => {
showEmoji.value = false
showMore.value = false
}
const insertEmoji = (e : string) => {
inputText.value += e
}
const fmtClock = (ts : number) : string => {
if (! ts) return ''
const d = new Date(ts)
const h = d.getHours().toString().padStart(2, '0')
const m = d.getMinutes().toString().padStart(2, '0')
return `${h}:${m}`
}
const toast = (t : string) => {
uni.showToast({ title: t, icon: 'none' })
closePanels()
}
// ===== 送礼弹窗 =====
type IGift = {
id : number
name : string
icon : string
price : number
}
const showGift = ref<boolean>(false)
const gifts = reactive<IGift[]>([])
const selectedGift = ref<IGift | null>(null)
const giftCount = ref<number>(1)
const giftLoading = ref<boolean>(false)
const giftTotal = computed<number>(() => {
const p = selectedGift.value?.price ?? 0
return p * giftCount.value
})
const openGift = () => {
closePanels()
showGift.value = true
loadGifts()
}
const loadGifts = () => {
if (giftLoading.value) return
giftLoading.value = true
Api.gift.list()
.then((res : any) => {
const list = (res as Array<IGift>) ?? []
gifts.splice(0, gifts.length)
list.forEach((g : IGift) => gifts.push(g))
if (gifts.length > 0 && selectedGift.value == null) {
selectedGift.value = gifts[0]
}
})
.catch(() => {
uni.showToast({ title: '礼物加载失败', icon: 'none' })
})
.finally(() => { giftLoading.value = false })
}
const selectGift = (g : IGift) => {
selectedGift.value = g
}
const sendGift = () => {
if (selectedGift.value == null) {
uni.showToast({ title: '请选择礼物', icon: 'none' })
return
}
if (contactUid.value <= 0) return
uni.showLoading({ title: '赠送中...', mask: true })
Api.gift.send({
to_uid: contactUid.value,
gift_id: selectedGift.value.id,
count: giftCount.value,
message: ''
})
.then(() => {
uni.hideLoading()
showGift.value = false
uni.showToast({ title: '赠送成功', icon: 'success' })
})
.catch((e : any) => {
uni.hideLoading()
const msg = (e as UTSJSONObject)?.get('message') as string
uni.showToast({ title: msg ?? '赠送失败', icon: 'none' })
})
}
const closeGift = () => {
showGift.value = false
}
const goBack = () => {
uni.navigateBack()
}
const onMoreClick = () => {
uni.showActionSheet({
itemList: ['清空本地聊天记录', '举报用户'],
success: (res) => {
if (res.tapIndex == 0) {
messages.value = []
uni.showToast({ title: '已清空', icon: 'none' })
}
}
})
}
onLoad((options : any) => {
const uid = Number(options?.uid ?? options?.userId ?? 0)
contactUid.value = uid
})
onMounted(() => {
const me = getUserInfo()
if (me != null) {
myUid.value = me.uid
myAvatar.value = me.avatar
}
loadContact()
loadHistory()
pollTimer = setInterval(() => {
pollNew()
}, 4000) as number
})
onUnmounted(() => {
if (pollTimer != null) {
clearInterval(pollTimer)
pollTimer = null
}
})
</script>
<style>
.page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #ededed;
}
.nav-bar {
padding-top: var(--status-bar-height);
height: 90rpx;
display: flex;
flex-direction: row;
align-items: center;
background-color: #f7f7f7;
border-bottom: 1rpx solid #e0e0e0;
}
.nav-left, .nav-right {
width: 90rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.nav-title {
font-size: 32rpx;
color: #333;
font-weight: 500;
}
.nav-sub {
font-size: 22rpx;
color: #999;
}
.msg-scroll {
flex: 1;
padding: 20rpx 16rpx 10rpx;
}
.msg-row {
display: flex;
flex-direction: row;
align-items: flex-start;
margin-bottom: 28rpx;
}
.msg-row.self {
flex-direction: row-reverse;
}
.msg-avatar {
width: 76rpx;
height: 76rpx;
border-radius: 12rpx;
background-color: #ccc;
flex-shrink: 0;
}
.msg-content {
display: flex;
flex-direction: column;
max-width: 70%;
margin: 0 16rpx;
}
.msg-row.self .msg-content {
align-items: flex-end;
}
.text-bubble {
padding: 18rpx 22rpx;
border-radius: 16rpx;
background-color: #fff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.text-bubble.self {
background-color: #95EC69;
}
.text-inner {
font-size: 30rpx;
color: #1a1a1a;
line-height: 1.45;
word-break: break-all;
}
.img-bubble {
border-radius: 16rpx;
overflow: hidden;
background-color: #fff;
max-width: 360rpx;
}
.msg-img {
width: 300rpx;
}
.msg-time {
font-size: 20rpx;
color: #b2b2b2;
margin-top: 6rpx;
}
.empty {
display: flex;
justify-content: center;
padding-top: 80rpx;
}
.empty-text {
font-size: 26rpx;
color: #999;
}
.input-bar {
display: flex;
flex-direction: row;
align-items: flex-end;
padding: 12rpx 16rpx;
background-color: #f7f7f7;
border-top: 1rpx solid #e0e0e0;
padding-bottom: calc(12rpx + var(--uni-safe-area-inset-bottom));
}
.action-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6rpx;
}
.input-box {
flex: 1;
margin: 0 12rpx;
background-color: #fff;
border-radius: 12rpx;
padding: 12rpx 16rpx;
max-height: 180rpx;
}
.text-input {
width: 100%;
font-size: 30rpx;
min-height: 40rpx;
}
.send-btn {
background-color: #07C160;
color: #fff;
font-size: 26rpx;
padding: 12rpx 24rpx;
border-radius: 12rpx;
margin-bottom: 6rpx;
margin-left: 8rpx;
}
.emoji-panel {
height: 360rpx;
background-color: #fff;
border-top: 1rpx solid #eee;
}
.emoji-scroll {
height: 360rpx;
}
.emoji-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 10rpx;
}
.emoji-item {
width: 72rpx;
height: 72rpx;
font-size: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.more-panel {
background-color: #fff;
border-top: 1rpx solid #eee;
padding: 30rpx;
}
.more-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.more-item {
width: 130rpx;
display: flex;
flex-direction: column;
align-items: center;
margin: 10rpx 10rpx;
}
.more-txt {
font-size: 24rpx;
color: #666;
margin-top: 10rpx;
}
.gift-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 999;
}
.gift-sheet {
width: 100%;
background-color: #fff;
border-top-left-radius: 28rpx;
border-top-right-radius: 28rpx;
padding: 24rpx 30rpx calc(30rpx + var(--uni-safe-area-inset-bottom));
display: flex;
flex-direction: column;
}
.gift-sheet-head {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
padding-bottom: 16rpx;
}
.gift-sheet-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.gift-sheet-list {
max-height: 420rpx;
display: flex;
flex-flow: row wrap;
}
.gift-sheet-cell {
width: 22%;
margin: 10rpx 1.5%;
padding: 20rpx 0;
border-radius: 16rpx;
background-color: #f7f7f7;
display: flex;
flex-direction: column;
align-items: center;
}
.gift-sheet-cell.active {
background-color: #fff0ec;
border: 2rpx solid #FF6B6B;
}
.gift-sheet-name {
font-size: 26rpx;
color: #333;
}
.gift-sheet-price {
font-size: 22rpx;
color: #FF6B6B;
margin-top: 6rpx;
}
.gift-sheet-empty {
width: 100%;
padding: 60rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.gift-sheet-count {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
margin: 20rpx 0;
}
.count-label {
font-size: 28rpx;
color: #333;
}
.count-ctrl {
display: flex;
flex-flow: row nowrap;
align-items: center;
}
.count-btn {
width: 60rpx;
height: 60rpx;
border-radius: 30rpx;
background-color: #f0f0f0;
text-align: center;
line-height: 60rpx;
font-size: 36rpx;
color: #666;
}
.count-num {
min-width: 60rpx;
text-align: center;
font-size: 30rpx;
color: #333;
}
.gift-sheet-send {
height: 92rpx;
border-radius: 46rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
display: flex;
align-items: center;
justify-content: center;
}
.send-text {
font-size: 32rpx;
font-weight: bold;
color: #fff;
}
</style>
@@ -0,0 +1,362 @@
<template>
<view class="page">
<!-- 顶部导航栏 -->
<view class="nav-bar">
<view class="nav-tabs">
<view class="nav-tab" :class="{ active: activeTab == 0 }" @click="switchTab(0)">
<text class="nav-tab-txt" :class="{ active: activeTab == 0 }">消息</text>
</view>
<view class="nav-tab" :class="{ active: activeTab == 1 }" @click="switchTab(1)">
<text class="nav-tab-txt" :class="{ active: activeTab == 1 }">缘友</text>
</view>
</view>
</view>
<!-- 消息 Tab:会话列表 -->
<scroll-view v-if="activeTab == 0" class="list-scroll" direction="vertical"
:refresher-enabled="true" :refresher-triggered="refreshing"
@refresherrefresh="loadConversations">
<view v-if="conversations.length == 0" class="empty">
<text class="empty-icon">💬</text>
<text class="empty-text">还没有聊天,去「缘友」找人聊聊吧</text>
</view>
<view v-for="(item, idx) in conversations" :key="idx" class="conv-item"
@click="openChat(item.peer_id)">
<view class="avatar-wrap">
<image class="avatar" :src="item.avatar || defaultAvatar" mode="aspectFill"></image>
<view v-if="item.online" class="online-dot"></view>
</view>
<view class="conv-main">
<view class="conv-row">
<text class="conv-name">{{ item.nickname || '用户' + item.peer_id }}</text>
<text class="conv-time">{{ fmtTime(item.last_time) }}</text>
</view>
<view class="conv-row">
<text class="conv-last">{{ item.is_self ? '我: ' : '' }}{{ previewContent(item) }}</text>
<view v-if="item.unread_count > 0" class="badge">
<text class="badge-txt">{{ item.unread_count > 99 ? '99+' : item.unread_count }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 缘友 Tab:在线用户,点击直接发起聊天 -->
<scroll-view v-else class="list-scroll" direction="vertical"
:refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="loadOnline">
<view v-if="onlineUsers.length == 0" class="empty">
<text class="empty-icon">🌟</text>
<text class="empty-text">暂无在线缘友,稍后再来看看</text>
</view>
<view v-for="(u, idx) in onlineUsers" :key="idx" class="conv-item" @click="openChat(u.uid)">
<view class="avatar-wrap">
<image class="avatar" :src="u.avatar || defaultAvatar" mode="aspectFill"></image>
<view v-if="u.online" class="online-dot"></view>
</view>
<view class="conv-main">
<view class="conv-row">
<text class="conv-name">{{ u.nickname || '用户' + u.uid }}</text>
<text class="conv-time online-tag">在线</text>
</view>
<view class="conv-row">
<text class="conv-last">点击发起聊天</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, onShow } from 'vue'
import Api from '@/common/api-service.uts'
type Convo = {
peer_id : number
nickname : string
avatar : string
online : boolean
last_content : string
last_content_type : number
last_time : number
unread_count : number
is_self : boolean
}
type OnlineUser = {
uid : number
nickname : string
avatar : string
online : boolean
}
const defaultAvatar = 'https://randomuser.me/api/portraits/lego/1.jpg'
const activeTab = ref<number>(0)
const refreshing = ref<boolean>(false)
const conversations = ref<Convo[]>([])
const onlineUsers = ref<OnlineUser[]>([])
const switchTab = (i : number) => {
activeTab.value = i
if (i == 0) {
loadConversations()
} else {
loadOnline()
}
}
const loadConversations = () => {
refreshing.value = true
Api.message.conversations()
.then((res : UTSJSONObject) => {
const list = res.get('list') as UTSJSONObject[] | null
const arr : Convo[] = []
if (list != null) {
for (const it of list) {
arr.push({
peer_id : Number(it.get('peer_id')),
nickname : String(it.get('nickname') ?? ''),
avatar : String(it.get('avatar') ?? ''),
online : Boolean(it.get('online') ?? false),
last_content : String(it.get('last_content') ?? ''),
last_content_type : Number(it.get('last_content_type') ?? 0),
last_time : Number(it.get('last_time') ?? 0),
unread_count : Number(it.get('unread_count') ?? 0),
is_self : Boolean(it.get('is_self') ?? false)
} as Convo)
}
}
conversations.value = arr
})
.catch(() => {
conversations.value = []
})
.finally(() => {
refreshing.value = false
})
}
const loadOnline = () => {
refreshing.value = true
Api.user.online()
.then((res : UTSJSONObject) => {
let list : UTSJSONObject[] | null = null
if (res.containsKey('list')) {
list = res.get('list') as UTSJSONObject[] | null
} else {
list = res as any as UTSJSONObject[]
}
const arr : OnlineUser[] = []
if (list != null) {
for (const it of list) {
const uid = Number(it.get('uid') ?? it.get('id') ?? 0)
if (uid <= 0) continue
const online = Boolean(it.get('online') ?? it.get('is_online') ?? (Number(it.get('online_status') ?? 0) == 1))
arr.push({
uid : uid,
nickname : String(it.get('nickname') ?? ''),
avatar : String(it.get('avatar') ?? ''),
online : online
} as OnlineUser)
}
}
onlineUsers.value = arr
})
.catch(() => {
onlineUsers.value = []
})
.finally(() => {
refreshing.value = false
})
}
const previewContent = (item : Convo) : string => {
if (item.last_content_type == 1) return '[图片]'
if (item.last_content_type == 2) return '[语音]'
return item.last_content || '[暂无消息]'
}
const fmtTime = (sec : number) : string => {
if (! sec) return ''
const d = new Date(sec * 1000)
const now = new Date()
const diff = (now.getTime() - d.getTime()) / 1000
if (diff < 60) return '刚刚'
if (diff < 3600) return Math.floor(diff / 60) + '分钟前'
if (diff < 86400) return Math.floor(diff / 3600) + '小时前'
if (diff < 86400 * 2) return '昨天'
const y = d.getFullYear()
const m = d.getMonth() + 1
const day = d.getDate()
if (y == now.getFullYear()) return `${m}月${day}日`
return `${y}年${m}月${day}日`
}
const openChat = (uid : number) => {
uni.navigateTo({ url: `/pages/chat/chat?uid=${uid}` })
}
onMounted(() => {
loadConversations()
})
// 从聊天页返回时刷新会话(更新未读/最新消息)
onShow(() => {
if (activeTab.value == 0) {
loadConversations()
}
})
</script>
<style>
.page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f6f8;
}
.nav-bar {
padding-top: var(--status-bar-height);
height: 90rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
border-bottom: 1rpx solid #eee;
}
.nav-tabs {
display: flex;
flex-direction: row;
}
.nav-tab {
margin: 0 24rpx;
padding: 10rpx 0;
}
.nav-tab-txt {
font-size: 32rpx;
color: #999;
}
.nav-tab-txt.active {
font-size: 38rpx;
font-weight: bold;
color: #333;
}
.list-scroll {
flex: 1;
padding: 10rpx 20rpx;
}
.conv-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 24rpx 20rpx;
background: #fff;
border-radius: 20rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.04);
}
.avatar-wrap {
position: relative;
margin-right: 20rpx;
}
.avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background-color: #eee;
}
.online-dot {
position: absolute;
right: 2rpx;
bottom: 2rpx;
width: 18rpx;
height: 18rpx;
border-radius: 50%;
background-color: #4CD964;
border: 3rpx solid #fff;
}
.conv-main {
flex: 1;
min-width: 0;
}
.conv-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.conv-name {
font-size: 32rpx;
color: #333;
font-weight: 500;
max-width: 360rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conv-time {
font-size: 24rpx;
color: #999;
}
.online-tag {
color: #4CD964;
}
.conv-last {
font-size: 26rpx;
color: #999;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16rpx;
}
.badge {
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
border-radius: 16rpx;
background-color: #FF4D4F;
display: flex;
align-items: center;
justify-content: center;
}
.badge-txt {
font-size: 22rpx;
color: #fff;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 160rpx;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
font-size: 26rpx;
color: #999;
}
</style>
@@ -0,0 +1,154 @@
<template>
<view class="page">
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<uni-icons type="left" size="22" color="#333"></uni-icons>
</view>
<text class="nav-title">心动聊天</text>
<view class="nav-right"></view>
</view>
<view class="hero">
<text class="hero-emoji">💬</text>
<text class="hero-title">心动聊天室</text>
<text class="hero-sub">玩游戏、破冰互动,轻松交到好朋友</text>
</view>
<view class="section">
<text class="section-title">热门玩法</text>
<view class="cell" v-for="(item, i) in rooms" :key="i" @click="enterRoom(item)">
<text class="cell-name">{{ item.name }}</text>
<text class="cell-online">{{ item.online }}人在线</text>
<uni-icons type="right" size="16" color="#ccc"></uni-icons>
</view>
</view>
<view class="tip">
<text>更多互动房间即将上线,敬请期待~</text>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
const rooms = ref([
{ name: '真心话大冒险', online: 128 },
{ name: '你画我猜', online: 86 },
{ name: '语音速配', online: 203 }
] as UTSJSONObject[])
const enterRoom = (item : UTSJSONObject) => {
uni.showToast({ title: '进入「' + (item.getString('name') ?? '') + '」', icon: 'none' })
}
const goBack = () => {
uni.navigateBack()
}
</script>
<style>
.page {
display: flex;
flex-direction: column;
background-color: #f6f6f6;
min-height: 100%;
}
.nav-bar {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 24rpx;
padding-top: 60rpx;
background-color: #fff;
}
.nav-back {
width: 60rpx;
display: flex;
align-items: center;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: bold;
color: #333;
}
.nav-right {
width: 60rpx;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx 0 40rpx;
background: linear-gradient(to bottom right, #FF6B6B, #FF8E53);
}
.hero-emoji {
font-size: 90rpx;
}
.hero-title {
font-size: 40rpx;
font-weight: bold;
color: #fff;
margin-top: 16rpx;
}
.hero-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 10rpx;
}
.section {
background-color: #fff;
margin: 24rpx;
border-radius: 20rpx;
padding: 10rpx 24rpx;
}
.section-title {
display: block;
font-size: 28rpx;
font-weight: bold;
color: #333;
padding: 20rpx 0 10rpx;
}
.cell {
display: flex;
flex-direction: row;
align-items: center;
padding: 26rpx 0;
border-top: 1rpx solid #f2f2f2;
}
.cell-name {
flex: 1;
font-size: 28rpx;
color: #333;
}
.cell-online {
font-size: 24rpx;
color: #FF6B6B;
margin-right: 12rpx;
}
.tip {
text-align: center;
padding: 30rpx;
}
.tip text {
font-size: 24rpx;
color: #aaa;
}
</style>
@@ -0,0 +1,897 @@
<template>
<view class="page">
<!-- 顶部导航栏 -->
<view class="page-nav">
<view class="page-nav-left">
<view class="tabsbox">
<view v-for="(tab, index) in filters" :key="index" class="tabsbox-item"
:class="{ 'tabsbox-active': tabActive == index }" @click="switchTab(index)">
<text class="tabsbox-txt"
:class="{'tabsbox-active-txt': tabActive == index }">{{ tab.text }}</text>
<!-- <text v-if="tab.count" class="ywx-tabs-badge">{{ tab.count }}</text> -->
</view>
</view>
</view>
<view class="page-nav-right">
<view class="icon-btn" @click="onSearchClick">
<uni-icons type="search" size="32" color="#333"></uni-icons>
</view>
<view class="icon-btn" @click="onMessageClick">
<uni-icons type="chat" size="32" color="#333"></uni-icons>
<text v-if="unreadCount > 0" class="message-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}
</text>
</view>
</view>
</view>
<view class="page-body" style=" padding-bottom: 90rpx;">
<!-- 轮播图 -->
<swiper class="banner-swiper" :indicator-dots="true" :autoplay="true" :interval="3000" :duration="500">
<swiper-item v-for="(banner, index) in banners" :key="index" @click="onBannerClick(banner)">
<image class="banner-image" :src="banner.image" mode="aspectFill"></image>
<text v-if="banner.tag" class="banner-tag">{{ banner.tag }}</text>
</swiper-item>
</swiper>
<!-- 快速入口 -->
<!-- <scroll-view class="quick-entrance" scroll-x>
<view v-for="(entrance, index) in quickEntrances" :key="index" class="entrance-item"
@click="onEntranceClick(entrance)">
<view class="entrance-icon" :style="{ backgroundColor: entrance.bgColor }">
<uni-icons :type="entrance.icon" size="28"
:color="entrance.iconColor != null || '#fff'"></uni-icons>
</view>
<text class="entrance-text">{{ entrance.text }}</text>
<view v-if="entrance.hot" class="hot-badge">HOT</view>
</view>
</scroll-view> -->
<!-- 筛选栏 -->
<!-- <view class="filter-bar">
<view v-for="(filter, index) in filters" :key="index" class="filter-item"
:class="{ active: tabActive == index }" @click="changeFilter(index)">
<text class="filter-text">{{ filter.text }}</text>
<view v-if="filter.count" class="filter-count">{{ filter.count }}</view>
</view>
<view class="filter-more" @click="showMoreFilters">
<uni-icons type="more" size="20" color="#666"></uni-icons>
</view>
</view> -->
<!-- 用户列表 -->
<scroll-view class="user-list" direction="vertical" :show-scrollbar="false" :refresher-enabled="true"
:refresher-triggered="refreshing" @refresherrefresh="onRefresh" @scrolltolower="onLoadMore">
<!-- 直播 -->
<view v-if="tabActive == 0" class="section">
<view class="section-header">
<text class="section-title">语音房</text>
<text class="section-subtitle">{{ voiceRooms.length }}个房间在线</text>
</view>
<view class="voice-room-list">
<view v-for="room in voiceRooms" :key="room.id" class="voice-room" @click="joinVoiceRoom(room)">
<view class="room-header">
<image class="room-cover" :src="room.cover" mode="aspectFill"></image>
<view class="room-status">
<uni-icons type="sound" size="16" color="#fff"></uni-icons>
<text class="room-status-text">{{ room.onlineCount }}/{{ room.maxCount }}</text>
</view>
<text v-if="room.hot" class="room-hot">🔥{{ room.hot }}</text>
</view>
<view class="room-info">
<text class="room-title">{{ room.title }}</text>
<view class="room-tags">
<text v-for="(tag, tagIndex) in room.tags" :key="tagIndex" class="room-tag">
{{ tag }}
</text>
</view>
<view class="room-users">
<view v-for="(user, userIndex) in room.users" :key="userIndex"
class="room-user-avatar">
<image class="user-avatar-small" :src="user.avatar" mode="aspectFill"></image>
</view>
<text v-if="room.users.length < room.onlineCount" class="room-user-more">
+{{ room.onlineCount - room.users.length }}
</text>
</view>
</view>
</view>
</view>
</view>
<!-- 语音 -->
<view v-if="tabActive == 1" class="section">
<view class="section-header">
<text class="section-title">游戏中心</text>
<text class="section-subtitle">边玩边交友</text>
</view>
<view class="game-list">
<view v-for="game in games" :key="game.id" class="game-item" @click="playGame(game)">
<view class="game-cover">
<image class="game-image" :src="game.image" mode="aspectFill"></image>
<view v-if="game.playing" class="game-playing">
<uni-icons type="play" size="16" color="#fff"></uni-icons>
<text class="playing-text">{{ formatNumber(game.playing) }}在线</text>
</view>
</view>
<view class="game-info">
<view class="game-header">
<text class="game-title">{{ game.title }}</text>
<view v-if="game.reward" class="game-reward">+{{ game.reward }}</view>
</view>
<text class="game-desc">{{ game.description }}</text>
<view class="game-tags">
<view v-for="(tag, tagIndex) in game.tags" :key="tagIndex" class="game-tag">
{{ tag }}
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 畅聊 -->
<view v-if="tabActive == 2" class="section">
<view class="section-header">
<text class="section-title">游戏中心</text>
<text class="section-subtitle">边玩边交友</text>
</view>
<view class="game-list">
<view v-for="game in games" :key="game.id" class="game-item" @click="playGame(game)">
<view class="game-cover">
<image class="game-image" :src="game.image" mode="aspectFill"></image>
<view v-if="game.playing" class="game-playing">
<uni-icons type="play" size="16" color="#fff"></uni-icons>
<text class="playing-text">{{ formatNumber(game.playing) }}在线</text>
</view>
</view>
<view class="game-info">
<view class="game-header">
<text class="game-title">{{ game.title }}</text>
<view v-if="game.reward" class="game-reward">+{{ game.reward }}</view>
</view>
<text class="game-desc">{{ game.description }}</text>
<view class="game-tags">
<view v-for="(tag, tagIndex) in game.tags" :key="tagIndex" class="game-tag">
{{ tag }}
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 游戏 -->
<view v-if="tabActive == 3" class="section">
<view class="section-header">
<text class="section-title">游戏中心</text>
<text class="section-subtitle">边玩边交友</text>
</view>
<view class="game-list">
<view v-for="game in games" :key="game.id" class="game-item" @click="playGame(game)">
<view class="game-cover">
<image class="game-image" :src="game.image" mode="aspectFill"></image>
<view v-if="game.playing" class="game-playing">
<uni-icons type="play" size="16" color="#fff"></uni-icons>
<text class="playing-text">{{ formatNumber(game.playing) }}在线</text>
</view>
</view>
<view class="game-info">
<view class="game-header">
<text class="game-title">{{ game.title }}</text>
<view v-if="game.reward" class="game-reward">+{{ game.reward }}</view>
</view>
<text class="game-desc">{{ game.description }}</text>
<view class="game-tags">
<view v-for="(tag, tagIndex) in game.tags" :key="tagIndex" class="game-tag">
{{ tag }}
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 加载状态 -->
<!-- <view class="load-status">
<uni-load-more :status="loadStatus" />
</view> -->
</scroll-view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, computed, onMounted } from 'vue'
import { IBanner, IFilter, IVoiceRoom, IGame, IMenu } from '@/types/entType.uts'
// 响应式数据
const unreadCount = ref(3)
const refreshing = ref(false)
const loading = ref(false)
const noMore = ref(false)
const page = ref(1)
const tabActive = ref(0)
// 轮播图数据
const banners = reactive<IBanner[]>([
{
id: 1,
image: 'https://images.unsplash.com/photo-1511795409834-ef04bbd61622?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&q=80',
tag: '新活动',
url: '/pages/activity/activity'
},
{
id: 2,
image: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&q=80',
tag: 'VIP专区',
url: '/pages/vip/vip'
},
{
id: 3,
image: 'https://images.unsplash.com/photo-1529626455594-4ff0802cfb7e?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&q=80',
tag: '热门推荐',
url: '/pages/hot/hot'
}
] as IBanner[])
// 快速入口数据
//语音 畅聊 游戏
const quickEntrances = reactive<IMenu[]>([
{ id: 2, icon: 'sound', text: '语音房', bgColor: '#5AC8FA', url: '/pages/voice-room/voice-room', hot: true },
{ id: 3, icon: 'videocam', text: '视频', bgColor: '#FF9500', url: '/pages/video/video' },
{ id: 4, icon: 'game', text: '游戏', bgColor: '#34C759', url: '/pages/game/game' },
{ id: 5, icon: 'gift', text: '礼物', bgColor: '#AF52DE', url: '/pages/gift/gift' },
{ id: 6, icon: 'location', text: '同城', bgColor: '#5856D6', url: '/pages/city/city' },
{ id: 7, icon: 'star', text: '推荐', bgColor: '#FFCC00', url: '/pages/recommend/recommend' },
{ id: 8, icon: 'fire', text: '热门', bgColor: '#FF3B30', url: '/pages/hot/hot' }
] as IMenu[])
// 筛选数据
const filters = reactive<IFilter[]>([
{ id: 0, text: '附近', count: 128 },
{ id: 1, text: '推荐', count: 56 },
{ id: 2, text: '语音房', count: 12 },
{ id: 3, text: '游戏', count: 8 }
] as IFilter[])
// 语音房数据
const voiceRooms = reactive<IVoiceRoom[]>([
{
id: 301,
title: '情感聊天室',
cover: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
onlineCount: 8,
maxCount: 10,
hot: 125,
tags: ['情感', '聊天', '治愈'],
users: [
{ id: 1, avatar: 'https://randomuser.me/api/portraits/women/44.jpg' },
{ id: 2, avatar: 'https://randomuser.me/api/portraits/men/32.jpg' },
{ id: 3, avatar: 'https://randomuser.me/api/portraits/women/68.jpg' }
]
},
{
id: 302,
title: '游戏开黑房',
cover: 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
onlineCount: 6,
maxCount: 8,
hot: 89,
tags: ['游戏', '开黑', '娱乐'],
users: [
{ id: 4, avatar: 'https://randomuser.me/api/portraits/men/75.jpg' },
{ id: 5, avatar: 'https://randomuser.me/api/portraits/women/65.jpg' }
]
},
{
id: 303,
title: '音乐分享会',
cover: 'https://images.unsplash.com/photo-1511379938547-c1f69419868d?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
onlineCount: 4,
maxCount: 6,
hot: 67,
tags: ['音乐', '分享', '放松'],
users: [
{ id: 6, avatar: 'https://randomuser.me/api/portraits/women/23.jpg' },
{ id: 7, avatar: 'https://randomuser.me/api/portraits/men/22.jpg' }
]
}
])
// 游戏数据
const games = reactive<IGame[]>([
{
id: 401,
title: '你画我猜',
image: 'https://images.unsplash.com/photo-1511512578047-dfb367046420?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
description: '经典互动游戏,发挥你的想象力',
playing: 1250,
reward: 50,
tags: ['互动', '休闲', '社交']
},
{
id: 402,
title: '真心话大冒险',
image: 'https://images.unsplash.com/photo-1535223289827-42f1e9919769?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
description: '增进了解的经典游戏',
playing: 890,
reward: 30,
tags: ['互动', '冒险', '社交']
},
{
id: 403,
title: '狼人杀',
image: 'https://images.unsplash.com/photo-1542751371-adc38448a05e?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
description: '考验推理和口才的社交游戏',
playing: 1560,
reward: 80,
tags: ['推理', '策略', '社交']
}
])
// 计算属性
const loadStatus = computed(() => {
if (loading.value) return 'loading'
if (noMore.value) return 'noMore'
return 'more'
})
// 加载数据
const loadData = () => {
console.log('加载数据...')
// 这里可以调用API加载数据
}
// 页面加载
onMounted(() => {
console.log('娱乐交友页面加载完成')
loadData()
})
// 格式化距离
const formatDistance = (distance : number) : string => {
if (distance < 1) {
return '<1km'
} else if (distance < 10) {
return `${distance.toFixed(1)}km`
} else {
return `${Math.floor(distance)}km`
}
}
// 格式化数字
const formatNumber = (num : number) : string => {
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k'
}
return num.toString()
}
// 点击搜索
const onSearchClick = () => {
console.log('点击搜索')
uni.navigateTo({
url: '/pages/search/search'
})
}
// 点击消息
const onMessageClick = () => {
console.log('点击消息')
unreadCount.value = 0
uni.navigateTo({
url: '/pages/message/message'
})
}
// 点击轮播图
const onBannerClick = (banner : IBanner) => {
console.log('点击轮播图:', banner.id)
uni.navigateTo({
url: banner.url
})
}
// 点击快速入口
const onEntranceClick = (entrance : IMenu) => {
console.log('点击快速入口:', entrance.text)
if (entrance.url != null) {
uni.navigateTo({
url: entrance.url
})
} else {
uni.showToast({
title: `进入${entrance.text}`,
icon: 'none',
duration: 1500
})
}
}
// 切换筛选
const switchTab = (index : number) => {
console.log('切换筛选:', filters[index].text)
tabActive.value = index
page.value = 1
noMore.value = false
// 这里应该重新加载对应筛选的数据
}
// 显示更多筛选
const showMoreFilters = () => {
console.log('显示更多筛选')
uni.showActionSheet({
itemList: ['最新', '热门', '在线', 'VIP'],
success: (res) => {
console.log('选择了筛选:', res.tapIndex)
}
})
}
// 加入语音房
const joinVoiceRoom = (room : IVoiceRoom) => {
console.log('加入语音房:', room.title)
uni.showModal({
title: '加入语音房',
content: `确定要加入"${room.title}"吗?`,
success: (res) => {
if (res.confirm) {
uni.showToast({
title: '正在加入房间...',
icon: 'none'
})
// 实际应该跳转到语音房页面
}
}
})
}
// 玩游戏
const playGame = (game : IGame) => {
console.log('玩游戏:', game.title)
uni.showToast({
title: `开始${game.title}`,
icon: 'none',
duration: 1500
})
// 实际应该跳转到游戏页面
}
// 下拉刷新
const onRefresh = () => {
console.log('下拉刷新')
refreshing.value = true
setTimeout(() => {
console.log('刷新完成')
refreshing.value = false
page.value = 1
noMore.value = false
uni.showToast({
title: '刷新成功',
icon: 'success',
duration: 2000
})
}, 1500)
}
// 上拉加载更多
const onLoadMore = () => {
console.log('上拉加载更多')
if (loading.value || noMore.value) return
loading.value = true
setTimeout(() => {
page.value++
// 模拟没有更多数据
if (page.value >= 3) {
noMore.value = true
}
loading.value = false
uni.showToast({
title: '加载成功',
icon: 'none',
duration: 1500
})
}, 1000)
}
// 语音通话
const onVoiceCall = () => {
console.log('语音通话')
uni.showActionSheet({
itemList: ['随机语音', '好友语音', '创建语音房'],
success: (res) => {
const actions = ['randomVoice', 'friendVoice', 'createRoom']
const action = actions[res.tapIndex]
console.log('选择了:', action)
}
})
}
// 视频通话
const onVideoCall = () => {
console.log('视频通话')
uni.showModal({
title: '视频通话',
content: '开始视频通话需要消耗流量,是否继续?',
confirmText: '开始通话',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: '/pages/video-call/video-call'
})
}
}
})
}
</script>
<style>
.header-right {
flex-flow: row nowrap;
display: flex;
}
.icon-btn {
position: relative;
width: 60rpx;
height: 60rpx;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
background-color: #f8f8f8;
}
.message-badge {
position: absolute;
top: -5rpx;
right: -5rpx;
min-width: 32rpx;
height: 32rpx;
line-height: 32rpx;
text-align: center;
background-color: #FF3B30;
color: #fff;
font-size: 20rpx;
border-radius: 16rpx;
padding: 0 8rpx;
}
/* 轮播图 */
.banner-swiper {
height: 300rpx;
margin: 10rpx;
border-radius: 20rpx;
overflow: hidden;
}
.banner-image {
width: 100%;
height: 100%;
}
.banner-tag {
position: absolute;
top: 20rpx;
right: 20rpx;
padding: 8rpx 20rpx;
background-color: rgba(255, 107, 107, 0.9);
color: #fff;
font-size: 24rpx;
border-radius: 20rpx;
}
/* 筛选栏 */
.filter-bar {
display: flex;
align-items: center;
flex-flow: row nowrap;
padding: 10rpx 20rpx;
background-color: #fff;
margin: 10rpx;
border-radius: 20rpx;
box-shadow: 0 5rpx 20rpx rgba(0, 0, 0, 0.05);
}
.filter-item {
display: flex;
align-items: center;
padding: 15rpx 30rpx;
margin-right: 20rpx;
border-radius: 40rpx;
background-color: #f8f8f8;
transition: all 0.3s ease;
}
.filter-item.active {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
color: #fff;
}
.filter-item.active .filter-count {
background-color: rgba(255, 255, 255, 0.2);
color: #fff;
}
.filter-text {
font-size: 28rpx;
font-weight: 500;
}
.filter-count {
margin-left: 10rpx;
padding: 4rpx 12rpx;
background-color: #e0e0e0;
color: #666;
font-size: 20rpx;
border-radius: 20rpx;
}
.filter-more {
width: 60rpx;
height: 60rpx;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
background-color: #f8f8f8;
margin-left: auto;
}
/* 用户列表 */
.user-list {
flex: 1;
padding: 10rpx 20rpx;
border-radius: 20rpx;
}
.section {
border-radius: 20rpx;
box-shadow: 0 5rpx 20rpx rgba(0, 0, 0, 0.05);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.section-subtitle {
font-size: 24rpx;
color: #999;
}
/* 语音房列表 */
.voice-room-list {
display: flex;
flex-direction: column;
}
.voice-room {
margin-bottom: 15rpx;
background-color: #f8f8f8;
border-radius: 20rpx;
overflow: hidden;
transition: transform 0.3s ease;
}
.voice-room:active {
transform: scale(0.99);
}
.room-header {
position: relative;
height: 200rpx;
}
.room-cover {
width: 100%;
height: 100%;
background-color: #e0e0e0;
}
.room-status {
position: absolute;
bottom: 20rpx;
right: 20rpx;
display: flex;
align-items: center;
padding: 8rpx 20rpx;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 20rpx;
}
.room-status .uni-icons {
margin-right: 8rpx;
}
.room-hot {
position: absolute;
top: 20rpx;
left: 20rpx;
padding: 8rpx 20rpx;
background-color: rgba(255, 107, 107, 0.9);
color: #fff;
font-size: 20rpx;
border-radius: 20rpx;
}
.room-info {
padding: 30rpx;
}
.room-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 15rpx;
}
.room-tags {
display: flex;
flex-flow: row wrap;
margin-bottom: 20rpx;
}
.room-tag {
padding: 8rpx 20rpx;
background-color: #e0e0e0;
color: #666;
font-size: 20rpx;
border-radius: 20rpx;
}
.room-users {
display: flex;
flex-flow: row wrap;
align-items: center;
}
.room-user-avatar {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
overflow: hidden;
border: 4rpx solid #fff;
margin-right: -10rpx;
}
.room-user-avatar:first-child {
margin-left: 0;
}
.user-avatar-small {
width: 100%;
height: 100%;
background-color: #e0e0e0;
}
.room-user-more {
width: 60rpx;
height: 60rpx;
line-height: 60rpx;
text-align: center;
background-color: #f0f0f0;
color: #999;
font-size: 20rpx;
border-radius: 50%;
border: 4rpx solid #fff;
margin-left: 10rpx;
}
/* 游戏列表 */
.game-list {
display: flex;
flex-direction: column;
}
.game-item {
display: flex;
background-color: #f8f8f8;
border-radius: 20rpx;
overflow: hidden;
transition: transform 0.3s ease;
margin-top: 15rpx;
}
.game-item:active {
transform: scale(0.99);
}
.game-cover {
position: relative;
height: 200rpx;
}
.game-image {
width: 100%;
height: 100%;
background-color: #e0e0e0;
}
.game-playing {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 10rpx;
background-color: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 20rpx;
}
.game-playing .uni-icons {
margin-right: 8rpx;
}
.game-info {
flex: 1;
padding: 30rpx;
}
.game-header {
display: flex;
align-items: center;
flex-flow: row wrap;
justify-content: space-between;
margin-bottom: 10rpx;
}
.game-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.game-reward {
padding: 8rpx 20rpx;
background-color: #FF9500;
color: #fff;
font-size: 20rpx;
border-radius: 20rpx;
}
.game-desc {
font-size: 24rpx;
color: #666;
margin-bottom: 15rpx;
line-height: 1.4;
}
.game-tags {
display: flex;
flex-flow: row wrap;
flex-wrap: wrap;
}
.game-tag {
padding: 8rpx 20rpx;
background-color: #e0e0e0;
color: #666;
font-size: 20rpx;
border-radius: 20rpx;
}
/* 加载状态 */
.load-status {
padding: 30rpx 0;
display: flex;
justify-content: center;
}
</style>
@@ -0,0 +1,305 @@
<template>
<view class="page">
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<uni-icons type="left" size="22" color="#333"></uni-icons>
</view>
<text class="nav-title">我的好友</text>
<view class="nav-right"></view>
</view>
<view class="add-bar">
<input class="add-input" v-model="newUid" type="number" placeholder="输入对方用户ID添加好友" />
<button class="add-btn" @click="sendRequest">添加</button>
</view>
<scroll-view class="tabs" direction="horizontal" :show-scrollbar="false">
<text class="tab" :class="{ active: tab == 0 }" @click="tab = 0">好友 ({{ friends.length }})</text>
<text class="tab" :class="{ active: tab == 1 }" @click="tab = 1">请求 ({{ requests.length }})</text>
</scroll-view>
<list-view class="list" v-if="tab == 0">
<list-item v-for="(f, i) in friends" :key="f.uid" class="item">
<image class="avatar" :src="f.avatar" mode="aspectFill"></image>
<view class="info">
<text class="name">{{ f.nickname }}</text>
<text class="meta">{{ f.remark && f.remark.length > 0 ? f.remark : '好友' }}</text>
</view>
<button class="del-btn" @click="removeFriend(f.friend_id)">删除</button>
</list-item>
<view v-if="friends.length == 0" class="empty">
<text>还没有好友,去真人匹配认识一下吧</text>
</view>
</list-view>
<list-view class="list" v-else>
<list-item v-for="(r, i) in requests" :key="r.uid" class="item">
<image class="avatar" :src="r.avatar" mode="aspectFill"></image>
<view class="info">
<text class="name">{{ r.nickname }}</text>
<text class="meta">请求添加你为好友</text>
</view>
<button class="ok-btn" @click="respond(r.friend_id, 1)">接受</button>
<button class="del-btn" @click="respond(r.friend_id, 2)">拒绝</button>
</list-item>
<view v-if="requests.length == 0" class="empty">
<text>暂无新的好友请求</text>
</view>
</list-view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { Api } from '@/common/api-service.uts'
const friends = ref<UTSJSONObject[]>([])
const requests = ref<UTSJSONObject[]>([])
const tab = ref(0)
const newUid = ref('')
const loadFriends = async () => {
try {
const data = await Api.friend.list({})
const list = data.getJSONArray('list')
const arr : UTSJSONObject[] = []
if (list != null) {
for (let i = 0; i < list.size(); i++) {
const it = list.get(i) as UTSJSONObject
if (it != null) {
arr.push({
uid: it.getNumber('uid') ?? 0,
nickname: it.getString('nickname') ?? '',
avatar: it.getString('avatar') ?? '',
remark: it.getString('remark') ?? '',
friend_id: it.getNumber('friend_id') ?? 0
} as UTSJSONObject)
}
}
}
friends.value = arr
} catch (e) {
console.warn('加载好友列表失败', e)
}
}
const loadRequests = async () => {
try {
const data = await Api.friend.requests({})
const list = data.getJSONArray('list')
const arr : UTSJSONObject[] = []
if (list != null) {
for (let i = 0; i < list.size(); i++) {
const it = list.get(i) as UTSJSONObject
if (it != null) {
arr.push({
uid: it.getNumber('uid') ?? 0,
nickname: it.getString('nickname') ?? '',
avatar: it.getString('avatar') ?? '',
friend_id: it.getNumber('friend_id') ?? 0
} as UTSJSONObject)
}
}
}
requests.value = arr
} catch (e) {
console.warn('加载好友请求失败', e)
}
}
const sendRequest = () => {
const fid = parseInt(newUid.value)
if (!fid || fid <= 0) {
uni.showToast({ title: '请输入有效ID', icon: 'none' })
return
}
Api.friend.create({ fid: fid }).then(() => {
uni.showToast({ title: '请求已发送', icon: 'success' })
newUid.value = ''
loadRequests()
}).catch((err : any) => {
uni.showToast({ title: err?.message ?? '失败', icon: 'none' })
})
}
const respond = (id : number, action : number) => {
Api.friend.respond(id, action).then(() => {
uni.showToast({ title: action == 1 ? '已添加' : '已拒绝', icon: 'success' })
loadRequests()
loadFriends()
}).catch((err : any) => {
uni.showToast({ title: err?.message ?? '失败', icon: 'none' })
})
}
const removeFriend = (id : number) => {
Api.friend.delete(id).then(() => {
uni.showToast({ title: '已删除', icon: 'success' })
loadFriends()
}).catch((err : any) => {
uni.showToast({ title: err?.message ?? '失败', icon: 'none' })
})
}
const goBack = () => {
uni.navigateBack()
}
onMounted(() => {
loadFriends()
loadRequests()
})
</script>
<style>
.page {
display: flex;
flex-direction: column;
background-color: #f6f6f6;
min-height: 100%;
}
.nav-bar {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 24rpx;
padding-top: 60rpx;
background-color: #fff;
}
.nav-back {
width: 60rpx;
display: flex;
align-items: center;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: bold;
color: #333;
}
.nav-right {
width: 60rpx;
}
.add-bar {
display: flex;
flex-direction: row;
padding: 20rpx 24rpx;
background-color: #fff;
}
.add-input {
flex: 1;
background-color: #f2f2f2;
border-radius: 40rpx;
padding: 18rpx 28rpx;
font-size: 26rpx;
margin-right: 16rpx;
}
.add-btn {
background: linear-gradient(to bottom right, #FF6B6B, #FF8E53);
color: #fff;
border: none;
border-radius: 40rpx;
font-size: 26rpx;
padding: 0 36rpx;
}
.tabs {
display: flex;
flex-direction: row;
background-color: #fff;
padding: 10rpx 24rpx;
border-top: 1rpx solid #f0f0f0;
}
.tab {
font-size: 28rpx;
color: #888;
margin-right: 40rpx;
padding: 12rpx 0;
}
.tab.active {
color: #FF6B6B;
font-weight: bold;
border-bottom: 4rpx solid #FF6B6B;
}
.list {
flex: 1;
}
.item {
display: flex;
flex-direction: row;
align-items: center;
background-color: #fff;
margin: 16rpx 24rpx;
padding: 24rpx;
border-radius: 20rpx;
}
.avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background-color: #eee;
}
.info {
flex: 1;
display: flex;
flex-direction: column;
margin-left: 24rpx;
}
.name {
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.meta {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
.ok-btn {
background-color: #4CD964;
color: #fff;
border: none;
border-radius: 40rpx;
font-size: 24rpx;
padding: 8rpx 24rpx;
margin-right: 12rpx;
}
.del-btn {
background-color: #ff3b30;
color: #fff;
border: none;
border-radius: 40rpx;
font-size: 24rpx;
padding: 8rpx 24rpx;
}
.empty {
padding: 80rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.empty text {
font-size: 26rpx;
color: #aaa;
}
</style>
@@ -0,0 +1,186 @@
<template>
<view class="page">
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<uni-icons type="left" size="22" color="#333"></uni-icons>
</view>
<text class="nav-title">免费群聊</text>
<view class="nav-right"></view>
</view>
<view class="hero">
<text class="hero-emoji">👥</text>
<text class="hero-title">免费群聊广场</text>
<text class="hero-sub">加入兴趣群,和同好畅聊不停</text>
</view>
<view class="section">
<text class="section-title">推荐群聊</text>
<view class="cell" v-for="(item, i) in groups" :key="i" @click="joinGroup(item)">
<view class="avatar">{{ item.tag }}</view>
<view class="cell-info">
<text class="cell-name">{{ item.name }}</text>
<text class="cell-sub">{{ item.member }}人 · {{ item.desc }}</text>
</view>
<button class="join-btn">加入</button>
</view>
</view>
<view class="tip">
<text>创建群聊、群管理功能即将上线~</text>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
const groups = ref([
{ name: '同城脱单互助', tag: '脱', member: 326, desc: '缘分从这里开始' },
{ name: '深夜树洞', tag: '夜', member: 198, desc: '倾诉你的小秘密' },
{ name: '游戏开黑交友', tag: '游', member: 512, desc: '边玩边聊' }
] as UTSJSONObject[])
const joinGroup = (item : UTSJSONObject) => {
uni.showToast({ title: '申请加入「' + (item.getString('name') ?? '') + '」', icon: 'none' })
}
const goBack = () => {
uni.navigateBack()
}
</script>
<style>
.page {
display: flex;
flex-direction: column;
background-color: #f6f6f6;
min-height: 100%;
}
.nav-bar {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 24rpx;
padding-top: 60rpx;
background-color: #fff;
}
.nav-back {
width: 60rpx;
display: flex;
align-items: center;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: bold;
color: #333;
}
.nav-right {
width: 60rpx;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx 0 40rpx;
background: linear-gradient(to bottom right, #4CD964, #34C759);
}
.hero-emoji {
font-size: 90rpx;
}
.hero-title {
font-size: 40rpx;
font-weight: bold;
color: #fff;
margin-top: 16rpx;
}
.hero-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 10rpx;
}
.section {
background-color: #fff;
margin: 24rpx;
border-radius: 20rpx;
padding: 10rpx 24rpx;
}
.section-title {
display: block;
font-size: 28rpx;
font-weight: bold;
color: #333;
padding: 20rpx 0 10rpx;
}
.cell {
display: flex;
flex-direction: row;
align-items: center;
padding: 24rpx 0;
border-top: 1rpx solid #f2f2f2;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
background-color: #4CD964;
color: #fff;
font-size: 32rpx;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
}
.cell-info {
flex: 1;
display: flex;
flex-direction: column;
}
.cell-name {
font-size: 28rpx;
color: #333;
font-weight: bold;
}
.cell-sub {
font-size: 22rpx;
color: #999;
margin-top: 6rpx;
}
.join-btn {
background-color: #4CD964;
color: #fff;
border: none;
border-radius: 40rpx;
font-size: 24rpx;
padding: 8rpx 28rpx;
}
.tip {
text-align: center;
padding: 30rpx;
}
.tip text {
font-size: 24rpx;
color: #aaa;
}
</style>
@@ -0,0 +1,113 @@
<template>
<view class="guide-container">
<!-- 轮播图形式的引导页 -->
<swiper class="swiper" :indicator-dots="true" :autoplay="false" @change="onChange">
<swiper-item v-for="(item, index) in guideList" :key="index">
<image :src="item.image" mode="aspectFill" class="guide-image" />
<text class="guide-text">{{ item.text }}</text>
</swiper-item>
</swiper>
<!-- 最后一张显示进入按钮 -->
<button v-if="currentIndex === guideList.length - 1" class="enter-btn" @click="enterApp">
立即体验
</button>
<!-- 跳过按钮 -->
<text class="skip-btn" @click="enterApp">跳过</text>
</view>
</template>
<script setup lang="uts">
import userState, { isLoggedIn } from '@/stores/user.uts'
import { appState } from '@/stores/app.uts'
import messageService from '@/services/websocket/message-service.uts'
// 响应式数据
const currentIndex = ref<number>(0)
type IList = { image : string, text : string }
// 引导页数据
const guideList = ref<Array<IList>>([
{ image: '/static/guide1.png', text: '功能介绍一' },
{ image: '/static/guide2.png', text: '功能介绍二' },
{ image: '/static/guide3.png', text: '功能介绍三' }
])
const isLogin = computed(() : boolean => {
return isLoggedIn.value
})
// 轮播切换事件
const onChange = (e : UniSwiperChangeEvent) => {
currentIndex.value = e.detail.current
}
// 进入应用
const enterApp = () => {
uni.setStorageSync('hasGuide', true)
if (isLoggedIn.value) {
// 连接实时消息通道(使用后端 appConf.socketUrl 与真实 token
messageService.connectIfNeeded(appState.systemConf?.socketUrl ?? null)
uni.switchTab({
url: '/pages/index/index'
})
} else {
uni.redirectTo({
url: '/pages/login/index'
})
}
}
// 页面生命周期 - 检查是否首次启动
onLoad(() => {
const hasGuide = uni.getStorageSync('hasGuide') ?? null
if (hasGuide != null) {
// 非首次启动,直接跳转首页
enterApp()
}
})
</script>
<style>
.guide-container {
position: relative;
height: 100%;
}
.swiper {
height: 100%;
}
.guide-image {
width: 100%;
height: 80%;
}
.guide-text {
text-align: center;
padding: 30rpx;
font-size: 32rpx;
color: #333;
}
.enter-btn {
position: absolute;
bottom: 200rpx;
left: 50%;
transform: translateX(-50%);
width: 300rpx;
height: 80rpx;
line-height: 80rpx;
background: #007aff;
color: #fff;
border-radius: 40rpx;
}
.skip-btn {
position: absolute;
top: 80rpx;
right: 40rpx;
padding: 10rpx 30rpx;
background: rgba(0, 0, 0, 0.3);
color: #fff;
border-radius: 30rpx;
font-size: 28rpx;
}
</style>
@@ -0,0 +1,623 @@
<template>
<!-- #ifdef APP -->
<scroll-view style="flex: 1;" enable-back-to-top="true">
<!-- #endif -->
<view class="page">
<view class="-status-bar"></view>
<!-- 功能模块入口(彩色区块) -->
<scroll-view class="function-modules" direction="horizontal" :show-scrollbar="false">
<view v-for="(module, index) in functionModules" :key="index" class="module-item"
:style="{ backgroundColor: module.bgColor }" @click="navigateToModule(module.path)">
<image class="module-icon" :src="module.icon" mode="aspectFit"></image>
<text class="module-name">{{ module.name }}</text>
<text class="module-desc">{{ module.desc }}</text>
</view>
</scroll-view>
<!-- 同城/附近切换栏 -->
<view class="tabsbox" style="justify-content: space-between;">
<text v-for="(tab, index) in tabs" :key="index" class="tabsbox-txt" style="flex:1"
:class="{ ' tabsbox-active-txt': activeTab === index }" @click="activeTab = index">
{{ tab }}
</text>
</view>
<list-view style="flex: 1;">
<list-item v-for="(user, index) in filteredUsers" :key="user.id" @click="viewUserProfile(user.id)">
<view class="chat-item">
<view class="chat-item-left">
<image class="avatar-square" :src="user.avatar" mode="aspectFill"></image>
</view>
<view class="chat-item-center" style="flex-direction: column; align-items: flex-start;">
<view style="flex-flow: row;">
<text class="user-name">{{ user.nickname }}</text>
<view class="user-badges">
<text v-if="user.vip" class="user-badge" style="background-color: #FFCC00;">V</text>
<text v-if="user.verified" class="user-badge" style="background-color: #09b400;">
R</text>
<text v-if="user.live" class="user-badge"
style="background-color: #FF3B30;">L</text>
</view>
</view>
<view class="user-info">
<text v-if="user.location" class="user-info-i"> {{ user.location }} </text>
<text v-if="user.age" class="user-info-i"> {{ user.age }}岁 </text>
<text v-if="user.height" class="user-info-i"> {{ user.height }}cm </text>
<text v-if="user.profession" class="user-info-i"> {{ user.profession }} </text>
</view>
<text v-if="user.signature" class="user-signature">{{ user.signature }}</text>
</view>
<view class="chat-item-right">
<button class="user-chat-btn" @click.stop="startChat(user.id)">搭讪</button>
</view>
</view>
</list-item>
</list-view>
<!-- 你可能感兴趣的人(右下角提示框) -->
<view class="interest-tip" @click="showInterestUsers">
<text class="tip-text">你可能感兴趣的人</text>
<uni-icons type="arrowright" size="16" color="#fff"></uni-icons>
</view>
</view>
<!-- #ifdef APP -->
</scroll-view>
<!-- #endif -->
</template>
<script setup lang="uts">
import { ref, reactive, computed, onMounted } from 'vue'
import { IGrid, IUser } from '@/types/index.uts'
import { Api } from "@/common/api-service.uts"
// 功能模块数据(参考第1张图彩色模块)
const functionModules = reactive<IGrid[]>([
{
name: '心动聊天',
desc: '玩游戏交朋友',
bgColor: '#FF6B6B',
icon: '/static/icons/chatroom.png',
path: '/pages/chatroom/index'
},
{
name: '免费群聊',
desc: '多人实时聊天',
bgColor: '#4CD964',
icon: '/static/icons/group.png',
path: '/pages/group-chat/index'
},
{
name: '神兽宠物',
desc: '养宠物交朋友',
bgColor: '#5AC8FA',
icon: '/static/icons/pet.png',
path: '/pages/pet-social/index'
},
{
name: '真人匹配',
desc: '真人线上奔现',
bgColor: '#FFCC00',
icon: '/static/icons/match.png',
path: '/pages/real-match/index'
},
{
name: '我的好友',
desc: '好友与请求',
bgColor: '#9B59B6',
icon: '/static/logo.png',
path: '/pages/friend/index'
}
] as IGrid[])
// 同城/附近切换标签
const tabs = ref(['同城', '附近'])
const activeTab = ref(0) // 0:同城, 1:附近
// 在线匹配数据
const matchInfo = reactive({
distance: '25.05km',
onlineCount: 473
})
// 用户卡片数据(整合三张图用户信息)
const users = reactive<IUser[]>([
{
id: 1,
nickname: '萌小依',
realname: true,
location: '南充',
age: 21,
height: 170,
profession: '研发',
signature: '我是个坚定的缘分信徒。相信在茫茫人海中...',
avatar: 'https://randomuser.me/api/portraits/women/44.jpg',
vip: false,
verified: true,
live: false
},
{
id: 2,
nickname: '董小姐',
realname: true,
location: '德阳',
age: 26,
height: 163,
profession: '',
signature: '人生苦短,三万天而已,烂事就得...',
avatar: 'https://randomuser.me/api/portraits/women/68.jpg',
vip: false,
verified: true,
live: false
},
{
id: 3,
nickname: '贝儿妹妹',
realname: true,
location: '沈阳',
age: 27,
height: 163,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/women/23.jpg',
vip: false,
verified: true,
live: true // 直播中
},
{
id: 4,
nickname: '积极的红薯',
realname: true,
location: '南充',
age: 31,
height: 167,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
vip: true,
verified: true,
live: false
},
{
id: 5,
nickname: '萌小依',
realname: true,
location: '南充',
age: 21,
height: 170,
profession: '研发',
signature: '我是个坚定的缘分信徒。相信在茫茫人海中...',
avatar: 'https://randomuser.me/api/portraits/women/44.jpg',
vip: false,
verified: true,
live: false
},
{
id: 6,
nickname: '董小姐',
realname: true,
location: '德阳',
age: 26,
height: 163,
profession: '',
signature: '人生苦短,三万天而已,烂事就得...',
avatar: 'https://randomuser.me/api/portraits/women/68.jpg',
vip: false,
verified: true,
live: false
},
{
id: 7,
nickname: '贝儿妹妹',
realname: true,
location: '沈阳',
age: 27,
height: 163,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/women/23.jpg',
vip: false,
verified: true,
live: true // 直播中
},
{
id: 8,
nickname: '积极的红薯',
realname: true,
location: '南充',
age: 31,
height: 167,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
vip: true,
verified: true,
live: false
},
{
id: 9,
nickname: '萌小依',
realname: true,
location: '南充',
age: 21,
height: 170,
profession: '研发',
signature: '我是个坚定的缘分信徒。相信在茫茫人海中...',
avatar: 'https://randomuser.me/api/portraits/women/44.jpg',
vip: false,
verified: true,
live: false
},
{
id: 10,
nickname: '董小姐',
realname: true,
location: '德阳',
age: 26,
height: 163,
profession: '',
signature: '人生苦短,三万天而已,烂事就得...',
avatar: 'https://randomuser.me/api/portraits/women/68.jpg',
vip: false,
verified: true,
live: false
},
{
id: 3,
nickname: '贝儿妹妹',
realname: true,
location: '沈阳',
age: 27,
height: 163,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/women/23.jpg',
vip: false,
verified: true,
live: true // 直播中
},
{
id: 4,
nickname: '积极的红薯',
realname: true,
location: '南充',
age: 31,
height: 167,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
vip: true,
verified: true,
live: false
},
{
id: 4,
nickname: '积极的红薯',
realname: true,
location: '南充',
age: 31,
height: 167,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
vip: true,
verified: true,
live: false
},
{
id: 4,
nickname: '积极的红薯',
realname: true,
location: '南充',
age: 31,
height: 167,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
vip: true,
verified: true,
live: false
},
{
id: 4,
nickname: '积极的红薯',
realname: true,
location: '南充',
age: 31,
height: 167,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
vip: true,
verified: true,
live: false
},
{
id: 4,
nickname: '积极的红薯',
realname: true,
location: '南充',
age: 31,
height: 167,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
vip: true,
verified: true,
live: false
},
{
id: 4,
nickname: '积极的红薯',
realname: true,
location: '南充',
age: 31,
height: 167,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
vip: true,
verified: true,
live: false
},
{
id: 4,
nickname: '积极的红薯',
realname: true,
location: '南充',
age: 31,
height: 167,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
vip: true,
verified: true,
live: false
},
{
id: 4,
nickname: '积极的红薯',
realname: true,
location: '南充',
age: 31,
height: 167,
profession: '',
signature: '',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
vip: true,
verified: true,
live: false
}
] as IUser[])
// 默认选中"交友"
const activeNav = ref(0)
// 计算属性:根据切换标签过滤用户(简化示例)
const filteredUsers = computed<IUser[]>(() => {
return users
})
// 将后端 user_profile 列表项映射为前端展示用的 IUser
const mapUser = (item : UTSJSONObject) : IUser => {
const city = item.getString('residecity') ?? ''
const province = item.getString('resideprovince') ?? ''
const location = city.length > 0 ? city : province
const age = item.getNumber('age') ?? 0
const height = item.getNumber('height') ?? 0
const isOnline = item.getBoolean('is_online') ?? false
return {
id: item.getNumber('uid') ?? 0,
nickname: item.getString('nickname') ?? '',
realname: false,
location: location,
age: age,
height: height,
profession: '',
signature: item.getString('bio') ?? '',
avatar: item.getString('avatar') ?? '',
vip: false,
verified: false,
live: isOnline
} as IUser
}
// 拉取附近/同城真实用户,成功则替换列表,失败保留默认假数据
const loadNearbyUsers = async () => {
try {
let data : UTSJSONObject | null = null
try {
data = await Api.user.local({})
} catch (e) {
console.warn('本地同城加载失败,尝试在线列表', e)
}
// 本地同城为空(如未设置城市)时,回退到在线活跃用户
const localList = data == null ? null : data.getJSONArray('list')
if (localList == null || localList.size() == 0) {
try {
data = await Api.user.online({})
} catch (e) {
console.warn('在线列表加载失败', e)
}
}
const list = data == null ? null : data.getJSONArray('list')
if (list != null && list.size() > 0) {
const mapped : IUser[] = []
for (let i = 0; i < list.size(); i++) {
const item = list.get(i) as UTSJSONObject
if (item != null) {
mapped.push(mapUser(item))
}
}
if (mapped.length > 0) {
users.splice(0, users.length, ...mapped)
}
}
} catch (error) {
console.warn('加载附近用户失败,使用默认数据', error)
}
}
// 方法:导航到功能模块
const navigateToModule = (path : string) => {
uni.navigateTo({
"url": path
})
}
// 方法:开始匹配
const startMatching = () => {
uni.showToast({ title: '开始匹配附近的人...', icon: 'loading' })
// 实际项目中调用匹配API
}
// 方法:发起聊天
const startChat = (userId : number) => {
uni.navigateTo({ url: `/pages/chat/chat?userId=${userId}` })
}
// 方法:查看用户资料
const viewUserProfile = (userId : number) => {
uni.navigateTo({ url: `/pages/user/profile?id=${userId}` })
}
// 方法:显示感兴趣的人
const showInterestUsers = () => {
uni.showToast({ title: '显示你可能感兴趣的人', icon: 'none' })
}
onMounted(() => {
console.log('社交交友页面加载完成')
loadNearbyUsers()
})
onLoad(() => {
loadNearbyUsers()
})
</script>
<style>
/* 功能模块区(彩色区块) */
.function-modules {
display: flex;
flex-flow: row nowrap;
}
.module-item {
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 5rpx 15rpx rgba(0, 0, 0, 0.1);
border-radius: 20rpx;
margin: 20rpx 10rpx;
padding: 15rpx;
}
.module-icon {
width: 64rpx;
height: 64rpx;
margin-bottom: 20rpx;
}
.module-name {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 10rpx;
}
.module-desc {
font-size: 24rpx;
opacity: 0.9;
}
/* 同城/附近切换栏 */
.tab-switch {
display: flex;
flex-flow: row nowrap;
justify-content: center;
align-items: center;
margin: 0 10rpx;
margin-bottom: 10rpx;
border-radius: 50rpx;
padding: 10rpx;
}
.tab-item {
flex: 1;
text-align: center;
padding: 10rpx 0;
border-radius: 40rpx;
font-size: 28rpx;
color: #666;
}
.tabactive {
background-color: #FF6B6B;
color: #fff;
font-weight: bold;
}
/* 用户列表 */
.user-name {
font-size: 28rpx;
font-weight: bold;
margin-right: 15rpx;
color: #5f5f5f;
}
.user-badges {
display: flex;
flex-flow: row nowrap;
}
.user-badge {
padding: 3rpx 9rpx;
margin: 3rpx;
border-radius: 15rpx;
font-size: 14rpx;
color: #fff;
}
.user-info {
flex-flow: row nowrap;
}
.user-info-i {
margin: 5rpx 3rpx;
padding: 3rpx 6rpx;
border-radius: 25rpx;
background: rgba(255, 255, 255, 0.3);
font-size: 18rpx;
}
.user-signature {
font-size: 16rpx;
color: #333;
overflow: hidden;
}
.user-chat-btn {
background-color: #FF6B6B;
color: #fff;
border: none;
border-radius: 50rpx;
padding: 5rpx 15rpx;
font-size: 16rpx;
}
/* 你可能感兴趣的人提示框 */
.interest-tip {
position: fixed;
right: 30rpx;
bottom: 150rpx;
background-color: #FF6B6B;
padding: 15rpx 25rpx;
border-radius: 50rpx;
display: flex;
align-items: center;
box-shadow: 0 5rpx 15rpx rgba(255, 107, 107, 0.3);
}
.tip-text {
font-size: 26rpx;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
<template>
<!-- #ifdef APP -->
<scroll-view style="flex:1">
<!-- #endif -->
<!-- #ifdef APP -->
</scroll-view>
<!-- #endif -->
</template>
<script setup>
</script>
<style>
</style>
@@ -0,0 +1,473 @@
<template>
<view class="card-panel">
<!-- 用户信息 -->
<view class="card-panel-header">
<view class="user-info" @click="onUserClick">
<image class="avatar" :src="moment.userAvatar" mode="aspectFill"></image>
<view class="user-details">
<view class="user-name-row">
<text class="user-name">{{ moment.userName }}</text>
<text v-if="moment.userVip" class="vip-badge">V</text>
<text v-if="moment.hot" class="hot-badge">🔥</text>
</view>
<view class="meta-info">
<text class="location">{{ moment.location }}</text>
<text class="time">{{ formatTime(moment.time) }}</text>
</view>
</view>
</view>
<view class="more-btn" @click="onMoreClick">
<uni-icons type="more" size="20" color="#999"></uni-icons>
</view>
</view>
<!-- 动态内容 -->
<view class="card-panel-body" @click="onContentClick">
<text class="content-text">{{ moment.content }}</text>
<!-- 图片内容 -->
<view v-if="moment.type === 'image' && moment.images.length > 0" class="image-content">
<view v-if="moment.images.length == 1" class="single-image" @click="previewImage(0)">
<image class="image-item" :src="moment.images[0]" mode="aspectFill"></image>
</view>
<view v-else-if="moment.images.length < 3" class="multi-images-row">
<view v-for="(img, index) in moment.images" :key="index" class="image-item-small"
@click="previewImage(index)">
<image class="image" :src="img" mode="aspectFill"></image>
</view>
</view>
<view v-else class="multi-images-grid">
<view v-for="(img, index) in moment.images.slice(0, 4)" :key="index" class="grid-image"
:class="{ 'last-image': index == 3 && moment.images.length > 4 }" @click="previewImage(index)">
<image class="image" :src="img" mode="aspectFill"></image>
<view v-if="index == 3 && moment.images.length > 4" class="image-count">
+{{ moment.images.length - 4 }}
</view>
</view>
</view>
</view>
<!-- 视频内容 -->
<view v-else-if="moment.type === 'video'" class="video-content" @click="playVideo">
<image v-if="moment.images.length > 0" class="video-cover" :src="moment.images[0] " mode="aspectFill">
</image>
<view class="video-play-btn">
<uni-icons type="play" size="40" color="#fff"></uni-icons>
</view>
<view class="video-duration">03:25</view>
</view>
<!-- 标签 -->
<view v-if="moment.tags != null" class="moment-tags">
<view v-for="(tag, index) in moment.tags" :key="index" class="tag-item">
<text class="tag-text">#{{ tag }}</text>
</view>
</view>
</view>
<!-- 互动操作栏 -->
<view class="card-panel-footer">
<view class="action-item" @click="onLikeClick">
<uni-icons :type="moment.isLiked ? 'heart-filled' : 'heart'" size="20"
:color="moment.isLiked ? '#FF2D55' : '#666'"></uni-icons>
<text class="action-text" :style="{ color: moment.isLiked ? '#FF2D55' : '#666' }">
{{ formatNumber(moment.likes) }}
</text>
</view>
<view class="action-item" @click="onCommentClick">
<uni-icons type="chat" size="20" color="#666"></uni-icons>
<text class="action-text">{{ formatNumber(moment.comments) }}</text>
</view>
<view class="action-item" @click="onShareClick">
<uni-icons type="redo" size="20" color="#666"></uni-icons>
<text class="action-text">{{ formatNumber(moment.shares) }}</text>
</view>
<view class="action-item distance" v-if="moment.distance !=0 && moment.distance < 50">
<uni-icons type="location" size="16" color="#999"></uni-icons>
<text class="distance-text">{{ formatDistance(moment.distance) }}</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { IMoment } from '@/types/dynamicType.uts'
const props = defineProps<{
moment : IMoment
}>()
const emit = defineEmits<{
like : [id: number]
comment : [id: number]
share : [id: number]
more : [id: number]
}>()
// 格式化时间
const formatTime = (timestamp : number) : string => {
const now = Date.now()
const diff = now - timestamp
if (diff < 60000) { // 1分钟内
return '刚刚'
} else if (diff < 3600000) { // 1小时内
return Math.floor(diff / 60000) + '分钟前'
} else if (diff < 86400000) { // 1天内
return Math.floor(diff / 3600000) + '小时前'
} else if (diff < 604800000) { // 1周内
return Math.floor(diff / 86400000) + '天前'
} else {
const date = new Date(timestamp)
return `${date.getMonth() + 1}月${date.getDate()}日`
}
}
// 格式化数字
const formatNumber = (num : number) : string => {
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k'
}
return num.toString()
}
// 格式化距离
const formatDistance = (distance : number) : string => {
if (distance < 1) {
return '<1km'
} else if (distance < 10) {
return distance.toFixed(1) + 'km'
} else {
return Math.floor(distance) + 'km'
}
}
// 点击用户
const onUserClick = () => {
console.log('点击用户:', props.moment.userName)
uni.navigateTo({
url: `/pages/user-detail/user-detail?id=${props.moment.userId}`
})
}
// 点击更多
const onMoreClick = () => {
emit('more', props.moment.id)
}
// 点击内容
const onContentClick = () => {
console.log('点击动态内容:', props.moment.id)
let page = `/pages/moment/details?id=${props.moment.id}`;
console.log(page)
uni.navigateTo({
url: page
})
}
// 预览图片
const previewImage = (index : number) => {
console.log('预览图片:', index)
uni.previewImage({
current: index,
urls: props.moment.images
})
}
// 播放视频
const playVideo = () => {
console.log('播放视频')
uni.navigateTo({
url: `/pages/video-player/video-player?url=${encodeURIComponent(props.moment.videoUrl)}`
})
}
// 点赞
const onLikeClick = () => {
emit('like', props.moment.id)
}
// 评论
const onCommentClick = () => {
emit('comment', props.moment.id)
}
// 分享
const onShareClick = () => {
emit('share', props.moment.id)
}
</script>
<style>
/* 用户信息 */
.user-info {
display: flex;
flex-flow: row nowrap;
align-items: center;
flex: 1;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 20rpx;
background-color: #f0f0f0;
}
.user-details {
flex: 1;
}
.user-name-row {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-bottom: 8rpx;
}
.user-name {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-right: 10rpx;
max-width: 200rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vip-badge {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #fff;
font-size: 20rpx;
padding: 3rpx 10rpx;
border-radius: 20rpx;
margin-right: 10rpx;
}
.hot-badge {
background-color: #f30f025c;
color: #fff;
font-size: 20rpx;
padding: 3rpx 10rpx;
border-radius: 20rpx;
}
.meta-info {
display: flex;
flex-flow: row nowrap;
align-items: center;
flex-wrap: wrap;
}
.location,
.time {
font-size: 24rpx;
color: #999;
margin-right: 20rpx;
}
.more-btn {
width: 60rpx;
height: 60rpx;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
background-color: #f8f8f8;
}
/* 动态内容 */
.moment-content {
margin-bottom: 25rpx;
}
.content-text {
font-size: 30rpx;
color: #333;
line-height: 1.5;
margin-bottom: 20rpx;
display: flex;
overflow: hidden;
}
/* 图片内容 */
.image-content {
margin: 20rpx 0;
}
.single-image {
width: 100%;
height: 400rpx;
border-radius: 20rpx;
overflow: hidden;
}
.image-item {
width: 100%;
height: 100%;
background-color: #f0f0f0;
}
.multi-images-row {
display: flex;
flex-flow: row nowrap;
margin-bottom: 20rpx;
}
.image-item-small {
flex: 1;
height: 375rpx;
border-radius: 20rpx;
overflow: hidden;
padding: 5rpx;
}
.image-item-small .image {
width: 100%;
height: 100%;
background-color: #f0f0f0;
}
.multi-images-grid {
display: flex;
flex-flow: row wrap;
overflow: hidden;
}
.grid-image {
margin: 5rpx;
position: relative;
flex-basis: 210rpx;
height: 230rpx;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.grid-image .image {
width: 100%;
height: 100%;
background-color: #f0f0f0;
}
.grid-image.last-image::after {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.image-count {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 40rpx;
color: #fff;
font-weight: bold;
z-index: 1;
}
/* 视频内容 */
.video-content {
position: relative;
height: 400rpx;
border-radius: 20rpx;
overflow: hidden;
margin: 20rpx 0;
}
.video-cover {
width: 100%;
height: 100%;
background-color: #000;
}
.video-play-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100rpx;
height: 100rpx;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
}
.video-duration {
position: absolute;
bottom: 20rpx;
right: 20rpx;
padding: 8rpx 20rpx;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 20rpx;
}
/* 标签 */
.moment-tags {
display: flex;
flex-flow: row nowrap;
margin-top: 20rpx;
}
.tag-item {
padding: 8rpx 20rpx;
background-color: #f0f0f0;
border-radius: 20rpx;
}
.tag-text {
font-size: 24rpx;
color: #007AFF;
}
/* 互动操作栏 */
.action-bar {
display: flex;
flex-flow: row nowrap;
align-items: center;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
}
.action-item {
display: flex;
align-items: center;
margin-right: 40rpx;
padding: 10rpx 0;
}
.action-item .uni-icons {
margin-right: 10rpx;
}
.action-text {
font-size: 24rpx;
color: #666;
}
.action-item.distance {
margin-left: auto;
margin-right: 0;
}
.distance-text {
font-size: 24rpx;
color: #999;
margin-left: 8rpx;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,451 @@
<template>
<view class="page">
<!-- 顶部导航栏 -->
<view class="page-nav">
<view class="page-nav-left">
<scroll-view class="tabsbox" :show-scrollbar="false" direction="horizontal">
<view v-for="(tab, index) in tabs" :key="index" class="tabsbox-item"
:class="{ 'tabsbox-active': activeTab === index }" @click="switchTab(index)">
<text class="tabsbox-text" :class="{'tabsbox-active-txt': activeTab == index }">{{ tab.name }}</text>
<!-- <text v-if="tab.badge" class="badge">{{ tab.badge }}</text> -->
</view>
</scroll-view>
</view>
<view class="page-nav-right">
<view class="icon-btn" @click="onSearchClick">
<uni-icons type="search" size="24" color="#333"></uni-icons>
</view>
<view class="icon-btn" @click="onPublishClick">
<uni-icons type="plusempty" size="24" color="#333"></uni-icons>
</view>
</view>
</view>
<view class="page-body" style="">
<scroll-view class="moment-list" :show-scrollbar="false" direction="vertical" :refresher-enabled="true" :refresher-triggered="refreshing"
@refresherrefresh="onRefresh" @scrolltolower="onLoadMore" style="padding-bottom: 96rpx;">
<!-- 置顶动态 -->
<view v-if="pinnedMoments.length > 0" class="pinned-section">
<text class="section-title">置顶动态</text>
<view v-for="moment in pinnedMoments" :key="'pinned-' + moment.id" class="moment-item pinned">
<moment-card :moment="moment" @like="onLike" @comment="onComment" @share="onShare"
@more="onMore" />
</view>
</view>
<!-- 推荐动态 -->
<view class="recommend-section" v-if="activeTab == 0">
<text class="section-title">推荐动态</text>
<view v-for="moment in recommendMoments" :key="'recommend-' + moment.id" class="moment-item">
<moment-card :moment="moment" @like="onLike" @comment="onComment" @share="onShare"
@more="onMore" />
</view>
</view>
<!-- 关注动态 -->
<view class="follow-section" v-if="activeTab == 1">
<view v-if="followMoments.length > 0">
<view v-for="moment in followMoments" :key="'follow-' + moment.id" class="moment-item">
<moment-card :moment="moment" @like="onLike" @comment="onComment" @share="onShare"
@more="onMore" />
</view>
</view>
<view v-else class="empty-state">
<uni-icons type="person" size="60" color="#ccc"></uni-icons>
<text class="empty-text">还没有关注的好友动态</text>
<button class="empty-btn" @click="findFriends">去发现好友</button>
</view>
</view>
<!-- 附近动态 -->
<view class="nearby-section" v-if="activeTab == 2">
<view class="section-header">
<text class="section-title">附近动态</text>
<text class="section-subtitle">{{ nearbyCount }}条动态</text>
</view>
<view v-for="moment in nearbyMoments" :key="'nearby-' + moment.id" class="moment-item">
<moment-card :moment="moment" @like="onLike" @comment="onComment" @share="onShare"
@more="onMore" />
</view>
</view>
<!-- 热门动态 -->
<view class="hot-section" v-if="activeTab == 3">
<view class="section-header">
<text class="section-title">热门动态</text>
<view class="hot-tags">
<view v-for="(tag, index) in hotTags" :key="index" class="hot-tag"
:class="{ active: activeHotTag == index }" @click="switchHotTag(index)">
<text class="tag-text">{{ tag }}</text>
</view>
</view>
</view>
<view v-for="moment in hotMoments" :key="'hot-' + moment.id" class="moment-item">
<moment-card :moment="moment" @like="onLike" @comment="onComment" @share="onShare"
@more="onMore" />
</view>
</view>
<!-- 加载状态 -->
<!-- <view class="load-status">
<uni-load-more :status="loadStatus" />
</view> -->
</scroll-view>
<!-- 发布按钮 -->
<view class="publish-fab" @click="onPublishClick">
<uni-icons type="plus" size="30" color="#fff"></uni-icons>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, computed, onMounted } from 'vue'
import MomentCard from './components/MomentCard.uvue'
import {IMoment,ITab} from '@/types/dynamicType.uts'
import Api from '@/common/api-service.uts'
// 响应式数据
const activeTab = ref(0)
const activeHotTag = ref(0)
const refreshing = ref(false)
const loading = ref(false)
const noMore = ref(false)
const page = ref(1)
const pageSize = 10
const moments = reactive<IMoment[]>([])
const scopes = ['recommend', 'follow', 'nearby', 'hot']
const nearbyCount = computed(() => moments.length)
// 标签页数据
const tabs = reactive<ITab[]>([
{ name: '推荐', badge: 0 },
{ name: '关注', badge: 3 },
{ name: '附近', badge: 12 },
{ name: '热门', badge: 0 }
])
// 热门标签
const hotTags = reactive(['全部', '生活', '情感', '旅行', '美食', '游戏'])
// 计算属性
const pinnedMoments = computed<IMoment[]>(() => moments.filter(m => m.isPinned))
const recommendMoments = computed<IMoment[]>(() => moments)
const followMoments = computed<IMoment[]>(() => moments)
const nearbyMoments = computed<IMoment[]>(() => moments)
const hotMoments = computed<IMoment[]>(() => moments)
const loadStatus = computed(() => {
if (loading.value) return 'loading'
if (noMore.value) return 'noMore'
return 'more'
})
// 加载数据
const loadData = (reset : boolean = false) => {
if (loading.value) return
loading.value = true
const p = reset ? 1 : page.value
Api.moment.list({ scope: scopes[activeTab.value], page: p, page_size: pageSize })
.then((res : UTSJSONObject) => {
const list = (res.get('list') as Array<IMoment>) ?? []
if (reset) {
moments.splice(0, moments.length)
page.value = 1
}
list.forEach((m : IMoment) => moments.push(m))
page.value = p + 1
noMore.value = list.length < pageSize
})
.catch(() => {})
.finally(() => { loading.value = false })
}
// 页面加载
onMounted(() => { loadData(true) })
onShow(() => { loadData(true) })
// 切换标签页
const switchTab = (index : number) => {
activeTab.value = index
page.value = 1
noMore.value = false
loadData(true)
}
// 切换热门标签
const switchHotTag = (index : number) => {
activeHotTag.value = index
console.log('切换热门标签:', hotTags[index])
// 这里应该根据标签筛选热门动态
}
// 点击搜索
const onSearchClick = () => {
console.log('点击搜索')
uni.navigateTo({
url: '/pages/search/search'
})
}
// 点击发布
const onPublishClick = () => {
console.log('点击发布')
uni.navigateTo({
url: '/pages/moment/publish'
})
}
// 点赞动态
const onLike = (momentId : number) => {
const m = moments.find(x => x.id === momentId)
if (! m) return
const willLike = ! m.isLiked
m.isLiked = willLike
m.likes += willLike ? 1 : -1
Api.moment.like({ dynamic_id: momentId })
.catch(() => { m.isLiked = !willLike; m.likes += willLike ? -1 : 1 })
}
// 评论动态
const onComment = (momentId : number) => {
uni.navigateTo({ url: `/pages/moment/details?id=${momentId}` })
}
// 分享动态
const onShare = (momentId : number) => {
console.log('分享动态:', momentId)
uni.showActionSheet({
itemList: ['分享给好友', '分享到朋友圈', '复制链接', '保存图片'],
success: (res) => {
console.log('选择了分享方式:', res.tapIndex)
uni.showToast({
title: '分享成功',
icon: 'success',
duration: 2000
})
}
})
}
// 更多操作
const onMore = (momentId : number) => {
console.log('更多操作:', momentId)
uni.showActionSheet({
itemList: ['举报', '不感兴趣', '屏蔽用户', '保存到相册'],
success: (res) => {
const actions = ['report', 'notInterested', 'block', 'save']
const action = actions[res.tapIndex]
console.log('选择了:', action)
if (action === 'report') {
uni.navigateTo({
url: '/pages/report/report?momentId=' + momentId
})
} else {
uni.showToast({
title: '操作成功',
icon: 'success',
duration: 1500
})
}
}
})
}
// 下拉刷新
const onRefresh = () => {
refreshing.value = true
page.value = 1
loadData(true)
setTimeout(() => { refreshing.value = false }, 600)
}
// 上拉加载更多
const onLoadMore = () => {
if (loading.value || noMore.value) return
loadData(false)
}
// 发现好友
const findFriends = () => {
console.log('去发现好友')
uni.switchTab({
url: '/pages/home/home'
})
}
</script>
<style>
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.header-right {
display: flex;
flex-flow: row nowrap;
}
.icon-btn {
width: 60rpx;
height: 60rpx;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
background-color: #f8f8f8;
}
/* 分类标签 */
.category-tabs {
display: flex;
flex-flow: row nowrap;
padding: 20rpx 0;
background-color: #fff;
border-bottom: 1rpx solid #eee;
}
.tab-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 30rpx;
padding: 10rpx 0;
}
.tab-text {
font-size: 30rpx;
color: #666;
}
.tabactive {
color: #FF6B6B;
font-weight: bold;
}
.badge {
position: absolute;
top: 0;
right: 0;
height: 32rpx;
text-align: center;
background-color: #FF6B6B;
color: #fff;
font-size: 20rpx;
border-radius: 16rpx;
transform: translate(50%, -50%);
}
/* 动态列表 */
.moment-list {
flex: 1;
padding: 0 20rpx 20rpx;
}
.section-title {
font-size: 28rpx;
color: #999;
padding: 20rpx 0 10rpx 20rpx;
}
.pinned-section .section-title {
color: #FF9500;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
background-color: #fff;
border-radius: 20rpx;
margin: 20rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
margin: 20rpx 0 30rpx;
}
.empty-btn {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
color: #fff;
padding: 20rpx 50rpx;
border-radius: 40rpx;
font-size: 28rpx;
border: none;
}
.empty-btn::after {
border: none;
}
/* 附近动态头部 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0 10rpx 20rpx;
}
.section-subtitle {
font-size: 24rpx;
color: #999;
margin-right: 20rpx;
}
/* 热门标签 */
.hot-tags {
display: flex;
flex-flow: row nowrap;
padding: 10rpx;
}
.hot-tag {
padding: 10rpx;
background-color: #f8f8f8;
border-radius: 30rpx;
font-size: 24rpx;
color: #666;
transition: all 0.3s ease;
}
.hot-tag.active {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
color: #fff;
}
/* 加载状态 */
.load-status {
padding: 30rpx 0;
display: flex;
justify-content: center;
}
/* 发布按钮 */
.publish-fab {
position: fixed;
right: 40rpx;
bottom: 140rpx;
width: 100rpx;
height: 100rpx;
background: linear-gradient(to bottom right , #FF6B6B , #FF8E53);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 10rpx 30rpx rgba(255, 107, 107, 0.3);
z-index: 100;
}
</style>
@@ -0,0 +1,655 @@
// pages/dynamic/publish.uts
<template>
<view class="page">
<!-- 顶部导航栏 -->
<view class="page-nav">
<view class="page-nav-left" @click="handleCancel">
<text class="cancel-text">取消</text>
</view>
<view class="page-nav-center">
<text class="nav-title">发布动态</text>
</view>
<view class="page-nav-right">
<button class="publish-btn" :class="{ active: canPublish }" @click="handlePublish">发布</button>
</view>
</view>
<!-- 内容输入区 -->
<view class="content-area">
<textarea class="content-input" placeholder="记录这一刻,晒给懂你的人" v-model="content" maxlength="250"
@input="updateCharCount"></textarea>
<text class="char-count">{{ charCount }}/250</text>
</view>
<!-- 图片上传区 -->
<view class="media-area">
<view class="upload-grid">
<view v-for="(file, index) in files" :key="index" class="grid-item">
<image :src="file.path" class="grid-image" mode="aspectFill" />
<view class="delete-btn" @click="removeImage(index)">
<uni-icons type="closeempty" color="#fff"></uni-icons>
</view>
</view>
<view v-if="files.length < 9" class="grid-item add-item" @click="chooseMedia">
<!-- <view class="add-icon">+</view> -->
<uni-icons type="plusempty" size="48"></uni-icons>
</view>
</view>
</view>
<!-- 功能选项区 -->
<view class="function-area">
<view class="function-item" @click="chooseLocation">
<view class="item-left">
<image src="/static/icons/location.png" class="item-icon" />
<text class="item-label">所在位置</text>
</view>
<view class="item-right">
<text class="item-value">{{ location ?? '未选择' }}</text>
<image src="/static/icons/arrow-right.png" class="arrow-icon" />
</view>
</view>
<view class="function-item" @click="showTopicDialog">
<view class="item-left">
<image src="/static/icons/topic.png" class="item-icon" />
<text class="item-label">添加话题</text>
</view>
<view class="item-right">
<text class="item-value">{{ topics.length > 0 ? `#${topics.join(' #')}` : '选择话题会获得更多互动哦' }}</text>
<image src="/static/icons/arrow-right.png" class="arrow-icon" />
</view>
</view>
<view class="function-item voice-item" @click="toggleRecording">
<view class="item-left">
<image :src="isRecording ? '/static/icons/voice-recording.png' : '/static/icons/voice.png'"
class="item-icon" />
<text class="item-label">{{ isRecording ? '正在录音...' : '语音描述' }}</text>
</view>
<view class="item-right">
<view v-if="audioPath" class="audio-preview">
<image src="/static/icons/play.png" class="play-icon" @click.stop="playAudio" />
<text class="audio-duration">{{ audioDuration }}"</text>
</view>
<image v-else src="/static/icons/arrow-right.png" class="arrow-icon" />
</view>
</view>
</view>
<!-- 话题选择弹窗 -->
<view v-if="showTopicSelector" class="topic-dialog">
<view class="dialog-mask" @click="hideTopicDialog"></view>
<view class="dialog-content">
<view class="dialog-header">
<text class="dialog-title">选择话题</text>
<text class="dialog-close" @click="hideTopicDialog">×</text>
</view>
<view class="topic-list">
<view v-for="(topic, index) in availableTopics" :key="index" class="topic-item"
@click="toggleTopic(topic)">
<text class="topic-text">#{{ topic }}</text>
<view class="topic-check" v-if="topics.includes(topic)">
<image src="/static/icons/checked.png" class="check-icon" />
</view>
</view>
</view>
<view class="dialog-footer">
<button class="confirm-btn" @click="hideTopicDialog">完成</button>
</view>
</view>
</view>
<!-- 位置选择弹窗 -->
<view v-if="showLocationSelector" class="location-dialog">
<view class="dialog-mask" @click="hideLocationDialog"></view>
<view class="dialog-content">
<view class="dialog-header">
<text class="dialog-title">选择位置</text>
<text class="dialog-close" @click="hideLocationDialog">×</text>
</view>
<view class="search-box">
<input v-model="locationSearch" placeholder="搜索地点" class="search-input" />
</view>
<view class="location-list">
<view v-for="(loc, index) in filteredLocations" :key="index" class="location-item"
@click="selectLocation(loc)">
<text class="location-name">{{ loc.name }}</text>
<text class="location-address">{{ loc.address }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import httpApi, { type IFileInfo } from '@/utlis/http-api'
import type { IUploadOption } from '@/types/http.uts'
import Api from '@/common/api-service.uts'
// 响应式数据
type locationItem = { name : string, address : string }
const content = ref<string>('')
const charCount = ref<number>(0)
const location = ref<string>('')
const topics = ref<Array<string>>([])
const audioPath = ref<string>('')
const audioDuration = ref<number>(0)
const isRecording = ref<boolean>(false)
const showTopicSelector = ref<boolean>(false)
const showLocationSelector = ref<boolean>(false)
const locationSearch = ref<string>('')
const recorderManager = ref(null as RecorderManager | null)
const files = ref<IFileInfo[]>([])
// 可用话题列表
const availableTopics = ref<Array<string>>([
'美食分享', '厨房日记', '烹饪技巧', '家常菜', '烘焙时光',
'美食探店', '食材百科', '健康饮食', '快手菜', '创意料理'
])
// 位置列表
const locations = ref<Array<locationItem>>([
{ name: '北京市朝阳区三里屯', address: '北京市朝阳区三里屯街道' },
{ name: '上海市黄浦区外滩', address: '上海市黄浦区外滩街道' },
{ name: '广州市天河区珠江新城', address: '广州市天河区珠江新城' },
{ name: '深圳市南山区科技园', address: '深圳市南山区科技园' },
{ name: '成都市锦江区春熙路', address: '成都市锦江区春熙路' }
])
// 计算属性
const canPublish = computed(() => {
return content.value.trim().length > 0 || files.value.length > 0 || audioPath.value !== ''
})
const filteredLocations = computed<locationItem[]>(() => {
if (locationSearch.value != null) return locations.value
return locations.value.filter(loc =>
loc.name.includes(locationSearch.value) || loc.address.includes(locationSearch.value)
)
})
// 生命周期钩子
onLoad(() => {
// 页面加载时的初始化操作
//updateCharCount()
})
// 方法定义
function updateCharCount() {
charCount.value = content.value.length
}
function chooseMedia() {
// #ifndef WEB
uni.chooseMedia({
count: 9 - files.value.length,
mediaType: ['image', 'video'],
sourceType: ['album', 'camera'],
success: (res) => {
console.log(res)
res.tempFiles.forEach((path : ChooseMediaTempFile) => {
files.value.push({
index: files.value.length ,
name: 'file' + (files.value.length ),
path: path.tempFilePath,
} as IFileInfo)
})
}
})
// #endif
// #ifdef WEB
uni.chooseImage({
count: 9 - files.value.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
console.log(res)
res.tempFilePaths.forEach((file) => {
files.value.push({
index: files.value.length ,
name: 'file' + (files.value.length ),
path: file,
} )
})
}
})
// #endif
}
function removeImage(index : number) {
files.value.splice(index, 1)
}
function chooseLocation() {
showLocationSelector.value = true
}
function showTopicDialog() {
showTopicSelector.value = true
}
function hideTopicDialog() {
showTopicSelector.value = false
}
function hideLocationDialog() {
showLocationSelector.value = false
locationSearch.value = ''
}
function selectLocation(loc : locationItem) {
location.value = loc.name
hideLocationDialog()
}
function toggleTopic(topic : string) {
const index = topics.value.indexOf(topic)
if (index > -1) {
topics.value.splice(index, 1)
} else {
topics.value.push(topic)
}
}
let innerAudioContext : any | null = null
function startRecording() {
isRecording.value = true
audioPath.value = ''
audioDuration.value = 0
// 创建录音管理器
recorderManager.value = uni.getRecorderManager();
// 录音结束事件
recorderManager.value!.onStop((res) => {
console.log(res);
// isRecording.value = false
// audioPath.value = res.tempFilePath
// audioDuration.value = Math.floor(res.duration as number / 1000) // 转换为秒
})
// 开始录音
recorderManager.value!.start({
duration: 60000, // 最长60秒
sampleRate: 44100,
numberOfChannels: 1,
encodeBitRate: 192000,
format: 'mp3'
})
}
function stopRecording() {
if (recorderManager != null) {
recorderManager.value!.stop()
}
}
function toggleRecording() {
if (isRecording.value) {
stopRecording()
} else {
startRecording()
}
}
function playAudio() {
// if (!innerAudioContext) {
// innerAudioContext = uni.createInnerAudioContext()
// }
// if (innerAudioContext.paused) {
// innerAudioContext.src = audioPath.value
// innerAudioContext.play()
// } else {
// innerAudioContext.pause()
// }
}
function handleCancel() {
uni.navigateBack()
}
async function handlePublish() {
if (!canPublish.value) return
uni.showLoading({ title: '发布中...', mask: true })
try {
// 1. 上传图片/视频,拿到可访问 URL
let media : UTSJSONObject[] = []
let hasVideo = false
if (files.value.length > 0) {
const uploaded = await httpApi.uploadMultipleFilesParallel({
url: '/wxchat/api/moment/upload'
} as IUploadOption, files.value)
uploaded.forEach((f) => {
const p = f.path
const isVideo = p.endsWith('.mp4') || p.endsWith('.mov') || p.endsWith('.m4v') || p.endsWith('.avi')
if (isVideo) hasVideo = true
media.push({ type: isVideo ? 2 : 1, url: p } as UTSJSONObject)
})
}
// 2. 计算动态类型:1 文字 / 2 图片 / 3 视频
let type = 1
if (hasVideo) {
type = 3
} else if (media.length > 0) {
type = 2
}
// 3. 提交发布
await Api.moment.create({
content: content.value.trim(),
type: type,
is_public: 1,
location: location.value,
media: media,
hashtags: topics.value
})
uni.hideLoading()
uni.showToast({ title: '发布成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1200)
} catch (error) {
uni.hideLoading()
uni.showToast({ title: '发布失败', icon: 'none' })
console.log('发布失败:', error)
}
}
</script>
<style lang="scss">
/* 内容输入区 */
.content-area {
margin-top: 100rpx;
position: relative;
padding: 16px;
background-color: #fff;
margin-bottom: 8px;
.content-input {
width: 100%;
min-height: 120px;
font-size: 16px;
line-height: 1.5;
color: #333;
border: none;
}
.char-count {
position: absolute;
right: 16px;
bottom: 8px;
font-size: 12px;
color: #999;
}
}
/* 图片上传区 */
.media-area {
padding: 16px;
background-color: #fff;
margin-bottom: 8px;
.upload-grid {
display: flex;
flex-flow: row wrap;
border-radius: 25rpx;
overflow: hidden;
background: none;
.grid-item {
position: relative;
border-radius: 4px;
margin: 3rpx;
overflow: hidden;
background-color: #f5f5f5;
flex: 0 0 222rpx;
height: 222rpx;
background-color: #f5f5f5;
overflow: hidden;
position: relative;
box-sizing: border-box;
.grid-image {
width: 100%;
height: 100%;
transition: transform 0.3s;
}
.delete-btn {
position: absolute;
top: 10rpx;
right: 10rpx;
width: 48rpx;
height: 48rpx;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
&.add-item {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #ccc;
.add-icon {
font-size: 32px;
color: #999;
}
}
}
}
}
/* 功能选项区 */
.function-area {
background-color: #fff;
margin-bottom: 8px;
.function-item {
display: flex;
flex-flow: row;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.item-left {
display: flex;
flex-flow: row;
align-items: center;
.item-icon {
width: 20px;
height: 20px;
margin-right: 12px;
}
.item-label {
font-size: 16px;
color: #333;
}
}
.item-right {
display: flex;
flex-flow: row;
align-items: center;
.item-value {
font-size: 14px;
color: #999;
margin-right: 8px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow-icon {
width: 16px;
height: 16px;
}
.audio-preview {
display: flex;
flex-flow: row;
align-items: center;
.play-icon {
width: 20px;
height: 20px;
margin-right: 8px;
}
.audio-duration {
font-size: 14px;
color: #666;
}
}
}
&.voice-item {
.item-label {
color: #ff2442;
}
}
}
}
/* 弹窗样式 */
.topic-dialog,
.location-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
.dialog-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.dialog-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
border-radius: 12px 12px 0 0;
display: flex;
flex-direction: column;
.dialog-header {
display: flex;
flex-flow: row;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
.dialog-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.dialog-close {
font-size: 24px;
color: #999;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
}
.topic-list,
.location-list {
flex: 1;
padding: 16px;
.topic-item,
.location-item {
display: flex;
flex-flow: row;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.topic-text {
font-size: 16px;
color: #333;
}
.topic-check {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #ff2442;
display: flex;
align-items: center;
justify-content: center;
.check-icon {
width: 12px;
height: 8px;
}
}
.location-name {
font-size: 16px;
color: #333;
margin-bottom: 4px;
}
.location-address {
font-size: 12px;
color: #999;
}
}
}
.search-box {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
.search-input {
width: 100%;
height: 36px;
padding: 0 12px;
border-radius: 18px;
background-color: #f5f5f5;
font-size: 14px;
}
}
.dialog-footer {
padding: 16px;
border-top: 1px solid #f0f0f0;
.confirm-btn {
width: 100%;
height: 44px;
background-color: #ff2442;
color: #fff;
font-size: 16px;
border-radius: 22px;
border: none;
}
}
}
}
</style>
@@ -0,0 +1,133 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;">
<view class="logo-box">
<view class="logo">
<uni-icons type="heart-filled" size="56" color="#fff"></uni-icons>
</view>
<text class="app-name">缘友</text>
<text class="app-slogan">让相遇更有温度</text>
</view>
<view class="list-container">
<view class="list-item" @click="toast('用户协议')">
<text class="list-text">用户协议</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
<view class="list-item" @click="toast('隐私政策')">
<text class="list-text">隐私政策</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
<view class="list-item" @click="toast('去评分')">
<text class="list-text">给我们评分</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
<view class="intro">
<text class="intro-text">
缘友是一款专注于真实社交的交友应用,基于同城与兴趣为你推荐合适的人。
我们倡导真诚、友善的互动,帮助你遇见那个对的人。
</text>
</view>
<text class="version">缘友 v{{ appVersion }}</text>
</scroll-view>
</view>
</template>
<script setup lang="uts">
const appVersion = ref('2.5.0')
function toast(msg : string) {
uni.showToast({ title: msg, icon: 'none' })
}
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.logo-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 0 50rpx;
}
.logo {
width: 140rpx;
height: 140rpx;
border-radius: 36rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 30rpx rgba(255, 107, 107, 0.3);
}
.app-name {
font-size: 40rpx;
font-weight: bold;
color: #333;
margin-top: 24rpx;
}
.app-slogan {
font-size: 26rpx;
color: #999;
margin-top: 10rpx;
}
.list-container {
margin: 0 30rpx;
background-color: #fff;
border-radius: 20rpx;
overflow: hidden;
}
.list-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:active {
background-color: #f8f8f8;
}
.list-text {
font-size: 30rpx;
color: #333;
}
.intro {
margin: 30rpx;
padding: 30rpx;
background-color: #fff;
border-radius: 20rpx;
}
.intro-text {
font-size: 26rpx;
color: #888;
line-height: 1.7;
}
.version {
display: block;
text-align: center;
font-size: 24rpx;
color: #bbb;
margin: 40rpx 0 80rpx;
}
</style>
@@ -0,0 +1,204 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;"
:refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view class="list">
<view v-for="(m, idx) in list" :key="idx" class="moment-item" @click="openDetail(m)">
<view class="m-head">
<image class="m-avatar" :src="imgUrl(m.userAvatar)" mode="aspectFill"></image>
<view class="m-meta">
<text class="m-name">{{ m.userName }}</text>
<text class="m-time">{{ formatTime(m.time) }}</text>
</view>
</view>
<text class="m-content">{{ m.content }}</text>
<view v-if="m.images && m.images.length > 0" class="m-images">
<image v-for="(img, i) in m.images.slice(0,3)" :key="i" class="m-img"
:src="imgUrl(img)" mode="aspectFill"></image>
</view>
<view class="m-foot">
<view class="m-stat"><uni-icons type="heart" size="22" color="#FF2D55"></uni-icons>
<text>{{ m.likes }}</text></view>
<view class="m-stat"><uni-icons type="chatbubble" size="22" color="#999"></uni-icons>
<text>{{ m.comments }}</text></view>
</view>
</view>
<view v-if="list.length === 0 && !loading" class="empty-state">
<uni-icons type="star" size="60" color="#ddd"></uni-icons>
<text class="empty-text">还没有收藏任何动态</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
const BASE = 'https://dev.xixingwl.cn'
type IMoment = {
id : number
uid : number
userName : string
userAvatar : string
content : string
images : string[]
videoUrl : string
type : string
time : number
likes : number
comments : number
shares : number
}
const list = reactive<IMoment[]>([])
const loading = ref<boolean>(false)
const refreshing = ref<boolean>(false)
function imgUrl(src : string) : string {
if (! src) return ''
return src.startsWith('http') ? src : (BASE + src)
}
function formatTime(ts : number) : string {
if (! ts) return ''
const d = new Date(ts)
const pad = (n : number) : string => n < 10 ? '0' + n : '' + n
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function openDetail(m : IMoment) {
uni.navigateTo({ url: '/pages/moment/details?id=' + m.id })
}
function loadData() {
if (loading.value) return
loading.value = true
Api.moment.collectedList({ page: 1, page_size: 20 })
.then((res : UTSJSONObject) => {
const arr = (res.get('list') as Array<IMoment>) ?? []
list.splice(0, list.length)
arr.forEach((m : IMoment) => list.push(m))
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => {
loading.value = false
refreshing.value = false
})
}
function onRefresh() {
refreshing.value = true
loadData()
}
onMounted(() => { loadData() })
onShow(() => { loadData() })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.list {
margin: 24rpx 30rpx;
}
.moment-item {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
}
.m-head {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-bottom: 16rpx;
}
.m-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
background-color: #f0f0f0;
margin-right: 16rpx;
}
.m-meta {
display: flex;
flex-direction: column;
}
.m-name {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.m-time {
font-size: 22rpx;
color: #bbb;
margin-top: 4rpx;
}
.m-content {
font-size: 28rpx;
color: #333;
line-height: 1.5;
display: block;
}
.m-images {
display: flex;
flex-flow: row wrap;
margin-top: 16rpx;
}
.m-img {
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
margin: 0 12rpx 12rpx 0;
background-color: #f0f0f0;
}
.m-foot {
display: flex;
flex-flow: row nowrap;
margin-top: 16rpx;
}
.m-stat {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-right: 40rpx;
font-size: 24rpx;
color: #999;
}
.m-stat text {
margin-left: 6rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
margin-top: 20rpx;
}
</style>
@@ -0,0 +1,212 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;"
:refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view class="list">
<view v-for="(m, idx) in list" :key="idx" class="moment-item" @click="openDetail(m)">
<view class="m-head">
<image class="m-avatar" :src="imgUrl(m.userAvatar)" mode="aspectFill"></image>
<view class="m-meta">
<text class="m-name">{{ m.userName }}</text>
<text class="m-time">{{ formatTime(m.time) }}</text>
</view>
</view>
<text class="m-content">{{ m.content }}</text>
<view v-if="m.images && m.images.length > 0" class="m-images">
<image v-for="(img, i) in m.images.slice(0,3)" :key="i" class="m-img"
:src="imgUrl(img)" mode="aspectFill"></image>
</view>
<view class="m-foot">
<view class="m-stat"><uni-icons type="heart" size="22" color="#FF2D55"></uni-icons>
<text>{{ m.likes }}</text></view>
<view class="m-stat"><uni-icons type="chatbubble" size="22" color="#999"></uni-icons>
<text>{{ m.comments }}</text></view>
<view class="m-stat"><uni-icons type="star" size="22" color="#FF9500"></uni-icons>
<text>{{ m.collectCount }}</text></view>
</view>
</view>
<view v-if="list.length === 0 && !loading" class="empty-state">
<uni-icons type="pyq" size="60" color="#ddd"></uni-icons>
<text class="empty-text">你还没有发布动态</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
const BASE = 'https://dev.xixingwl.cn'
type IMoment = {
id : number
uid : number
userName : string
userAvatar : string
content : string
images : string[]
videoUrl : string
type : string
time : number
likes : number
comments : number
shares : number
collectCount : number
}
const list = reactive<IMoment[]>([])
const loading = ref<boolean>(false)
const refreshing = ref<boolean>(false)
function imgUrl(src : string) : string {
if (! src) return ''
return src.startsWith('http') ? src : (BASE + src)
}
function formatTime(ts : number) : string {
if (! ts) return ''
const d = new Date(ts)
const pad = (n : number) : string => n < 10 ? '0' + n : '' + n
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function openDetail(m : IMoment) {
uni.navigateTo({ url: '/pages/moment/details?id=' + m.id })
}
function loadData() {
if (loading.value) return
loading.value = true
Api.moment.mine({ page: 1, page_size: 50 })
.then((res : UTSJSONObject) => {
const arr = (res.get('list') as Array<IMoment>) ?? []
list.splice(0, list.length)
arr.forEach((m : IMoment) => {
const obj = (m as UTSJSONObject)
const stats = obj.get('stats') as UTSJSONObject | null
m.collectCount = stats != null ? ((stats.get('collect_count') as number) ?? 0) : 0
list.push(m)
})
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => {
loading.value = false
refreshing.value = false
})
}
function onRefresh() {
refreshing.value = true
loadData()
}
onMounted(() => { loadData() })
onShow(() => { loadData() })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.list {
margin: 24rpx 30rpx;
}
.moment-item {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
}
.m-head {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-bottom: 16rpx;
}
.m-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
background-color: #f0f0f0;
margin-right: 16rpx;
}
.m-meta {
display: flex;
flex-direction: column;
}
.m-name {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.m-time {
font-size: 22rpx;
color: #bbb;
margin-top: 4rpx;
}
.m-content {
font-size: 28rpx;
color: #333;
line-height: 1.5;
display: block;
}
.m-images {
display: flex;
flex-flow: row wrap;
margin-top: 16rpx;
}
.m-img {
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
margin: 0 12rpx 12rpx 0;
background-color: #f0f0f0;
}
.m-foot {
display: flex;
flex-flow: row nowrap;
margin-top: 16rpx;
}
.m-stat {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-right: 40rpx;
font-size: 24rpx;
color: #999;
}
.m-stat text {
margin-left: 6rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
margin-top: 20rpx;
}
</style>
@@ -0,0 +1,212 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;"
:refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh"
@scrolltolower="onReachBottom">
<view class="list">
<view v-for="(item, index) in records" :key="index" class="user-item" @click="goProfile(item.id)">
<image class="avatar" :src="item.avatar" mode="aspectFill"></image>
<view class="info">
<view class="name-row">
<text class="nickname">{{ item.nickname }}</text>
<view v-if="item.is_mutual == 1" class="mutual-tag">
<text class="mutual-txt">互相关注</text>
</view>
</view>
<text class="time">{{ item.follow_time }}</text>
</view>
<view v-if="item.is_mutual == 0" class="follow-btn" @click.stop="follow(item)">
<text class="follow-txt">回关</text>
</view>
<uni-icons v-else type="right" size="18" color="#ccc"></uni-icons>
</view>
<view v-if="records.length == 0 && !loading" class="empty-state">
<uni-icons type="staff" size="60" color="#ddd"></uni-icons>
<text class="empty-text">还没有粉丝</text>
</view>
<view v-if="loading" class="loading-tip">
<text class="loading-txt">加载中...</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
import { getUserInfo } from '@/stores/user.uts'
type IFollowUser = {
id : number
nickname : string
avatar : string
is_mutual : number
follow_time : string
}
const records = reactive<IFollowUser[]>([])
const loading = ref<boolean>(false)
const refreshing = ref<boolean>(false)
const page = ref<number>(1)
const size = ref<number>(20)
const total = ref<number>(0)
const uid = ref<number>(0)
function loadData(reset : boolean) {
if (loading.value) return
if (uid.value <= 0) {
const info = getUserInfo()
uid.value = info?.uid ?? 0
}
if (uid.value <= 0) return
loading.value = true
if (reset) page.value = 1
Api.follow.followerList({ uid: uid.value, page: page.value, size: size.value })
.then((res : UTSJSONObject) => {
total.value = (res.get('total') as number) ?? 0
const list = (res.get('list') as Array<IFollowUser>) ?? []
if (reset) records.splice(0, records.length)
list.forEach((r : IFollowUser) => records.push(r))
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => {
loading.value = false
refreshing.value = false
})
}
function onRefresh() {
refreshing.value = true
loadData(true)
}
function onReachBottom() {
if (records.length >= total.value) return
page.value = page.value + 1
loadData(false)
}
function follow(item : IFollowUser) {
if (uid.value <= 0 || item.id <= 0) return
Api.follow.toggle(uid.value, item.id, 1)
.then(() => {
item.is_mutual = 1
uni.showToast({ title: '已回关', icon: 'success' })
})
.catch(() => {
uni.showToast({ title: '操作失败', icon: 'none' })
})
}
function goProfile(targetUid : number) {
if (targetUid <= 0) return
uni.navigateTo({ url: '/pages/user/profile?uid=' + targetUid })
}
onMounted(() => { loadData(true) })
onShow(() => { loadData(true) })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.list {
margin: 24rpx 30rpx;
}
.user-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
padding: 20rpx 24rpx;
background-color: #fff;
border-radius: 16rpx;
margin-bottom: 16rpx;
}
.avatar {
width: 88rpx;
height: 88rpx;
border-radius: 44rpx;
background-color: #eee;
margin-right: 20rpx;
}
.info {
flex: 1;
display: flex;
flex-direction: column;
}
.name-row {
display: flex;
flex-flow: row nowrap;
align-items: center;
}
.nickname {
font-size: 30rpx;
color: #333;
font-weight: 500;
}
.mutual-tag {
margin-left: 14rpx;
padding: 2rpx 12rpx;
border-radius: 16rpx;
background-color: #fff0ec;
}
.mutual-txt {
font-size: 20rpx;
color: #FF6B6B;
}
.time {
font-size: 22rpx;
color: #bbb;
margin-top: 8rpx;
}
.follow-btn {
padding: 10rpx 28rpx;
border-radius: 30rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
}
.follow-txt {
font-size: 24rpx;
color: #fff;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
margin-top: 20rpx;
}
.loading-tip {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx 0;
}
.loading-txt {
font-size: 24rpx;
color: #bbb;
}
</style>
@@ -0,0 +1,181 @@
<template>
<view class="page">
<view class="-status-bar"></view>
<view class="header">
<text class="title">意见反馈</text>
</view>
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;" class="form">
<view class="section">
<text class="label">反馈类型</text>
<view class="type-row">
<view v-for="(t, idx) in types" :key="t.value" class="type-item" :class="{ active: type == t.value }" @click="type = t.value">
<text class="type-text">{{ t.label }}</text>
</view>
</view>
</view>
<view class="section">
<text class="label">反馈内容</text>
<textarea class="textarea" v-model="content" placeholder="请描述您遇到的问题或建议(最多500字)" maxlength="500" />
<text class="counter">{{ content.length }}/500</text>
</view>
<view class="section">
<text class="label">联系方式(选填)</text>
<input class="input" v-model="contact" placeholder="手机号/微信,方便我们回复您" />
</view>
<view class="submit-btn" :class="{ disabled: submitting }" @click="onSubmit">
<text class="submit-text">{{ submitting ? '提交中...' : '提交反馈' }}</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
type IType = { label : string, value : number }
const types = reactive<IType[]>([
{ label: '功能建议', value: 1 },
{ label: '投诉举报', value: 2 },
{ label: '其他', value: 3 }
])
const type = ref<number>(1)
const content = ref<string>('')
const contact = ref<string>('')
const submitting = ref<boolean>(false)
function onSubmit() {
if (submitting.value) return
if (content.value.trim() == '') {
uni.showToast({ title: '请输入反馈内容', icon: 'none' })
return
}
submitting.value = true
Api.feedback.save({ type: type.value, content: content.value, contact: contact.value })
.then(() => {
uni.showToast({ title: '提交成功', icon: 'success' })
content.value = ''
contact.value = ''
})
.catch((e : any) => {
const msg = e && e.message ? e.message : '提交失败'
uni.showToast({ title: msg, icon: 'none' })
})
.finally(() => { submitting.value = false })
}
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.-status-bar {
height: var(--status-bar-height);
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
}
.header {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
padding: 24rpx 30rpx 36rpx;
display: flex;
align-items: center;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #fff;
}
.form {
padding: 24rpx 30rpx;
}
.section {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 24rpx;
}
.label {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
display: block;
}
.type-row {
display: flex;
flex-flow: row wrap;
}
.type-item {
padding: 14rpx 36rpx;
border-radius: 30rpx;
background-color: #f2f2f2;
margin: 0 20rpx 16rpx 0;
}
.type-item.active {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
}
.type-text {
font-size: 26rpx;
color: #666;
}
.type-item.active .type-text {
color: #fff;
}
.textarea {
width: 100%;
height: 200rpx;
font-size: 28rpx;
color: #333;
line-height: 1.5;
}
.counter {
font-size: 22rpx;
color: #bbb;
float: right;
margin-top: 8rpx;
}
.input {
width: 100%;
height: 70rpx;
font-size: 28rpx;
color: #333;
}
.submit-btn {
margin: 40rpx 30rpx 60rpx;
height: 92rpx;
border-radius: 46rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 30rpx rgba(255, 107, 107, 0.3);
}
.submit-btn.disabled {
opacity: 0.6;
}
.submit-text {
color: #fff;
font-size: 32rpx;
font-weight: bold;
}
</style>
@@ -0,0 +1,212 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;"
:refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh"
@scrolltolower="onReachBottom">
<view class="list">
<view v-for="(item, index) in records" :key="index" class="user-item" @click="goProfile(item.id)">
<image class="avatar" :src="item.avatar" mode="aspectFill"></image>
<view class="info">
<view class="name-row">
<text class="nickname">{{ item.nickname }}</text>
<view v-if="item.is_mutual == 1" class="mutual-tag">
<text class="mutual-txt">互相关注</text>
</view>
</view>
<text class="time">{{ item.follow_time }}</text>
</view>
<view v-if="item.is_mutual == 0" class="follow-btn" @click.stop="follow(item)">
<text class="follow-txt">回关</text>
</view>
<uni-icons v-else type="right" size="18" color="#ccc"></uni-icons>
</view>
<view v-if="records.length == 0 && !loading" class="empty-state">
<uni-icons type="staff" size="60" color="#ddd"></uni-icons>
<text class="empty-text">还没有粉丝</text>
</view>
<view v-if="loading" class="loading-tip">
<text class="loading-txt">加载中...</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
import { getUserInfo } from '@/stores/user.uts'
type IFollowUser = {
id : number
nickname : string
avatar : string
is_mutual : number
follow_time : string
}
const records = reactive<IFollowUser[]>([])
const loading = ref<boolean>(false)
const refreshing = ref<boolean>(false)
const page = ref<number>(1)
const size = ref<number>(20)
const total = ref<number>(0)
const uid = ref<number>(0)
function loadData(reset : boolean) {
if (loading.value) return
if (uid.value <= 0) {
const info = getUserInfo()
uid.value = info?.uid ?? 0
}
if (uid.value <= 0) return
loading.value = true
if (reset) page.value = 1
Api.follow.followerList({ uid: uid.value, page: page.value, size: size.value })
.then((res : UTSJSONObject) => {
total.value = (res.get('total') as number) ?? 0
const list = (res.get('list') as Array<IFollowUser>) ?? []
if (reset) records.splice(0, records.length)
list.forEach((r : IFollowUser) => records.push(r))
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => {
loading.value = false
refreshing.value = false
})
}
function onRefresh() {
refreshing.value = true
loadData(true)
}
function onReachBottom() {
if (records.length >= total.value) return
page.value = page.value + 1
loadData(false)
}
function follow(item : IFollowUser) {
if (uid.value <= 0 || item.id <= 0) return
Api.follow.toggle(uid.value, item.id, 1)
.then(() => {
item.is_mutual = 1
uni.showToast({ title: '已回关', icon: 'success' })
})
.catch(() => {
uni.showToast({ title: '操作失败', icon: 'none' })
})
}
function goProfile(targetUid : number) {
if (targetUid <= 0) return
uni.navigateTo({ url: '/pages/user/profile?uid=' + targetUid })
}
onMounted(() => { loadData(true) })
onShow(() => { loadData(true) })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.list {
margin: 24rpx 30rpx;
}
.user-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
padding: 20rpx 24rpx;
background-color: #fff;
border-radius: 16rpx;
margin-bottom: 16rpx;
}
.avatar {
width: 88rpx;
height: 88rpx;
border-radius: 44rpx;
background-color: #eee;
margin-right: 20rpx;
}
.info {
flex: 1;
display: flex;
flex-direction: column;
}
.name-row {
display: flex;
flex-flow: row nowrap;
align-items: center;
}
.nickname {
font-size: 30rpx;
color: #333;
font-weight: 500;
}
.mutual-tag {
margin-left: 14rpx;
padding: 2rpx 12rpx;
border-radius: 16rpx;
background-color: #fff0ec;
}
.mutual-txt {
font-size: 20rpx;
color: #FF6B6B;
}
.time {
font-size: 22rpx;
color: #bbb;
margin-top: 8rpx;
}
.follow-btn {
padding: 10rpx 28rpx;
border-radius: 30rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
}
.follow-txt {
font-size: 24rpx;
color: #fff;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
margin-top: 20rpx;
}
.loading-tip {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx 0;
}
.loading-txt {
font-size: 24rpx;
color: #bbb;
}
</style>
@@ -0,0 +1,186 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;"
:refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh"
@scrolltolower="onReachBottom">
<view class="list">
<view v-for="(item, index) in records" :key="index" class="user-item" @click="goProfile(item.id)">
<image class="avatar" :src="item.avatar" mode="aspectFill"></image>
<view class="info">
<view class="name-row">
<text class="nickname">{{ item.nickname }}</text>
<view v-if="item.is_mutual == 1" class="mutual-tag">
<text class="mutual-txt">互相关注</text>
</view>
</view>
<text class="time">{{ item.follow_time }}</text>
</view>
<uni-icons type="right" size="18" color="#ccc"></uni-icons>
</view>
<view v-if="records.length == 0 && !loading" class="empty-state">
<uni-icons type="staff" size="60" color="#ddd"></uni-icons>
<text class="empty-text">还没有关注任何人</text>
</view>
<view v-if="loading" class="loading-tip">
<text class="loading-txt">加载中...</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
import { getUserInfo } from '@/stores/user.uts'
type IFollowUser = {
id : number
nickname : string
avatar : string
is_mutual : number
follow_time : string
}
const records = reactive<IFollowUser[]>([])
const loading = ref<boolean>(false)
const refreshing = ref<boolean>(false)
const page = ref<number>(1)
const size = ref<number>(20)
const total = ref<number>(0)
const uid = ref<number>(0)
function loadData(reset : boolean) {
if (loading.value) return
if (uid.value <= 0) {
const info = getUserInfo()
uid.value = info?.uid ?? 0
}
if (uid.value <= 0) return
loading.value = true
if (reset) page.value = 1
Api.follow.followingList({ uid: uid.value, page: page.value, size: size.value })
.then((res : UTSJSONObject) => {
total.value = (res.get('total') as number) ?? 0
const list = (res.get('list') as Array<IFollowUser>) ?? []
if (reset) records.splice(0, records.length)
list.forEach((r : IFollowUser) => records.push(r))
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => {
loading.value = false
refreshing.value = false
})
}
function onRefresh() {
refreshing.value = true
loadData(true)
}
function onReachBottom() {
if (records.length >= total.value) return
page.value = page.value + 1
loadData(false)
}
function goProfile(targetUid : number) {
if (targetUid <= 0) return
uni.navigateTo({ url: '/pages/user/profile?uid=' + targetUid })
}
onMounted(() => { loadData(true) })
onShow(() => { loadData(true) })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.list {
margin: 24rpx 30rpx;
}
.user-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
padding: 20rpx 24rpx;
background-color: #fff;
border-radius: 16rpx;
margin-bottom: 16rpx;
}
.avatar {
width: 88rpx;
height: 88rpx;
border-radius: 44rpx;
background-color: #eee;
margin-right: 20rpx;
}
.info {
flex: 1;
display: flex;
flex-direction: column;
}
.name-row {
display: flex;
flex-flow: row nowrap;
align-items: center;
}
.nickname {
font-size: 30rpx;
color: #333;
font-weight: 500;
}
.mutual-tag {
margin-left: 14rpx;
padding: 2rpx 12rpx;
border-radius: 16rpx;
background-color: #fff0ec;
}
.mutual-txt {
font-size: 20rpx;
color: #FF6B6B;
}
.time {
font-size: 22rpx;
color: #bbb;
margin-top: 8rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
margin-top: 20rpx;
}
.loading-tip {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx 0;
}
.loading-txt {
font-size: 24rpx;
color: #bbb;
}
</style>
@@ -0,0 +1,233 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;"
:refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<!-- 汇总卡片 -->
<view class="summary-card">
<view class="summary-item">
<text class="summary-num">{{ totalCount }}</text>
<text class="summary-label">收到礼物</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-num">{{ totalValue }}</text>
<text class="summary-label">礼物价值(金币)</text>
</view>
</view>
<!-- 礼物记录 -->
<view class="list">
<view v-for="(item, index) in records" :key="index" class="gift-item">
<view class="gift-icon">
<uni-icons type="gift-filled" size="30" color="#FF6B6B"></uni-icons>
</view>
<view class="gift-info">
<text class="gift-name">{{ item.gift_name }} x{{ item.count }}</text>
<text v-if="item.message.length > 0" class="gift-msg">{{ item.message }}</text>
<text class="gift-time">{{ formatTime(item.create_at) }}</text>
</view>
<view class="gift-value">
<text class="value-num">+{{ item.total_price }}</text>
<text class="value-label">金币</text>
</view>
</view>
<view v-if="records.length === 0 && !loading" class="empty-state">
<uni-icons type="gift" size="60" color="#ddd"></uni-icons>
<text class="empty-text">还没有收到礼物哦</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
type IGiftRecord = {
id : number
sender_uid : number
receiver_uid : number
gift_id : number
gift_name : string
price : number
count : number
total_price : number
message : string
create_at : number
}
const records = reactive<IGiftRecord[]>([])
const loading = ref<boolean>(false)
const refreshing = ref<boolean>(false)
const totalCount = computed<number>(() => {
let c = 0
records.forEach((r : IGiftRecord) => { c += r.count })
return c
})
const totalValue = computed<number>(() => {
let v = 0
records.forEach((r : IGiftRecord) => { v += r.total_price })
return v
})
function loadData() {
if (loading.value) return
loading.value = true
Api.gift.received({})
.then((res : UTSJSONObject) => {
const list = (res as Array<IGiftRecord>) ?? []
records.splice(0, records.length)
list.forEach((r : IGiftRecord) => records.push(r))
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => {
loading.value = false
refreshing.value = false
})
}
function onRefresh() {
refreshing.value = true
loadData()
}
function formatTime(ts : number) : string {
if (ts <= 0) return ''
const d = new Date(ts * 1000)
const m = d.getMonth() + 1
const day = d.getDate()
const h = d.getHours()
const min = d.getMinutes()
const pad = (n : number) : string => n < 10 ? '0' + n : '' + n
return `${m}-${pad(day)} ${pad(h)}:${pad(min)}`
}
onMounted(() => { loadData() })
onShow(() => { loadData() })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.summary-card {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin: 24rpx 30rpx;
padding: 40rpx 0;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
border-radius: 20rpx;
}
.summary-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.summary-num {
font-size: 44rpx;
font-weight: bold;
color: #fff;
}
.summary-label {
font-size: 24rpx;
color: #ffe;
margin-top: 8rpx;
}
.summary-divider {
width: 2rpx;
height: 60rpx;
background-color: rgba(255, 255, 255, 0.4);
}
.list {
margin: 0 30rpx;
}
.gift-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
padding: 24rpx;
background-color: #fff;
border-radius: 16rpx;
margin-bottom: 16rpx;
}
.gift-icon {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
background-color: #fff0ec;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.gift-info {
flex: 1;
display: flex;
flex-direction: column;
}
.gift-name {
font-size: 30rpx;
color: #333;
font-weight: 500;
}
.gift-msg {
font-size: 24rpx;
color: #999;
margin-top: 6rpx;
}
.gift-time {
font-size: 22rpx;
color: #bbb;
margin-top: 6rpx;
}
.gift-value {
display: flex;
flex-direction: row;
align-items: baseline;
}
.value-num {
font-size: 32rpx;
font-weight: bold;
color: #FF6B6B;
}
.value-label {
font-size: 22rpx;
color: #FF6B6B;
margin-left: 4rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
margin-top: 20rpx;
}
</style>
@@ -0,0 +1,139 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;">
<view class="hero">
<uni-icons type="help-filled" size="48" color="#FF6B6B"></uni-icons>
<text class="hero-title">常见问题</text>
</view>
<view class="qa" v-for="(item, idx) in qaList" :key="idx">
<view class="q" @click="toggle(idx)">
<text class="q-text">Q{{ item.q }}</text>
<uni-icons :type="item.open ? 'top' : 'right'" size="18" color="#999"></uni-icons>
</view>
<view v-if="item.open" class="a">
<text class="a-text">{{ item.a }}</text>
</view>
</view>
<view class="contact">
<text class="contact-title">仍未解决?</text>
<view class="contact-btn" @click="copyMail">
<uni-icons type="email" size="20" color="#fff"></uni-icons>
<text class="contact-btn-text">联系客服:support@xixingwl.cn</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
type QA = {
q : string
a : string
open : boolean
}
const qaList = reactive<QA[]>([
{ q: '如何修改个人资料?', a: '在「我的」页面点击左上角头像区域,或在资料卡中点击编辑即可修改昵称、签名、择偶条件等信息。', open: true },
{ q: '金币有什么用?', a: '金币可用于每日抽奖、赠送礼物给喜欢的用户,部分特权功能也需消耗金币。', open: false },
{ q: '为什么看不到对方的在线状态?', a: '对方可能在隐私设置中隐藏了在线状态、年龄或距离,这是对方的个人选择,无法强制查看。', open: false },
{ q: '动态被收藏/点赞会有提醒吗?', a: '当前版本点赞与收藏会累计到「我的」统计中,消息中心会同步相关互动通知。', open: false },
{ q: '如何注销账号?', a: '进入「账号安全」页面,点击「注销账号」,按提示操作即可。注销后资料将被匿名化处理。', open: false },
])
function toggle(idx : number) {
qaList[idx].open = ! qaList[idx].open
}
function copyMail() {
uni.setClipboardData({
data: 'support@xixingwl.cn',
success: () => {
uni.showToast({ title: '邮箱已复制', icon: 'success' })
}
})
}
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 50rpx 0 30rpx;
}
.hero-title {
font-size: 34rpx;
font-weight: bold;
color: #333;
margin-top: 16rpx;
}
.qa {
margin: 0 30rpx 16rpx;
background-color: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.q {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
padding: 28rpx 24rpx;
}
.q-text {
font-size: 28rpx;
color: #333;
font-weight: 500;
flex: 1;
margin-right: 16rpx;
}
.a {
padding: 0 24rpx 28rpx;
}
.a-text {
font-size: 26rpx;
color: #888;
line-height: 1.6;
}
.contact {
display: flex;
flex-direction: column;
align-items: center;
margin: 40rpx 0 80rpx;
}
.contact-title {
font-size: 26rpx;
color: #999;
margin-bottom: 24rpx;
}
.contact-btn {
display: flex;
flex-flow: row nowrap;
align-items: center;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
padding: 20rpx 36rpx;
border-radius: 40rpx;
}
.contact-btn-text {
color: #fff;
font-size: 26rpx;
margin-left: 12rpx;
}
</style>
@@ -0,0 +1,204 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;"
:refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view class="list">
<view v-for="(m, idx) in list" :key="idx" class="moment-item" @click="openDetail(m)">
<view class="m-head">
<image class="m-avatar" :src="imgUrl(m.userAvatar)" mode="aspectFill"></image>
<view class="m-meta">
<text class="m-name">{{ m.userName }}</text>
<text class="m-time">{{ formatTime(m.time) }}</text>
</view>
</view>
<text class="m-content">{{ m.content }}</text>
<view v-if="m.images && m.images.length > 0" class="m-images">
<image v-for="(img, i) in m.images.slice(0,3)" :key="i" class="m-img"
:src="imgUrl(img)" mode="aspectFill"></image>
</view>
<view class="m-foot">
<view class="m-stat"><uni-icons type="heart" size="22" color="#FF2D55"></uni-icons>
<text>{{ m.likes }}</text></view>
<view class="m-stat"><uni-icons type="chatbubble" size="22" color="#999"></uni-icons>
<text>{{ m.comments }}</text></view>
</view>
</view>
<view v-if="list.length === 0 && !loading" class="empty-state">
<uni-icons type="eye" size="60" color="#ddd"></uni-icons>
<text class="empty-text">还没有浏览记录</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
const BASE = 'https://dev.xixingwl.cn'
type IMoment = {
id : number
uid : number
userName : string
userAvatar : string
content : string
images : string[]
videoUrl : string
type : string
time : number
likes : number
comments : number
shares : number
}
const list = reactive<IMoment[]>([])
const loading = ref<boolean>(false)
const refreshing = ref<boolean>(false)
function imgUrl(src : string) : string {
if (! src) return ''
return src.startsWith('http') ? src : (BASE + src)
}
function formatTime(ts : number) : string {
if (! ts) return ''
const d = new Date(ts)
const pad = (n : number) : string => n < 10 ? '0' + n : '' + n
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function openDetail(m : IMoment) {
uni.navigateTo({ url: '/pages/moment/details?id=' + m.id })
}
function loadData() {
if (loading.value) return
loading.value = true
Api.moment.historyList({ page: 1, page_size: 20 })
.then((res : UTSJSONObject) => {
const arr = (res.get('list') as Array<IMoment>) ?? []
list.splice(0, list.length)
arr.forEach((m : IMoment) => list.push(m))
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => {
loading.value = false
refreshing.value = false
})
}
function onRefresh() {
refreshing.value = true
loadData()
}
onMounted(() => { loadData() })
onShow(() => { loadData() })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.list {
margin: 24rpx 30rpx;
}
.moment-item {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
}
.m-head {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-bottom: 16rpx;
}
.m-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
background-color: #f0f0f0;
margin-right: 16rpx;
}
.m-meta {
display: flex;
flex-direction: column;
}
.m-name {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.m-time {
font-size: 22rpx;
color: #bbb;
margin-top: 4rpx;
}
.m-content {
font-size: 28rpx;
color: #333;
line-height: 1.5;
display: block;
}
.m-images {
display: flex;
flex-flow: row wrap;
margin-top: 16rpx;
}
.m-img {
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
margin: 0 12rpx 12rpx 0;
background-color: #f0f0f0;
}
.m-foot {
display: flex;
flex-flow: row nowrap;
margin-top: 16rpx;
}
.m-stat {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-right: 40rpx;
font-size: 24rpx;
color: #999;
}
.m-stat text {
margin-left: 6rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
margin-top: 20rpx;
}
</style>
@@ -0,0 +1,188 @@
<template>
<view class="page">
<view class="-status-bar"></view>
<view class="header">
<uni-icons type="vip" size="44" color="#FFD700"></uni-icons>
<text class="title">会员中心</text>
<text class="sub">开通会员,尊享更多特权</text>
</view>
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;">
<view class="plan-row">
<view v-for="(p, idx) in plans" :key="p.id" class="plan-card" :class="{ active: selected == p.id }" @click="selected = p.id">
<text class="plan-name">{{ p.name }}</text>
<view class="plan-price">
<text class="price-num">¥{{ p.price }}</text>
<text class="price-unit">/{{ p.unit }}</text>
</view>
<text class="plan-tip">{{ p.tip }}</text>
</view>
</view>
<view class="benefits">
<text class="benefits-title">会员特权</text>
<view class="benefit-item" v-for="(b, idx) in benefits" :key="idx">
<uni-icons type="checkmark" size="18" color="#34C759"></uni-icons>
<text class="benefit-text">{{ b }}</text>
</view>
</view>
<view class="open-btn" @click="onOpen">
<text class="open-text">立即开通</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
type IPlan = { id : number, name : string, price : number, unit : string, tip : string }
const selected = ref<number>(2)
const plans = reactive<IPlan[]>([
{ id: 1, name: '月度会员', price: 30, unit: '月', tip: '灵活体验' },
{ id: 2, name: '季度会员', price: 78, unit: '季', tip: '省21元' },
{ id: 3, name: '年度会员', price: 268, unit: '年', tip: '最划算' }
])
const benefits = reactive<string[]>([
'无限畅聊,消息免打扰',
'查看谁看过我 / 超级喜欢',
'每日专属推荐位曝光',
'专属动态置顶与标识',
'会员专属装扮与客服优先'
])
function onOpen() {
uni.showToast({ title: '跳转支付(演示)', icon: 'none' })
}
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.-status-bar {
height: var(--status-bar-height);
background: linear-gradient(135deg, #2C2C3A 0%, #1A1A24 100%);
}
.header {
background: linear-gradient(135deg, #2C2C3A 0%, #1A1A24 100%);
padding: 50rpx 30rpx 60rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.title {
font-size: 40rpx;
font-weight: bold;
color: #FFD700;
margin-top: 16rpx;
}
.sub {
font-size: 24rpx;
color: #bbb;
margin-top: 10rpx;
}
.plan-row {
display: flex;
flex-flow: row nowrap;
padding: 30rpx;
justify-content: space-between;
}
.plan-card {
width: 31%;
padding: 30rpx 0;
background-color: #fff;
border-radius: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
border: 2rpx solid #f0f0f0;
}
.plan-card.active {
border-color: #FFD700;
background: linear-gradient(180deg, #FFF8E1 0%, #FFFFFF 100%);
}
.plan-name {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.plan-price {
display: flex;
flex-flow: row nowrap;
align-items: baseline;
margin: 16rpx 0 8rpx;
}
.price-num {
font-size: 40rpx;
font-weight: bold;
color: #FF9500;
}
.price-unit {
font-size: 22rpx;
color: #999;
margin-left: 4rpx;
}
.plan-tip {
font-size: 22rpx;
color: #999;
}
.benefits {
margin: 10rpx 30rpx 0;
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
}
.benefits-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
display: block;
}
.benefit-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
padding: 16rpx 0;
}
.benefit-text {
font-size: 28rpx;
color: #555;
margin-left: 16rpx;
}
.open-btn {
margin: 40rpx 60rpx 60rpx;
height: 92rpx;
border-radius: 46rpx;
background: linear-gradient(135deg, #FFD700 0%, #FFA000 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 30rpx rgba(255, 168, 0, 0.3);
}
.open-text {
color: #5a3d00;
font-size: 32rpx;
font-weight: bold;
}
</style>
@@ -0,0 +1,815 @@
<template>
<view class="page">
<view class="-status-bar"></view>
<scroll-view direction="vertical" :show-scrollbar="false" style="height: 100%;">
<!-- 顶部背景 -->
<view class="top-background"></view>
<!-- 用户信息卡片 -->
<view class="user-card">
<view class="user-header">
<view class="avatar-section">
<image class="avatar" :src=" avatar" mode="aspectFill"></image>
<!-- <text class="vip-badge" v-if="userInfo.vip">
<uni-icons type="vip" size="16" color="#fff"></uni-icons>
</text> -->
<!-- <view class="online-status" v-if="userInfo.online"></view> -->
</view>
<view class="user-info">
<view class="name-section">
<text class="nickname">{{ userInfo?.nickname }}</text>
<!-- <text class="level-badge">Lv.{{ userInfo.level }}</text> -->
<!-- <view class="gender-badge" :class="userInfo.gender">
<uni-icons :type="userInfo.gender === 'male' ? 'male' : 'female'" size="12"
:color="userInfo.gender === 'male' ? '#007AFF' : '#FF2D55'"></uni-icons>
</view> -->
</view>
<text class="user-id">ID: {{ userInfo?.uid }}</text>
<!-- <text class="signature">{{ userInfo.signature !=null ?userInfo.signature : '这家伙很懒,什么都没留下' }}</text> -->
</view>
</view>
<view class="stats-row">
<view class="stat-item" @click="navigateTo('followers')">
<text class="stat-value">{{ formatNumber(stats.friends) }}</text>
<text class="stat-label">缘友</text>
</view>
<view class="stat-item" @click="navigateTo('followers')">
<text class="stat-value">{{ formatNumber(stats.followers) }}</text>
<text class="stat-label">粉丝</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item" @click="navigateTo('following')">
<text class="stat-value">{{ formatNumber(stats.following) }}</text>
<text class="stat-label">关注</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item" @click="navigateTo('likes')">
<text class="stat-value">{{ formatNumber(stats.likes) }}</text>
<text class="stat-label">获赞</text>
</view>
</view>
</view>
<!-- 钱包和收益区域 -->
<view class="wallet-section">
<view class="wallet-item" @click="navigateTo('wallet')">
<uni-icons type="wallet" size="32" color="#FF9500"></uni-icons>
<text class="wallet-label">我的钱包</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
<view class="divider"></view>
<view class="wallet-item" @click="navigateTo('income')">
<uni-icons type="vip" size="32" color="#FF3B30"></uni-icons>
<text class="wallet-label">会员中心</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
<!-- 功能区网格 -->
<view class="function-grid">
<view class="grid-row">
<view class="grid-item" @click="navigateTo('dynamic')">
<view class="grid-icon">
<uni-icons type="pyq" size="32" color="#ffd790"></uni-icons>
</view>
<text class="grid-text">我的动态</text>
<!-- <view v-if="userInfo.newDynamic" class="badge-dot"></view> -->
</view>
<view class="grid-item" @click="navigateTo('collect')">
<view class="grid-icon">
<uni-icons type="star" size="32" color="#FF9500"></uni-icons>
</view>
<text class="grid-text">我的收藏</text>
</view>
<view class="grid-item" @click="navigateTo('history')">
<view class="grid-icon">
<uni-icons type="heart" size="32" color="#FF2D55"></uni-icons>
</view>
<text class="grid-text">浏览记录</text>
</view>
<view class="grid-item" @click="navigateTo('gift')">
<view class="grid-icon">
<uni-icons type="gift" size="32" color="#5856D6"></uni-icons>
</view>
<text class="grid-text">我的礼物</text>
<!-- <view v-if="userInfo.newGift" class="badge-dot"></view> -->
</view>
</view>
<view class="grid-row">
<view class="grid-item" @click="navigateTo('fans')">
<view class="grid-icon">
<uni-icons type="person" size="32" color="#007AFF"></uni-icons>
</view>
<text class="grid-text">粉丝团</text>
</view>
<view class="grid-item" @click="navigateTo('ranking')">
<view class="grid-icon">
<uni-icons type="list" size="32" color="#FFCC00"></uni-icons>
</view>
<text class="grid-text">排行榜</text>
</view>
<view class="grid-item" @click="navigateTo('task')">
<view class="grid-icon">
<uni-icons type="calendar" size="32" color="#5AC8FA"></uni-icons>
</view>
<text class="grid-text">每日任务</text>
</view>
<view class="grid-item" @click="navigateTo('lottery')">
<view class="grid-icon">
<uni-icons type="help" size="32" color="#AF52DE"></uni-icons>
</view>
<text class="grid-text">幸运抽奖</text>
</view>
</view>
</view>
<!-- 列表功能区 -->
<view class="list-section">
<text class="list-title">账号与安全</text>
<view class="list-container">
<view class="list-item" @click="navigateTo('security')">
<view class="list-left">
<uni-icons type="locked" size="20" color="#007AFF"></uni-icons>
<text class="list-text">账号安全</text>
</view>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
<view class="list-item" @click="navigateTo('privacy')">
<view class="list-left">
<uni-icons type="eye" size="20" color="#34C759"></uni-icons>
<text class="list-text">隐私设置</text>
</view>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
<view class="list-item" @click="navigateTo('notification')">
<view class="list-left">
<uni-icons type="sound" size="20" color="#FF9500"></uni-icons>
<text class="list-text">消息通知</text>
</view>
<view class="list-right">
<!-- <text class="list-desc">{{ userInfo.notification ? '已开启' : '已关闭' }}</text> -->
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</view>
<text class="list-title">服务与支持</text>
<view class="list-container">
<view class="list-item" @click="navigateTo('feedback')">
<view class="list-left">
<uni-icons type="chatbubble" size="20" color="#5856D6"></uni-icons>
<text class="list-text">意见反馈</text>
</view>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
<view class="list-item" @click="navigateTo('help')">
<view class="list-left">
<uni-icons type="help" size="20" color="#FF3B30"></uni-icons>
<text class="list-text">帮助中心</text>
</view>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
<view class="list-item" @click="navigateTo('about')">
<view class="list-left">
<uni-icons type="info" size="20" color="#5AC8FA"></uni-icons>
<text class="list-text">关于我们</text>
</view>
<view class="list-right">
<text class="list-desc">v{{ appVersion }}</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
<view class="list-item" @click="showLogoutDialog">
<view class="list-left">
<uni-icons type="redo" size="20" color="#FF9500"></uni-icons>
<text class="list-text logout-text">退出登录</text>
</view>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import userState from "@/stores/user.uts"
import { IUserInfo } from "@/types/user.uts"
import Api from "@/common/api-service.uts"
const userInfo = computed(() : IUserInfo | null => {
return userState.info ?? null
})
// 我的聚合统计(缘友/粉丝/关注/获赞/动态)
const stats = reactive({
friends: 0,
followers: 0,
following: 0,
likes: 0,
moments: 0
})
const loadStats = () => {
Api.user.summary()
.then((res : UTSJSONObject) => {
stats.friends = (res.get('friends') as number) ?? 0
stats.followers = (res.get('followers') as number) ?? 0
stats.following = (res.get('following') as number) ?? 0
stats.likes = (res.get('likes') as number) ?? 0
stats.moments = (res.get('moments') as number) ?? 0
})
.catch(() => {})
}
const avatar = computed(() => {
return 'https://dev.xixingwl.cn' + userInfo.value?.avatar
})
// 响应式数据
/* const userInfo = reactive<IUserInfo>({
userId: '123456789',
nickname: '心动玩家',
avatar: "https://dev.xixingwl.cn/" + userState.user?.avatar,//'https://randomuser.me/api/portraits/men/32.jpg',
signature: '阳光正好,微风不燥,遇见你真好~',
gender: 'male',
age: 25,
level: 12,
vip: true,
online: true,
balance: 888.88,
todayIncome: 128.50,
followers: 12560,
following: 230,
likes: 45620,
newDynamic: true,
newGift: false,
notification: true
} as IUserInfo) */
const appVersion = ref('2.5.0')
// 加载用户信息
const loadUserInfo = () => {
console.log('加载用户信息...')
// 模拟API调用
setTimeout(() => {
// 这里应该是从API获取数据
console.log('用户信息加载完成')
}, 1000)
}
// 页面加载
onMounted(() => {
console.log('我的页面加载完成')
// 这里可以加载用户数据
loadUserInfo()
loadStats()
})
onShow(() => {
loadStats()
})
// 格式化数字(千分位)
const formatNumber = (num : number) : string => {
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k'
}
return num.toString()
}
// 编辑资料
const editProfile = () => {
console.log('编辑资料')
uni.navigateTo({
url: '/pages/edit-profile/edit-profile'
})
}
// 开通VIP
const openVip = () => {
console.log('开通VIP')
uni.showModal({
title: '开通VIP会员',
content: '开通VIP享受更多特权,包括去广告、专属装扮、优先匹配等',
confirmText: '立即开通',
cancelText: '再想想',
success: (res) => {
if (res.confirm) {
uni.showToast({
title: '跳转支付页面',
icon: 'none'
})
}
}
})
}
// VIP中心
const vipCenter = () => {
console.log('VIP中心')
uni.navigateTo({
url: '/pages/vip-center/vip-center'
})
}
// 页面跳转
const navigateTo = (page : string) => {
console.log('跳转到页面:', page)
const pageUrl = ref<string>('')
switch (page) {
case 'wallet':
pageUrl.value = '/pages/my/wallet';
break;
case 'income':
pageUrl.value = '/pages/my/income';
break;
case 'dynamic':
pageUrl.value = '/pages/my/dynamic';
break;
case 'collect':
pageUrl.value = '/pages/my/collect';
break;
case 'history':
pageUrl.value = '/pages/my/history';
break;
case 'gift':
pageUrl.value = '/pages/my/gift';
break;
case 'fans':
pageUrl.value = '/pages/my/fans';
break;
case 'ranking':
pageUrl.value = '/pages/my/ranking';
break;
case 'task':
pageUrl.value = '/pages/my/task';
break;
case 'lottery':
pageUrl.value = '/pages/my/lottery';
break;
case 'security':
pageUrl.value = '/pages/my/security';
break;
case 'privacy':
pageUrl.value = '/pages/my/privacy';
break;
case 'notification':
pageUrl.value = '/pages/notification/notification';
break;
case 'feedback':
pageUrl.value = '/pages/my/feedback';
break;
case 'help':
pageUrl.value = '/pages/my/help';
break;
case 'about':
pageUrl.value = '/pages/my/about';
break;
case 'followers':
pageUrl.value = '/pages/my/followers';
break;
case 'following':
pageUrl.value = '/pages/my/following';
break;
case 'likes':
pageUrl.value = '/pages/my/likes';
break;
}
if (pageUrl.value != '') {
uni.navigateTo({
url: pageUrl.value
})
} else {
uni.showToast({
title: '页面开发中',
icon: 'none',
duration: 1500
})
}
}
// 退出登录
const logout = () => {
console.log('退出登录')
uni.showLoading({
title: '退出中...',
mask: true
})
// 清除本地登录态(token / 用户信息)
Api.auth.logout()
setTimeout(() => {
uni.hideLoading()
uni.showToast({
title: '退出成功',
icon: 'success',
duration: 1500
})
// 跳转到登录页面
setTimeout(() => {
uni.reLaunch({
url: '/pages/login/index'
})
}, 1500)
}, 500)
}
// 显示退出登录对话框
const showLogoutDialog = () => {
uni.showModal({
title: '退出登录',
content: '确定要退出当前账号吗?',
confirmText: '退出',
confirmColor: '#FF3B30',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
logout()
}
}
})
}
</script>
<style>
/* 顶部背景 */
.top-background {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 300rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
border-bottom-left-radius: 40rpx;
border-bottom-right-radius: 40rpx;
z-index: 0;
}
/* 用户信息卡片 */
.user-card {
position: relative;
margin: 40rpx 30rpx 0;
padding: 40rpx 30rpx;
background-color: #fff;
border-radius: 20rpx;
box-shadow: 0 10rpx 30rpx rgba(255, 107, 107, 0.1);
z-index: 1;
}
.user-header {
display: flex;
align-items: flex-start;
flex-direction: row;
margin-bottom: 40rpx;
}
.avatar-section {
position: relative;
margin-right: 30rpx;
}
.avatar {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
border: 4rpx solid #fff;
box-shadow: 0 10rpx 20rpx rgba(0, 0, 0, 0.1);
background-color: #f0f0f0;
}
.vip-badge {
position: absolute;
bottom: 0;
right: 0;
width: 40rpx;
height: 40rpx;
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
border-radius: 50%;
text-align: center;
border: 4rpx solid #fff;
}
.online-status {
position: absolute;
top: 0;
right: 0;
width: 24rpx;
height: 24rpx;
background-color: #4CD964;
border-radius: 50%;
border: 4rpx solid #fff;
}
.user-info {
flex: 1;
}
.name-section {
display: flex;
align-items: center;
flex-flow: row nowrap;
margin-bottom: 10rpx;
}
.nickname {
font-size: 40rpx;
font-weight: bold;
color: #333;
margin-right: 20rpx;
max-width: 300rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.level-badge {
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
color: #fff;
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 20rpx;
margin-right: 10rpx;
}
.gender-badge {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.gender-badge.male {
background-color: rgba(0, 122, 255, 0.1);
}
.gender-badge.female {
background-color: rgba(255, 45, 85, 0.1);
}
.user-id {
font-size: 24rpx;
color: #999;
margin-bottom: 10rpx;
}
.signature {
font-size: 28rpx;
color: #666;
line-height: 1.4;
margin-bottom: 30rpx;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
}
.stats-row {
display: flex;
align-items: center;
justify-content: space-around;
flex-flow: row nowrap;
background-color: #f8f8f8;
border-radius: 20rpx;
padding: 20rpx 0;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.stat-value {
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 5rpx;
}
.stat-label {
font-size: 24rpx;
color: #999;
}
.stat-divider {
width: 2rpx;
height: 40rpx;
background-color: #e0e0e0;
}
.action-buttons {
display: flex;
}
.edit-btn,
.vip-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
font-size: 28rpx;
font-weight: 500;
border: none;
transition: all 0.3s ease;
}
.edit-btn:active,
.vip-btn:active {
transform: scale(0.98);
opacity: 0.9;
}
.edit-btn {
background-color: #f0f0f0;
color: #333;
}
.vip-btn {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
color: #fff;
}
.vip-btn.active {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #333;
}
/* 钱包区域 */
.wallet-section {
margin: 30rpx;
padding: 30rpx;
background-color: #fff;
border-radius: 20rpx;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
box-shadow: 0 5rpx 20rpx rgba(0, 0, 0, 0.05);
}
.wallet-item {
flex: 1;
display: flex;
align-items: center;
flex-direction: row;
padding: 0 20rpx;
}
.wallet-label {
font-size: 28rpx;
color: #333;
margin: 0 20rpx 0 15rpx;
flex: 1;
}
.wallet-amount {
font-size: 32rpx;
font-weight: bold;
color: #FF9500;
margin-right: 15rpx;
}
.wallet-amount+.uni-icons {
color: #999;
}
.divider {
width: 2rpx;
height: 60rpx;
background-color: #f0f0f0;
}
/* 功能区网格 */
.function-grid {
margin: 0 30rpx 30rpx;
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx 0;
box-shadow: 0 5rpx 20rpx rgba(0, 0, 0, 0.05);
}
.grid-row {
display: flex;
flex-flow: row nowrap;
justify-content: space-around;
margin-bottom: 30rpx;
}
.grid-row:last-child {
margin-bottom: 0;
}
.grid-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
width: 25%;
}
.grid-icon {
width: 100rpx;
height: 100rpx;
border-radius: 25rpx;
background-color: #f8f8f8;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 15rpx;
transition: all 0.3s ease;
}
.grid-item:active {
transform: scale(0.95);
}
.grid-icon:active {
transform: scale(0.95);
}
.grid-text {
font-size: 24rpx;
color: #333;
}
.badge-dot {
position: absolute;
top: 0;
right: 20rpx;
width: 16rpx;
height: 16rpx;
background-color: #FF3B30;
border-radius: 50%;
border: 2rpx solid #fff;
}
/* 列表区域 */
.list-section {
margin: 0 30rpx;
}
.list-title {
font-size: 28rpx;
color: #999;
margin: 20rpx 0 15rpx;
padding-left: 10rpx;
}
.list-container {
background-color: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 5rpx 20rpx rgba(0, 0, 0, 0.05);
margin-bottom: 30rpx;
}
.list-item {
display: flex;
justify-content: space-between;
flex-flow: row nowrap;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
transition: background-color 0.2s ease;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:active {
background-color: #f8f8f8;
}
.list-left {
display: flex;
flex-flow: row nowrap;
align-items: center;
flex: 1;
}
.list-left .uni-icons {
margin-right: 20rpx;
}
.list-text {
font-size: 30rpx;
color: #333;
}
.logout-text {
color: #FF3B30;
}
.list-right {
display: flex;
flex-flow: row nowrap;
align-items: center;
}
.list-desc {
font-size: 28rpx;
color: #999;
margin-right: 15rpx;
}
</style>
@@ -0,0 +1,260 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;"
:refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view class="summary-card">
<text class="summary-num">{{ totalLikes }}</text>
<text class="summary-label">共获得 {{ list.length }} 条动态的点赞</text>
</view>
<view class="list">
<view v-for="(m, idx) in sorted" :key="idx" class="moment-item" @click="openDetail(m)">
<view class="m-head">
<image class="m-avatar" :src="imgUrl(m.userAvatar)" mode="aspectFill"></image>
<view class="m-meta">
<text class="m-name">{{ m.userName }}</text>
<text class="m-time">{{ formatTime(m.time) }}</text>
</view>
<view class="like-badge">
<uni-icons type="heart-filled" size="22" color="#FF2D55"></uni-icons>
<text class="like-num">{{ m.likes }}</text>
</view>
</view>
<text class="m-content">{{ m.content }}</text>
<view v-if="m.images && m.images.length > 0" class="m-images">
<image v-for="(img, i) in m.images.slice(0,3)" :key="i" class="m-img"
:src="imgUrl(img)" mode="aspectFill"></image>
</view>
<view class="m-foot">
<view class="m-stat"><uni-icons type="chatbubble" size="22" color="#999"></uni-icons>
<text>{{ m.comments }}</text></view>
<view class="m-stat"><uni-icons type="redo" size="22" color="#999"></uni-icons>
<text>{{ m.shares }}</text></view>
</view>
</view>
<view v-if="list.length === 0 && !loading" class="empty-state">
<uni-icons type="heart" size="60" color="#ddd"></uni-icons>
<text class="empty-text">还没有人给你点赞哦</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
const BASE = 'https://dev.xixingwl.cn'
type IMoment = {
id : number
uid : number
userName : string
userAvatar : string
content : string
images : string[]
videoUrl : string
type : string
time : number
likes : number
comments : number
shares : number
}
const list = reactive<IMoment[]>([])
const sorted = computed<IMoment[]>(() => {
return list.slice().sort((a : IMoment, b : IMoment) => b.likes - a.likes)
})
const totalLikes = computed<number>(() => {
let s = 0
list.forEach((m : IMoment) => { s += m.likes })
return s
})
const loading = ref<boolean>(false)
const refreshing = ref<boolean>(false)
function imgUrl(src : string) : string {
if (! src) return ''
return src.startsWith('http') ? src : (BASE + src)
}
function formatTime(ts : number) : string {
if (! ts) return ''
const d = new Date(ts)
const pad = (n : number) : string => n < 10 ? '0' + n : '' + n
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function openDetail(m : IMoment) {
uni.navigateTo({ url: '/pages/moment/details?id=' + m.id })
}
function loadData() {
if (loading.value) return
loading.value = true
Api.moment.mine({ page: 1, page_size: 50 })
.then((res : UTSJSONObject) => {
const arr = (res.get('list') as Array<IMoment>) ?? []
list.splice(0, list.length)
arr.forEach((m : IMoment) => list.push(m))
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => {
loading.value = false
refreshing.value = false
})
}
function onRefresh() {
refreshing.value = true
loadData()
}
onMounted(() => { loadData() })
onShow(() => { loadData() })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.summary-card {
display: flex;
flex-direction: column;
align-items: center;
margin: 24rpx 30rpx;
padding: 36rpx 0;
background: linear-gradient(135deg, #FF2D55 0%, #FF6B6B 100%);
border-radius: 20rpx;
}
.summary-num {
font-size: 52rpx;
font-weight: bold;
color: #fff;
}
.summary-label {
font-size: 24rpx;
color: #ffe;
margin-top: 8rpx;
}
.list {
margin: 0 30rpx 24rpx;
}
.moment-item {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
}
.m-head {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-bottom: 16rpx;
}
.m-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
background-color: #f0f0f0;
margin-right: 16rpx;
}
.m-meta {
display: flex;
flex-direction: column;
flex: 1;
}
.m-name {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.m-time {
font-size: 22rpx;
color: #bbb;
margin-top: 4rpx;
}
.like-badge {
display: flex;
flex-flow: row nowrap;
align-items: center;
background-color: rgba(255, 45, 85, 0.08);
padding: 6rpx 16rpx;
border-radius: 24rpx;
}
.like-num {
font-size: 26rpx;
color: #FF2D55;
font-weight: bold;
margin-left: 6rpx;
}
.m-content {
font-size: 28rpx;
color: #333;
line-height: 1.5;
display: block;
}
.m-images {
display: flex;
flex-flow: row wrap;
margin-top: 16rpx;
}
.m-img {
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
margin: 0 12rpx 12rpx 0;
background-color: #f0f0f0;
}
.m-foot {
display: flex;
flex-flow: row nowrap;
margin-top: 16rpx;
}
.m-stat {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-right: 40rpx;
font-size: 24rpx;
color: #999;
}
.m-stat text {
margin-left: 6rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
margin-top: 20rpx;
}
</style>
@@ -0,0 +1,305 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;">
<!-- 顶部金币信息 -->
<view class="top-card">
<view class="coin-row">
<uni-icons type="wallet-filled" size="28" color="#FFD700"></uni-icons>
<text class="coin-num">{{ coins }}</text>
<text class="coin-label">我的金币</text>
</view>
<view class="free-tip">
今日免费抽奖剩余 <text class="free-num">{{ freeLeft }}</text> 次(每次消耗 {{ cost }} 金币)
</view>
</view>
<!-- 奖品展示 -->
<view class="section-title">奖品池</view>
<view class="prize-grid">
<view v-for="(p, idx) in prizes" :key="idx" class="prize-item">
<view class="prize-icon">
<uni-icons :type="prizeIcon(p.type)" size="30"
:color="p.type == 1 ? '#FF9500' : (p.type == 2 ? '#FF2D55' : '#999')"></uni-icons>
</view>
<text class="prize-name">{{ p.name }}</text>
<text class="prize-sub" v-if="p.type == 1">{{ p.value }} 金币</text>
<text class="prize-sub" v-else-if="p.type == 2">实物</text>
<text class="prize-sub" v-else>谢谢参与</text>
</view>
</view>
<!-- 抽奖按钮 -->
<view class="draw-btn" :class="{ disabled: drawing }" @click="onDraw">
<text v-if="!drawing">{{ freeLeft > 0 ? '免费抽奖' : ('抽一次(' + cost + '金币)') }}</text>
<text v-else>抽奖中...</text>
</view>
<text class="draw-hint">每日 {{ freeDaily }} 次免费机会,点击即抽</text>
</scroll-view>
<!-- 结果弹窗 -->
<view v-if="showResult" class="mask" @click="showResult = false">
<view class="result-card" @click.stop>
<uni-icons :type="resultIcon" size="56"
:color="resultType == 1 ? '#FF9500' : (resultType == 2 ? '#FF2D55' : '#bbb')"></uni-icons>
<text class="result-title">{{ resultPrize }}</text>
<text class="result-sub" v-if="resultType == 1">+{{ resultValue }} 金币已到账</text>
<view class="result-btn" @click="showResult = false">
<text>开心收下</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
const BASE = 'https://dev.xixingwl.cn'
type IPrize = {
id : number
name : string
type : number
value : number
probability : number
}
const coins = ref<number>(0)
const cost = ref<number>(10)
const freeDaily = ref<number>(1)
const freeLeft = ref<number>(0)
const prizes = reactive<IPrize[]>([])
const drawing = ref<boolean>(false)
const showResult = ref<boolean>(false)
const resultPrize = ref<string>('')
const resultType = ref<number>(3)
const resultValue = ref<number>(0)
const resultIcon = computed<string>(() => {
if (resultType.value == 1) return 'wallet-filled'
if (resultType.value == 2) return 'gift-filled'
return 'close'
})
function prizeIcon(type : number) : string {
if (type == 1) return 'wallet-filled'
if (type == 2) return 'gift-filled'
return 'more'
}
function loadConfig() {
Api.lottery.config()
.then((res : UTSJSONObject) => {
coins.value = (res.get('coins') as number) ?? 0
cost.value = (res.get('cost') as number) ?? 10
freeDaily.value = (res.get('free_daily') as number) ?? 1
freeLeft.value = (res.get('free_left') as number) ?? 0
const list = (res.get('prizes') as Array<IPrize>) ?? []
prizes.splice(0, prizes.length)
list.forEach((p : IPrize) => prizes.push(p))
})
.catch(() => {
uni.showToast({ title: '配置加载失败', icon: 'none' })
})
}
function onDraw() {
if (drawing.value) return
if (freeLeft.value <= 0 && coins.value < cost.value) {
uni.showToast({ title: '金币不足', icon: 'none' })
return
}
drawing.value = true
Api.lottery.draw()
.then((res : UTSJSONObject) => {
resultPrize.value = (res.get('prize') as string) ?? '谢谢参与'
resultType.value = (res.get('prize_type') as number) ?? 3
resultValue.value = (res.get('prize_value') as number) ?? 0
coins.value = (res.get('coins_left') as number) ?? coins.value
showResult.value = true
loadConfig()
})
.catch((e : any) => {
uni.showToast({ title: (e && e.message) ? e.message : '抽奖失败', icon: 'none' })
})
.finally(() => {
drawing.value = false
})
}
onMounted(() => { loadConfig() })
onShow(() => { loadConfig() })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.top-card {
margin: 24rpx 30rpx;
padding: 40rpx;
background: linear-gradient(135deg, #6A5ACD 0%, #FF6B6B 100%);
border-radius: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.coin-row {
display: flex;
flex-flow: row nowrap;
align-items: baseline;
}
.coin-num {
font-size: 56rpx;
font-weight: bold;
color: #fff;
margin: 0 12rpx;
}
.coin-label {
font-size: 26rpx;
color: #ffe;
}
.free-tip {
margin-top: 16rpx;
font-size: 24rpx;
color: #fff;
}
.free-num {
font-weight: bold;
color: #FFD700;
}
.section-title {
font-size: 28rpx;
color: #999;
margin: 10rpx 30rpx 16rpx;
}
.prize-grid {
display: flex;
flex-flow: row wrap;
margin: 0 30rpx;
background-color: #fff;
border-radius: 20rpx;
padding: 20rpx 0;
}
.prize-item {
width: 33.33%;
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 0;
}
.prize-icon {
width: 96rpx;
height: 96rpx;
border-radius: 48rpx;
background-color: #f7f7f7;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
}
.prize-name {
font-size: 26rpx;
color: #333;
font-weight: 500;
}
.prize-sub {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
.draw-btn {
margin: 40rpx 60rpx 0;
height: 96rpx;
border-radius: 48rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 30rpx rgba(255, 107, 107, 0.3);
}
.draw-btn.disabled {
opacity: 0.6;
}
.draw-btn text {
color: #fff;
font-size: 32rpx;
font-weight: bold;
}
.draw-hint {
display: block;
text-align: center;
font-size: 22rpx;
color: #bbb;
margin: 16rpx 0 60rpx;
}
.mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.result-card {
width: 560rpx;
background-color: #fff;
border-radius: 24rpx;
padding: 60rpx 40rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.result-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-top: 24rpx;
}
.result-sub {
font-size: 26rpx;
color: #FF9500;
margin-top: 12rpx;
}
.result-btn {
margin-top: 40rpx;
width: 100%;
height: 80rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
display: flex;
align-items: center;
justify-content: center;
}
.result-btn text {
color: #fff;
font-size: 30rpx;
}
</style>
@@ -0,0 +1,132 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;">
<view class="tip-bar">
<uni-icons type="locked" size="20" color="#34C759"></uni-icons>
<text class="tip-text">以下设置仅对你生效,管理他人查看你的方式</text>
</view>
<view class="list-container">
<view class="list-item" v-for="(item, idx) in items" :key="idx">
<view class="item-left">
<text class="item-title">{{ item.title }}</text>
<text class="item-sub">{{ item.sub }}</text>
</view>
<switch :checked="item.value == 1" color="#34C759" @change="() => onToggle(item)" />
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
type SettingItem = {
key : string
title : string
sub : string
value : number
}
const items = reactive<SettingItem[]>([
{ key: 'hide_age', title: '隐藏年龄', sub: '他人将无法看到你的真实年龄', value: 0 },
{ key: 'hide_online', title: '隐藏在线状态', sub: '他人看不到你是否在线', value: 0 },
{ key: 'hide_distance', title: '隐藏距离', sub: '同城/附近不再显示与你的距离', value: 0 },
{ key: 'hide_album', title: '隐藏相册', sub: '他人访问你的资料时看不到相册', value: 0 },
{ key: 'danmaku_global', title: '弹幕总开关', sub: '关闭后全站不显示互动弹幕', value: 1 },
])
function loadPrivacy() {
Api.user.privacy()
.then((res : UTSJSONObject) => {
items.forEach((it : SettingItem) => {
const v = res.get(it.key)
if (v != null) {
it.value = (v as number) == 1 ? 1 : 0
}
})
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
}
function onToggle(item : SettingItem) {
const next = item.value == 1 ? 0 : 1
item.value = next
Api.user.updatePrivacy({ [item.key]: next })
.then(() => {
uni.showToast({ title: '已更新', icon: 'success', duration: 1000 })
})
.catch((err : any) => {
item.value = item.value == 1 ? 0 : 1
uni.showToast({ title: (err && err.message) ? err.message : '更新失败', icon: 'none' })
})
}
onMounted(() => { loadPrivacy() })
onShow(() => { loadPrivacy() })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.tip-bar {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin: 24rpx 30rpx;
padding: 20rpx 24rpx;
background-color: rgba(52, 199, 89, 0.1);
border-radius: 16rpx;
}
.tip-text {
font-size: 24rpx;
color: #34C759;
margin-left: 12rpx;
flex: 1;
}
.list-container {
margin: 0 30rpx;
background-color: #fff;
border-radius: 20rpx;
overflow: hidden;
}
.list-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.list-item:last-child {
border-bottom: none;
}
.item-left {
display: flex;
flex-direction: column;
flex: 1;
margin-right: 20rpx;
}
.item-title {
font-size: 30rpx;
color: #333;
}
.item-sub {
font-size: 24rpx;
color: #999;
margin-top: 6rpx;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
<template>
<view class="page">
<view class="tabs">
<view class="tab" :class="{ active: type === 'charm' }" @click="switchTab('charm')">
<text>魅力榜</text>
</view>
<view class="tab" :class="{ active: type === 'rich' }" @click="switchTab('rich')">
<text>财富榜</text>
</view>
</view>
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;"
:refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view class="list">
<view v-for="(u, idx) in list" :key="idx" class="rank-item" @click="openUser(u)">
<view class="rank-no" :class="'top' + u.rank">
<text v-if="u.rank <= 3">{{ ['', '①', '②', '③'][u.rank] }}</text>
<text v-else>{{ u.rank }}</text>
</view>
<image class="rank-avatar" :src="imgUrl(u.avatar)" mode="aspectFill"></image>
<view class="rank-info">
<text class="rank-name">{{ u.nickname }}</text>
</view>
<view class="rank-value">
<text class="value-num">{{ u.value }}</text>
<text class="value-unit">{{ type === 'rich' ? '金币' : '粉丝' }}</text>
</view>
</view>
<view v-if="list.length === 0 && !loading" class="empty-state">
<uni-icons type="list" size="60" color="#ddd"></uni-icons>
<text class="empty-text">暂无排行数据</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
const BASE = 'https://dev.xixingwl.cn'
type IRank = {
rank : number
uid : number
nickname : string
avatar : string
value : number
coins : number
}
const type = ref<string>('charm')
const list = reactive<IRank[]>([])
const loading = ref<boolean>(false)
const refreshing = ref<boolean>(false)
function imgUrl(src : string) : string {
if (! src) return ''
return src.startsWith('http') ? src : (BASE + src)
}
function switchTab(t : string) {
if (type.value === t) return
type.value = t
loadData()
}
function openUser(u : IRank) {
uni.navigateTo({ url: '/pages/user/profile?uid=' + u.uid })
}
function loadData() {
if (loading.value) return
loading.value = true
Api.user.ranking({ type: type.value })
.then((res : UTSJSONObject) => {
const arr = (res.get('list') as Array<IRank>) ?? []
list.splice(0, list.length)
arr.forEach((u : IRank) => list.push(u))
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => {
loading.value = false
refreshing.value = false
})
}
function onRefresh() {
refreshing.value = true
loadData()
}
onMounted(() => { loadData() })
onShow(() => { loadData() })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
display: flex;
flex-direction: column;
}
.tabs {
display: flex;
flex-flow: row nowrap;
background-color: #fff;
padding: 16rpx 0;
}
.tab {
flex: 1;
text-align: center;
font-size: 30rpx;
color: #999;
padding: 12rpx 0;
position: relative;
}
.tab.active {
color: #FF6B6B;
font-weight: bold;
}
.tab.active::after {
content: '';
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: 48rpx;
height: 6rpx;
border-radius: 3rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
}
.list {
margin: 24rpx 30rpx;
}
.rank-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
}
.rank-no {
width: 56rpx;
text-align: center;
font-size: 30rpx;
font-weight: bold;
color: #999;
}
.rank-no.top1 { color: #FF9500; }
.rank-no.top2 { color: #FF6B6B; }
.rank-no.top3 { color: #AF52DE; }
.rank-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
background-color: #f0f0f0;
margin: 0 20rpx;
}
.rank-info {
flex: 1;
}
.rank-name {
font-size: 30rpx;
color: #333;
font-weight: 500;
}
.rank-value {
display: flex;
flex-flow: row nowrap;
align-items: baseline;
}
.value-num {
font-size: 34rpx;
font-weight: bold;
color: #FF6B6B;
}
.value-unit {
font-size: 22rpx;
color: #999;
margin-left: 4rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
margin-top: 20rpx;
}
</style>
@@ -0,0 +1,178 @@
<template>
<view class="page">
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;">
<view class="list-title">账号绑定</view>
<view class="list-container">
<view class="list-item" @click="toast('手机号已绑定')">
<view class="item-left">
<uni-icons type="phone" size="20" color="#007AFF"></uni-icons>
<text class="list-text">手机号</text>
</view>
<view class="item-right">
<text class="item-desc">{{ mobileMask }}</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
<view class="list-item" @click="toast('修改密码功能开发中')">
<view class="item-left">
<uni-icons type="locked" size="20" color="#FF9500"></uni-icons>
<text class="list-text">修改密码</text>
</view>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
<view class="list-title">隐私与安全</view>
<view class="list-container">
<view class="list-item" @click="goPage('/pages/my/privacy')">
<view class="item-left">
<uni-icons type="eye" size="20" color="#34C759"></uni-icons>
<text class="list-text">隐私设置</text>
</view>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
<view class="list-item" @click="toast('登录设备管理开发中')">
<view class="item-left">
<uni-icons type="staff" size="20" color="#5AC8FA"></uni-icons>
<text class="list-text">登录设备管理</text>
</view>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
<view class="list-title">其他</view>
<view class="list-container">
<view class="list-item danger" @click="onCancel">
<view class="item-left">
<uni-icons type="trash" size="20" color="#FF3B30"></uni-icons>
<text class="list-text danger-text">注销账号</text>
</view>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
<text class="version">当前版本 v{{ appVersion }}</text>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
import userState from '@/stores/user.uts'
const appVersion = ref('2.5.0')
const mobileMask = computed<string>(() => {
const info = userState.info
const m = info && info.mobile ? (info.mobile as string) : ''
if (m.length >= 11) {
return m.substring(0, 3) + '****' + m.substring(7)
}
return m || '未绑定'
})
function toast(msg : string) {
uni.showToast({ title: msg, icon: 'none' })
}
function goPage(url : string) {
uni.navigateTo({ url: url })
}
function onCancel() {
uni.showModal({
title: '注销账号',
content: '注销后你的资料将被匿名化处理,且无法恢复,确定继续吗?',
confirmText: '注销',
confirmColor: '#FF3B30',
success: (res) => {
if (! res.confirm) return
Api.user.cancelAccount()
.then(() => {
Api.auth.logout()
uni.reLaunch({ url: '/pages/login/index' })
})
.catch((e : any) => {
uni.showToast({ title: (e && e.message) ? e.message : '操作失败', icon: 'none' })
})
}
})
}
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.list-title {
font-size: 28rpx;
color: #999;
margin: 24rpx 30rpx 16rpx;
}
.list-container {
margin: 0 30rpx;
background-color: #fff;
border-radius: 20rpx;
overflow: hidden;
}
.list-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:active {
background-color: #f8f8f8;
}
.item-left {
display: flex;
flex-flow: row nowrap;
align-items: center;
flex: 1;
}
.item-left .uni-icons {
margin-right: 20rpx;
}
.list-text {
font-size: 30rpx;
color: #333;
}
.item-right {
display: flex;
flex-flow: row nowrap;
align-items: center;
}
.item-desc {
font-size: 28rpx;
color: #999;
margin-right: 12rpx;
}
.danger-text {
color: #FF3B30;
}
.version {
display: block;
text-align: center;
font-size: 24rpx;
color: #bbb;
margin: 60rpx 0;
}
</style>
@@ -0,0 +1,270 @@
<template>
<view class="page">
<view class="-status-bar"></view>
<view class="header">
<view class="coins-card">
<uni-icons type="wallet-filled" size="36" color="#FFB300"></uni-icons>
<view class="coins-info">
<text class="coins-num">{{ coins }}</text>
<text class="coins-label">我的金币</text>
</view>
</view>
</view>
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;">
<view class="task-item" v-for="(item, idx) in tasks" :key="item.id">
<view class="task-icon">
<uni-icons :type="item.icon" size="30" color="#fff"></uni-icons>
</view>
<view class="task-main">
<text class="task-title">{{ item.title }}</text>
<text class="task-desc">{{ item.desc }}</text>
<view class="task-progress" v-if="item.status != 2">
<view class="bar">
<view class="bar-inner" :style="{ width: progressWidth(item) }"></view>
</view>
<text class="bar-text">{{ item.current }}/{{ item.target }}</text>
</view>
</view>
<view class="task-reward">+{{ item.reward }}</view>
<button class="task-btn" :class="btnClass(item.status)" :disabled="item.status == 2"
@click="onReceive(item)">
{{ btnText(item.status) }}
</button>
</view>
<view v-if="tasks.length === 0 && !loading" class="empty">
<text class="empty-text">暂无任务</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
type ITask = {
id : number
title : string
desc : string
icon : string
reward : number
target : number
current : number
status : number
}
const coins = ref<number>(0)
const loading = ref<boolean>(false)
const tasks = reactive<ITask[]>([])
const btnText = (status : number) : string => {
if (status == 2) return '已领取'
if (status == 1) return '领取'
return '去完成'
}
const btnClass = (status : number) : string => {
if (status == 2) return 'done'
if (status == 1) return 'ready'
return 'todo'
}
const progressWidth = (item : ITask) : string => {
const pct = item.target > 0 ? (item.current / item.target * 100) : 0
return Math.min(100, pct) + '%'
}
function loadData() {
if (loading.value) return
loading.value = true
Api.task.list()
.then((res : UTSJSONObject) => {
coins.value = (res.get('coins') as number) ?? 0
const list = (res.get('list') as Array<ITask>) ?? []
tasks.splice(0, tasks.length)
list.forEach((t : ITask) => tasks.push(t))
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => { loading.value = false })
}
function onReceive(item : ITask) {
if (item.status == 2) return
if (item.status == 0) {
uni.showToast({ title: '任务未完成', icon: 'none' })
return
}
uni.showLoading({ title: '领取中...', mask: true })
Api.task.receive(item.id)
.then((res : UTSJSONObject) => {
item.status = 2
coins.value = (res.get('coins_left') as number) ?? coins.value
uni.hideLoading()
uni.showToast({ title: '领取成功 +' + item.reward + '金币', icon: 'none' })
})
.catch((e : any) => {
uni.hideLoading()
const msg = (e as UTSJSONObject)?.get('message') as string
uni.showToast({ title: msg ?? '领取失败', icon: 'none' })
})
}
onMounted(() => { loadData() })
onShow(() => { loadData() })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.-status-bar {
height: var(--status-bar-height);
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
}
.header {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
padding-bottom: 30rpx;
}
.coins-card {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin: 24rpx 30rpx 0;
padding: 30rpx;
background-color: rgba(255, 255, 255, 0.18);
border-radius: 20rpx;
}
.coins-info {
display: flex;
flex-direction: column;
margin-left: 20rpx;
}
.coins-num {
font-size: 44rpx;
font-weight: bold;
color: #fff;
}
.coins-label {
font-size: 24rpx;
color: #ffe;
margin-top: 4rpx;
}
.task-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin: 20rpx 30rpx 0;
padding: 28rpx 24rpx;
background-color: #fff;
border-radius: 18rpx;
box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.04);
}
.task-icon {
width: 84rpx;
height: 84rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #FF8E53 0%, #FF6B6B 100%);
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.task-main {
flex: 1;
display: flex;
flex-direction: column;
}
.task-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.task-desc {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
.task-progress {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-top: 10rpx;
}
.bar {
flex: 1;
height: 12rpx;
border-radius: 6rpx;
background-color: #f0f0f0;
overflow: hidden;
margin-right: 12rpx;
}
.bar-inner {
height: 100%;
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
border-radius: 6rpx;
}
.bar-text {
font-size: 20rpx;
color: #bbb;
}
.task-reward {
font-size: 26rpx;
font-weight: bold;
color: #FF9500;
margin-right: 16rpx;
}
.task-btn {
height: 60rpx;
padding: 0 28rpx;
line-height: 60rpx;
border-radius: 30rpx;
font-size: 26rpx;
border: none;
}
.task-btn.ready {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
color: #fff;
}
.task-btn.todo {
background-color: #f0f0f0;
color: #999;
}
.task-btn.done {
background-color: #f0f0f0;
color: #ccc;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
}
</style>
@@ -0,0 +1,231 @@
<template>
<view class="page">
<view class="-status-bar"></view>
<view class="header">
<view class="balance-card">
<text class="balance-label">账户余额(元)</text>
<text class="balance-num">{{ balance.toFixed(2) }}</text>
<view class="coin-row">
<uni-icons type="wallet-filled" size="22" color="#FFD700"></uni-icons>
<text class="coin-num">{{ coins }}</text>
<text class="coin-label">金币</text>
</view>
</view>
<view class="summary-row">
<view class="summary-item">
<text class="s-num">{{ totalRecharge.toFixed(2) }}</text>
<text class="s-label">累计充值</text>
</view>
<view class="summary-item">
<text class="s-num">{{ totalConsume.toFixed(2) }}</text>
<text class="s-label">累计消费</text>
</view>
</view>
</view>
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;">
<view class="block-title">明细记录</view>
<view v-if="logs.length === 0 && !loading" class="empty">
<text class="empty-text">暂无明细</text>
</view>
<view class="log-item" v-for="(log, idx) in logs" :key="log.id">
<view class="log-left">
<text class="log-type">{{ log.remark && log.remark != '' ? log.remark : log.type }}</text>
<text class="log-time">{{ formatTime(log.create_at) }}</text>
</view>
<text class="log-num" :class="log.change_num >= 0 ? 'plus' : 'minus'">{{ log.change_num >= 0 ? '+' : '' }}{{ log.change_num }}</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
type ILog = {
id : number
type : string
change_num : number
balance_after : number
remark : string
create_at : number
}
const loading = ref<boolean>(false)
const balance = ref<number>(0)
const coins = ref<number>(0)
const totalRecharge = ref<number>(0)
const totalConsume = ref<number>(0)
const logs = reactive<ILog[]>([])
function loadData() {
if (loading.value) return
loading.value = true
Api.user.wallet()
.then((res : UTSJSONObject) => {
balance.value = (res.get('balance') as number) ?? 0
coins.value = (res.get('coins') as number) ?? 0
totalRecharge.value = (res.get('total_recharge') as number) ?? 0
totalConsume.value = (res.get('total_consume') as number) ?? 0
const arr = (res.get('logs') as Array<ILog>) ?? []
logs.splice(0, logs.length)
arr.forEach((l : ILog) => logs.push(l))
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => { loading.value = false })
}
function formatTime(ts : number) : string {
if (!ts) return ''
const d = new Date(ts * 1000)
const pad = (n : number) : string => { return n < 10 ? ('0' + n) : ('' + n) }
return (d.getMonth() + 1) + '-' + d.getDate() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
}
onMounted(() => { loadData() })
onShow(() => { loadData() })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.-status-bar {
height: var(--status-bar-height);
background: linear-gradient(135deg, #FFB75E 0%, #ED8F03 100%);
}
.header {
background: linear-gradient(135deg, #FFB75E 0%, #ED8F03 100%);
padding: 30rpx 30rpx 50rpx;
}
.balance-card {
display: flex;
flex-direction: column;
}
.balance-label {
font-size: 26rpx;
color: #fff;
opacity: 0.9;
}
.balance-num {
font-size: 64rpx;
font-weight: bold;
color: #fff;
margin: 8rpx 0 20rpx;
}
.coin-row {
display: flex;
flex-flow: row nowrap;
align-items: center;
}
.coin-num {
font-size: 30rpx;
font-weight: bold;
color: #fff;
margin: 0 8rpx 0 12rpx;
}
.coin-label {
font-size: 24rpx;
color: #fff;
opacity: 0.9;
}
.summary-row {
display: flex;
flex-flow: row nowrap;
margin-top: 36rpx;
}
.summary-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
border-right: 1rpx solid rgba(255, 255, 255, 0.3);
}
.summary-item:last-child {
border-right: none;
}
.s-num {
font-size: 34rpx;
font-weight: bold;
color: #fff;
}
.s-label {
font-size: 22rpx;
color: #fff;
opacity: 0.9;
margin-top: 6rpx;
}
.block-title {
font-size: 28rpx;
color: #999;
margin: 30rpx 30rpx 16rpx;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
}
.log-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
padding: 28rpx 30rpx;
background-color: #fff;
border-bottom: 1rpx solid #f2f2f2;
}
.log-left {
display: flex;
flex-direction: column;
}
.log-type {
font-size: 28rpx;
color: #333;
}
.log-time {
font-size: 22rpx;
color: #bbb;
margin-top: 6rpx;
}
.log-num {
font-size: 32rpx;
font-weight: bold;
}
.log-num.plus {
color: #34C759;
}
.log-num.minus {
color: #FF3B30;
}
</style>
@@ -0,0 +1,212 @@
<template>
<view class="page">
<view class="-status-bar"></view>
<view class="header">
<text class="title">消息通知</text>
</view>
<scroll-view direction="vertical" :show-scrollbar="false" style="flex:1;">
<view v-if="list.length === 0 && !loading" class="empty">
<uni-icons type="chat" size="64" color="#e0e0e0"></uni-icons>
<text class="empty-text">暂无消息</text>
</view>
<view class="conv-item" v-for="(item, idx) in list" :key="item.peer_id" @click="openChat(item)">
<view class="avatar-wrap">
<image class="avatar" :src="avatarUrl(item.avatar)" mode="aspectFill"></image>
<view v-if="item.online" class="online-dot"></view>
<view v-if="item.unread_count > 0" class="badge">{{ item.unread_count > 99 ? '99+' : item.unread_count }}</view>
</view>
<view class="conv-main">
<view class="conv-top">
<text class="name">{{ item.nickname }}</text>
<text class="time">{{ formatTime(item.last_time) }}</text>
</view>
<text class="last">{{ item.is_self ? '我: ' : '' }}{{ item.last_content }}</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import Api from '@/common/api-service.uts'
type IConv = {
peer_id : number
nickname : string
avatar : string
online : boolean
last_content : string
last_content_type : number
last_time : number
unread_count : number
is_self : boolean
}
const BASE = 'https://dev.xixingwl.cn'
const loading = ref<boolean>(false)
const list = reactive<IConv[]>([])
const avatarUrl = (avatar : string) : string => {
if (avatar == null || avatar == '') return ''
if (avatar.startsWith('http')) return avatar
return BASE + avatar
}
function loadData() {
if (loading.value) return
loading.value = true
Api.message.conversations()
.then((res : UTSJSONObject) => {
const arr = (res.get('list') as Array<IConv>) ?? []
list.splice(0, list.length)
arr.forEach((c : IConv) => list.push(c))
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => { loading.value = false })
}
function openChat(item : IConv) {
uni.navigateTo({ url: `/pages/chat/chat?userId=${item.peer_id}&userName=${item.nickname}` })
}
function formatTime(ts : number) : string {
if (!ts) return ''
const now = Math.floor(Date.now() / 1000)
const diff = now - ts
if (diff < 60) return '刚刚'
if (diff < 3600) return Math.floor(diff / 60) + '分钟前'
if (diff < 86400) return Math.floor(diff / 3600) + '小时前'
if (diff < 7 * 86400) return Math.floor(diff / 86400) + '天前'
const d = new Date(ts * 1000)
return (d.getMonth() + 1) + '/' + d.getDate()
}
onMounted(() => { loadData() })
onShow(() => { loadData() })
</script>
<style lang="scss">
.page {
flex: 1;
background-color: #f7f7f7;
}
.-status-bar {
height: var(--status-bar-height);
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
}
.header {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
padding: 24rpx 30rpx 36rpx;
display: flex;
align-items: center;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #fff;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 140rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
margin-top: 20rpx;
}
.conv-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
padding: 28rpx 30rpx;
background-color: #fff;
border-bottom: 1rpx solid #f2f2f2;
}
.conv-item:active {
background-color: #f8f8f8;
}
.avatar-wrap {
position: relative;
margin-right: 24rpx;
}
.avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background-color: #f0f0f0;
}
.online-dot {
position: absolute;
right: 0;
bottom: 0;
width: 22rpx;
height: 22rpx;
border-radius: 50%;
background-color: #4CD964;
border: 4rpx solid #fff;
}
.badge {
position: absolute;
top: -8rpx;
right: -8rpx;
min-width: 32rpx;
height: 32rpx;
padding: 0 6rpx;
border-radius: 16rpx;
background-color: #FF3B30;
color: #fff;
font-size: 20rpx;
line-height: 32rpx;
text-align: center;
border: 2rpx solid #fff;
}
.conv-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.conv-top {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
}
.name {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.time {
font-size: 22rpx;
color: #bbb;
}
.last {
font-size: 26rpx;
color: #999;
margin-top: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,155 @@
<template>
<view class="page">
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<uni-icons type="left" size="22" color="#333"></uni-icons>
</view>
<text class="nav-title">神兽宠物</text>
<view class="nav-right"></view>
</view>
<view class="hero">
<text class="hero-emoji">🐾</text>
<text class="hero-title">神兽宠物社交</text>
<text class="hero-sub">养一只专属神兽,结识同好铲屎官</text>
</view>
<view class="grid">
<view class="pet-card" v-for="(pet, i) in pets" :key="i" @click="selectPet(pet)">
<text class="pet-emoji">{{ pet.emoji }}</text>
<text class="pet-name">{{ pet.name }}</text>
<text class="pet-desc">{{ pet.desc }}</text>
</view>
</view>
<view class="tip">
<text>领养、互动、宠物社区功能即将上线~</text>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
const pets = ref([
{ emoji: '🐱', name: '招财猫', desc: '带来好运' },
{ emoji: '🐶', name: '旺旺犬', desc: '守护缘分' },
{ emoji: '🐰', name: '月亮兔', desc: '温柔陪伴' },
{ emoji: '🐉', name: '祥云龙', desc: '稀有神兽' }
] as UTSJSONObject[])
const selectPet = (pet : UTSJSONObject) => {
uni.showToast({ title: '选中「' + (pet.getString('name') ?? '') + '」', icon: 'none' })
}
const goBack = () => {
uni.navigateBack()
}
</script>
<style>
.page {
display: flex;
flex-direction: column;
background-color: #f6f6f6;
min-height: 100%;
}
.nav-bar {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 24rpx;
padding-top: 60rpx;
background-color: #fff;
}
.nav-back {
width: 60rpx;
display: flex;
align-items: center;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: bold;
color: #333;
}
.nav-right {
width: 60rpx;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx 0 40rpx;
background: linear-gradient(to bottom right, #5AC8FA, #007AFF);
}
.hero-emoji {
font-size: 90rpx;
}
.hero-title {
font-size: 40rpx;
font-weight: bold;
color: #fff;
margin-top: 16rpx;
}
.hero-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 10rpx;
}
.grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 24rpx;
justify-content: space-between;
}
.pet-card {
width: 48%;
background-color: #fff;
border-radius: 20rpx;
padding: 36rpx 0;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 24rpx;
}
.pet-emoji {
font-size: 72rpx;
}
.pet-name {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-top: 16rpx;
}
.pet-desc {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
}
.tip {
text-align: center;
padding: 30rpx;
}
.tip text {
font-size: 24rpx;
color: #aaa;
}
</style>
@@ -0,0 +1,464 @@
<template>
<view class="page">
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<uni-icons type="left" size="22" color="#333"></uni-icons>
</view>
<text class="nav-title">真人匹配</text>
<view class="nav-right">
<uni-icons type="reload" size="22" color="#333" @click="reload"></uni-icons>
</view>
</view>
<view class="filter-bar">
<text class="filter-label">缘分卡片</text>
<text class="filter-sub">右滑喜欢 · 左滑跳过,遇到同频的人</text>
</view>
<!-- 滑卡区 -->
<view class="card-stack" v-if="displayCards.length > 0">
<view v-for="(card, idx) in displayCards" :key="card.uid" class="card"
:style="cardStyle(idx)">
<!-- 顶部照片 -->
<image class="card-photo" :src="card.avatar" mode="aspectFill"></image>
<!-- 喜欢 / 跳过 印章 -->
<view v-if="idx === 0" class="stamp like-stamp" :style="{ opacity: likeOpacity }">喜欢</view>
<view v-if="idx === 0" class="stamp nope-stamp" :style="{ opacity: nopeOpacity }">跳过</view>
<!-- 底部信息 -->
<view class="card-info">
<view class="card-name-row">
<text class="card-name">{{ card.name }}</text>
<text class="card-age" v-if="card.age > 0">{{ card.age }}岁</text>
<text class="card-online" v-if="card.isOnline">在线</text>
</view>
<view class="card-meta" v-if="card.location || card.distance > 0">
<text class="card-meta-i" v-if="card.location">{{ card.location }}</text>
<text class="card-meta-i" v-if="card.distance > 0">{{ card.distance }}km</text>
</view>
<text class="card-bio" v-if="card.bio">{{ card.bio }}</text>
</view>
<!-- 仅顶层卡片绑定手势 -->
<view v-if="idx === 0" class="card-gesture"
@touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd"></view>
</view>
</view>
<!-- 空态 -->
<view v-else class="empty">
<text class="empty-text">附近暂时没有更多缘分啦~</text>
<button class="empty-btn" @click="reload">换一批</button>
</view>
<!-- 操作按钮 -->
<view class="actions" v-if="displayCards.length > 0">
<view class="act-btn skip" @click="skipTop">
<uni-icons type="close" size="28" color="#FF6B6B"></uni-icons>
</view>
<view class="act-btn like" @click="likeTop">
<uni-icons type="heart" size="30" color="#fff"></uni-icons>
</view>
</view>
<view class="tip">滑动卡片或点击按钮,喜欢即关注对方</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import { Api } from '@/common/api-service.uts'
import { getUserInfo } from '@/stores/user.uts'
const cards = ref<UTSJSONObject[]>([])
const offsetX = ref(0)
const rotate = ref(0)
const likeOpacity = ref(0)
const nopeOpacity = ref(0)
const dragging = ref(false)
let startX = 0
let startY = 0
let loading = false
const displayCards = computed<UTSJSONObject[]>(() => {
return cards.value.slice(0, 3)
})
const meUid = computed<number>(() => {
return getUserInfo()?.uid ?? 0
})
const mapCard = (it : UTSJSONObject) : UTSJSONObject => {
const city = it.getString('residecity') ?? ''
const province = it.getString('resideprovince') ?? ''
return {
uid: it.getNumber('uid') ?? 0,
name: it.getString('nickname') ?? '匿名用户',
avatar: it.getString('avatar') ?? '',
age: it.getNumber('age') ?? 0,
isOnline: it.getBoolean('is_online') ?? false,
location: city.length > 0 ? city : province,
distance: it.getNumber('distance') ?? 0,
bio: it.getString('bio') ?? ''
} as UTSJSONObject
}
const loadUsers = async () => {
if (loading) return
loading = true
try {
let data : UTSJSONObject | null = null
let list : any = null
try {
data = await Api.user.nearby({ limit: 30, radius: 100 })
list = data.getJSONArray('list')
} catch (e) {
console.warn('nearby 失败,回退 local/online', e)
}
if (list == null || list.size() == 0) {
try {
data = await Api.user.local({})
list = data.getJSONArray('list')
} catch (e) { console.warn('local 失败', e) }
}
if (list == null || list.size() == 0) {
try {
data = await Api.user.online({})
list = data.getJSONArray('list')
} catch (e) { console.warn('online 失败', e) }
}
const arr : UTSJSONObject[] = []
if (list != null) {
for (let i = 0; i < list.size(); i++) {
const it = list.get(i) as UTSJSONObject
if (it != null) arr.push(mapCard(it))
}
}
if (arr.length > 0) {
cards.value = arr
}
} catch (e) {
console.warn('加载匹配用户失败', e)
} finally {
loading = false
}
}
const reload = () => {
offsetX.value = 0
rotate.value = 0
likeOpacity.value = 0
nopeOpacity.value = 0
loadUsers()
}
const removeTop = () => {
if (cards.value.length > 0) {
cards.value.splice(0, 1)
}
offsetX.value = 0
rotate.value = 0
likeOpacity.value = 0
nopeOpacity.value = 0
if (cards.value.length === 0) {
loadUsers()
}
}
const likeTop = () => {
const top = cards.value[0]
if (top != null) {
const uid = top.getNumber('uid') ?? 0
if (uid > 0 && meUid.value > 0) {
Api.follow.toggle(meUid.value, uid, 1).then(() => {
uni.showToast({ title: '已喜欢,已关注', icon: 'none' })
}).catch((err : any) => {
console.warn('关注失败', err)
})
}
}
removeTop()
}
const skipTop = () => {
removeTop()
}
// ===== 手势 =====
const onTouchStart = (e : any) => {
dragging.value = true
const t = e.touches[0]
startX = t.clientX
startY = t.clientY
}
const onTouchMove = (e : any) => {
if (! dragging.value) return
const t = e.touches[0]
const dx = t.clientX - startX
const dy = t.clientY - startY
offsetX.value = dx
rotate.value = dx / 20
likeOpacity.value = dx > 0 ? Math.min(dx / 100, 1) : 0
nopeOpacity.value = dx < 0 ? Math.min(-dx / 100, 1) : 0
}
const onTouchEnd = (e : any) => {
if (! dragging.value) return
dragging.value = false
const dx = offsetX.value
if (dx > 100) {
likeTop()
} else if (dx < -100) {
skipTop()
} else {
offsetX.value = 0
rotate.value = 0
likeOpacity.value = 0
nopeOpacity.value = 0
}
}
const cardStyle = (idx : number) : UTSJSONObject => {
if (idx === 0) {
return {
transform: 'translateX(' + offsetX.value + 'px) rotate(' + rotate.value + 'deg)',
zIndex: 10
} as UTSJSONObject
}
return {
transform: 'scale(' + (1 - idx * 0.05) + ') translateY(' + (idx * 16) + 'px)',
zIndex: 10 - idx,
opacity: 0.92
} as UTSJSONObject
}
const goBack = () => {
uni.navigateBack()
}
onMounted(() => {
loadUsers()
})
</script>
<style>
.page {
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #FFE9EC 0%, #f6f6f6 40%);
min-height: 100%;
box-sizing: border-box;
padding-bottom: 40rpx;
}
.nav-bar {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 24rpx;
padding-top: 60rpx;
}
.nav-back,
.nav-right {
width: 80rpx;
display: flex;
align-items: center;
}
.nav-right {
justify-content: flex-end;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: bold;
color: #333;
}
.filter-bar {
display: flex;
flex-direction: column;
padding: 10rpx 40rpx 20rpx;
}
.filter-label {
font-size: 38rpx;
font-weight: bold;
color: #222;
}
.filter-sub {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
.card-stack {
position: relative;
height: 880rpx;
margin: 20rpx 50rpx;
}
.card {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 880rpx;
border-radius: 28rpx;
overflow: hidden;
background-color: #fff;
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.12);
}
.card-photo {
width: 100%;
height: 700rpx;
background-color: #eee;
}
.card-gesture {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
.stamp {
position: absolute;
top: 60rpx;
padding: 10rpx 30rpx;
border: 6rpx solid;
border-radius: 16rpx;
font-size: 52rpx;
font-weight: bold;
transform: rotate(-18deg);
}
.like-stamp {
left: 40rpx;
color: #4CD964;
border-color: #4CD964;
}
.nope-stamp {
right: 40rpx;
color: #FF3B30;
border-color: #FF3B30;
transform: rotate(18deg);
}
.card-info {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 24rpx 30rpx 40rpx;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.55) 100%);
}
.card-name-row {
display: flex;
flex-direction: row;
align-items: center;
}
.card-name {
font-size: 40rpx;
font-weight: bold;
color: #fff;
}
.card-age {
font-size: 32rpx;
color: #fff;
margin-left: 16rpx;
}
.card-online {
font-size: 22rpx;
color: #fff;
background: #4CD964;
border-radius: 20rpx;
padding: 4rpx 14rpx;
margin-left: 16rpx;
}
.card-meta {
display: flex;
flex-direction: row;
margin-top: 12rpx;
}
.card-meta-i {
font-size: 24rpx;
color: #fff;
background: rgba(255, 255, 255, 0.25);
border-radius: 20rpx;
padding: 4rpx 16rpx;
margin-right: 12rpx;
}
.card-bio {
font-size: 24rpx;
color: #f0f0f0;
margin-top: 12rpx;
overflow: hidden;
}
.actions {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
margin-top: 30rpx;
}
.act-btn {
width: 110rpx;
height: 110rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 40rpx;
background: #fff;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
}
.act-btn.like {
background: linear-gradient(135deg, #FF6B6B, #FF8E53);
}
.tip {
text-align: center;
font-size: 24rpx;
color: #999;
margin-top: 30rpx;
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #aaa;
margin-bottom: 30rpx;
}
.empty-btn {
background: linear-gradient(135deg, #FF6B6B, #FF8E53);
color: #fff;
border: none;
border-radius: 40rpx;
font-size: 26rpx;
padding: 12rpx 50rpx;
}
</style>
@@ -0,0 +1,17 @@
<template>
<!-- #ifdef APP -->
<scroll-view style="flex:1">
<!-- #endif -->
<!-- #ifdef APP -->
</scroll-view>
<!-- #endif -->
</template>
<script setup>
</script>
<style>
</style>
@@ -0,0 +1,613 @@
<template>
<scroll-view class="page" :show-scrollbar="false" style="padding-bottom: 140rpx;">
<view class="-status-bar"></view>
<!-- 照片墙 -->
<swiper class="photo-swiper" :indicator-dots="true" :circular="true" v-if="userInfo.albumThumbs.length > 0">
<swiper-item v-for="(img, i) in userInfo.albumThumbs" :key="i">
<image :src="img" mode="aspectFill" class="photo"></image>
</swiper-item>
</swiper>
<view v-else class="photo-swiper photo-empty">
<uni-icons type="person" size="80" color="#ccc"></uni-icons>
</view>
<!-- 昵称 / 年龄 / 在线 -->
<view class="head">
<text class="nickname">{{ userInfo.nickname }}</text>
<text class="age" v-if="userInfo.age > 0">{{ userInfo.age }}岁</text>
<text class="online" v-if="userInfo.isOnline">在线</text>
</view>
<!-- 标签切换 -->
<view class="tabsbox" style="justify-content: space-around;">
<text class="tabsbox-item" style="flex:1"
:class="currentTab == 'about' ? 'tabsbox-active tabsbox-active-txt' : ''"
@click="switchTab('about')">关于TA</text>
<text class="tabsbox-item" style="flex:1"
:class="currentTab == 'moments' ? 'tabsbox-active tabsbox-active-txt' : ''"
@click="switchTab('moments')">动态 ({{ userInfo.profile.momentCount }})</text>
</view>
<!-- 关于TA -->
<view v-if="currentTab == 'about'">
<view class="panel">
<text class="panel-title">个人资料</text>
<view class="panel-content">
<text class="panel-text" v-if="userInfo.profile.age > 0">{{ userInfo.profile.age }}岁</text>
<text class="panel-text" v-if="userInfo.profile.height > 0">{{ userInfo.profile.height }}cm</text>
<text class="panel-text" v-if="userInfo.profile.weight > 0">{{ userInfo.profile.weight }}kg</text>
<text class="panel-text" v-if="userInfo.profile.live">{{ userInfo.profile.live }}</text>
<text class="panel-text" v-if="userInfo.profile.hometown">{{ userInfo.profile.hometown }}</text>
<text class="panel-text" v-if="userInfo.profile.education">{{ userInfo.profile.education }}</text>
<text class="panel-text" v-if="userInfo.profile.profession">{{ userInfo.profile.profession }}</text>
<text class="panel-text" v-if="userInfo.profile.constellation">{{ userInfo.profile.constellation }}</text>
<text class="panel-text" v-if="userInfo.profile.annualIncome">{{ userInfo.profile.annualIncome }}</text>
</view>
</view>
<view class="panel" v-if="userInfo.profile.bio">
<text class="panel-title">个性签名</text>
<view class="panel-content" style="flex-flow: column; align-items: flex-start;">
<text class="bio">{{ userInfo.profile.bio }}</text>
</view>
</view>
<view class="panel">
<text class="panel-title">收到的礼物</text>
<view class="panel-content" style="flex-flow: column; align-items: flex-start;">
<text class="bio tip">在聊天中赠送礼物,表达你的心意~</text>
</view>
</view>
</view>
<!-- 动态 -->
<view v-if="currentTab == 'moments'">
<view class="panel">
<view class="panel-content" style="flex-flow: column; align-items: center;">
<text class="bio tip">该用户暂未公开动态</text>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="tabbar-op">
<view class="tabbar-item" @click="onFollowClick">
<uni-icons :type="isFollowed ? 'star-filled' : 'star'" size="40"
:color="isFollowed ? '#FFB400' : '#666'"></uni-icons>
<text class="tabbar-text">{{ isFollowed ? '已关注' : '关注' }}</text>
</view>
<view class="tabbar-item" @click="onChatClick">
<uni-icons type="chat" size="40" color="#666"></uni-icons>
<text class="tabbar-text">私聊</text>
</view>
<view class="tabbar-item" @click="onGiftClick">
<uni-icons type="gift" size="40" color="#666"></uni-icons>
<text class="tabbar-text">礼物</text>
</view>
<view class="tabbar-item tabbar-hion" @click="onAccostClick">
<uni-icons type="heart" size="40" color="#FF5C8A"></uni-icons>
<text class="tabbar-text">搭讪</text>
</view>
</view>
<!-- 送礼弹窗 -->
<view v-if="showGift" class="gift-mask" @click="closeGift">
<view class="gift-sheet" @click.stop>
<view class="gift-sheet-head">
<text class="gift-sheet-title">赠送礼物</text>
<uni-icons type="close" size="22" color="#999" @click="closeGift"></uni-icons>
</view>
<scroll-view class="gift-sheet-list" direction="vertical" :show-scrollbar="false">
<view v-for="(g, i) in gifts" :key="g.id" class="gift-sheet-cell"
:class="{ active: selectedGift != null && selectedGift.id == g.id }" @click="selectGift(g)">
<text class="gift-sheet-name">{{ g.name }}</text>
<text class="gift-sheet-price">{{ g.price }}金币</text>
</view>
<view v-if="gifts.length == 0" class="gift-sheet-empty">
<text class="empty-text">暂无可赠送的礼物</text>
</view>
</scroll-view>
<view class="gift-sheet-count">
<text class="count-label">数量</text>
<view class="count-ctrl">
<text class="count-btn" @click="giftCount > 1 ? giftCount = giftCount - 1 : null">-</text>
<text class="count-num">{{ giftCount }}</text>
<text class="count-btn" @click="giftCount = giftCount + 1">+</text>
</view>
</view>
<view class="gift-sheet-send" @click="sendGift">
<text class="send-text">赠送({{ giftTotal }}金币)</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref, reactive, computed, onLoad } from 'vue'
import { Api } from '@/common/api-service.uts'
import { getUserInfo } from '@/stores/user.uts'
type IViewProfile = {
age: number
height: number
weight: number
live: string
hometown: string
education: string
profession: string
constellation: string
annualIncome: string
bio: string
momentCount: number
uid: number
}
type IViewUser = {
uid: number
nickname: string
avatar: string
albumThumbs: string[]
age: number
isOnline: boolean
profile: IViewProfile
}
const userInfo = reactive<IViewUser>({
uid: 0,
nickname: '',
avatar: '',
albumThumbs: [],
age: 0,
isOnline: false,
profile: {
age: 0, height: 0, weight: 0, live: '', hometown: '', education: '',
profession: '', constellation: '', annualIncome: '', bio: '', momentCount: 0, uid: 0
}
})
const currentTab = ref('about')
const isFollowed = ref(false)
const targetUid = ref(0)
const switchTab = (tab : string) => {
currentTab.value = tab
}
const loadDetail = async (uid : number) => {
try {
const data = await Api.user.detail(uid)
const p = data
const city = p.getString('residecity') ?? ''
const province = p.getString('resideprovince') ?? ''
const age = p.getNumber('age') ?? 0
const photos = p.getJSONArray('photos')
const album : string[] = []
if (photos != null && photos.size() > 0) {
for (let i = 0; i < photos.size(); i++) {
const s = photos.get(i) as string
if (s != null && s.length > 0) album.push(s)
}
}
const avatar = p.getString('avatar') ?? ''
if (album.length == 0 && avatar.length > 0) album.push(avatar)
userInfo.uid = uid
userInfo.nickname = p.getString('nickname') ?? '匿名用户'
userInfo.avatar = avatar
userInfo.albumThumbs = album
userInfo.age = age
userInfo.isOnline = p.getBoolean('is_online') ?? false
userInfo.profile = {
age: age,
height: p.getNumber('height') ?? 0,
weight: p.getNumber('weight') ?? 0,
live: city.length > 0 ? city : province,
hometown: province,
education: p.getString('education') ?? '',
profession: p.getString('occupation') ?? '',
constellation: p.getString('constellation') ?? '',
annualIncome: p.getString('revenue') ?? '',
bio: p.getString('bio') ?? '',
momentCount: p.getNumber('moment_count') ?? 0,
uid: uid
}
isFollowed.value = (p.getNumber('is_followed') ?? 0) == 1
} catch (e : any) {
uni.showToast({ title: e?.message ?? '加载失败', icon: 'none' })
}
}
const meUid = () : number => {
return getUserInfo()?.uid ?? 0
}
const onFollowClick = () => {
if (targetUid.value <= 0) return
const action = isFollowed.value ? 0 : 1
Api.follow.toggle(meUid(), targetUid.value, action).then(() => {
isFollowed.value = ! isFollowed.value
uni.showToast({ title: isFollowed.value ? '已关注' : '已取消关注', icon: 'none' })
}).catch((err : any) => {
uni.showToast({ title: err?.message ?? '操作失败', icon: 'none' })
})
}
const onChatClick = () => {
if (targetUid.value <= 0) return
uni.navigateTo({ url: `/pages/chat/chat?uid=${targetUid.value}` })
}
const onGiftClick = () => {
showGift.value = true
loadGifts()
}
// ===== 送礼弹窗 =====
type IGift = {
id : number
name : string
icon : string
price : number
}
const showGift = ref<boolean>(false)
const gifts = reactive<IGift[]>([])
const selectedGift = ref<IGift | null>(null)
const giftCount = ref<number>(1)
const giftLoading = ref<boolean>(false)
const giftTotal = computed<number>(() => {
const p = selectedGift.value?.price ?? 0
return p * giftCount.value
})
const loadGifts = () => {
if (giftLoading.value) return
giftLoading.value = true
Api.gift.list()
.then((res : any) => {
const list = (res as Array<IGift>) ?? []
gifts.splice(0, gifts.length)
list.forEach((g : IGift) => gifts.push(g))
if (gifts.length > 0 && selectedGift.value == null) {
selectedGift.value = gifts[0]
}
})
.catch(() => {
uni.showToast({ title: '礼物加载失败', icon: 'none' })
})
.finally(() => { giftLoading.value = false })
}
const selectGift = (g : IGift) => {
selectedGift.value = g
}
const sendGift = () => {
if (selectedGift.value == null) {
uni.showToast({ title: '请选择礼物', icon: 'none' })
return
}
if (targetUid.value <= 0) return
uni.showLoading({ title: '赠送中...', mask: true })
Api.gift.send({
to_uid: targetUid.value,
gift_id: selectedGift.value.id,
count: giftCount.value,
message: ''
})
.then(() => {
uni.hideLoading()
showGift.value = false
uni.showToast({ title: '赠送成功', icon: 'success' })
})
.catch((e : any) => {
uni.hideLoading()
const msg = (e as UTSJSONObject)?.get('message') as string
uni.showToast({ title: msg ?? '赠送失败', icon: 'none' })
})
}
const closeGift = () => {
showGift.value = false
}
const onAccostClick = () => {
if (targetUid.value <= 0) return
Api.message.send({
receiver_id: targetUid.value,
receiver_type: 0,
content: '你好,很高兴认识你~'
}).then(() => {
uni.showToast({ title: '搭讪已送达', icon: 'success' })
}).catch((err : any) => {
uni.showToast({ title: err?.message ?? '搭讪失败', icon: 'none' })
})
}
onLoad((options : any) => {
const uid = Number(options?.uid ?? options?.id ?? 0)
targetUid.value = uid
if (uid > 0) {
loadDetail(uid)
}
})
</script>
<style>
.page {
display: flex;
flex-direction: column;
background-color: #f6f6f6;
min-height: 100%;
}
.photo-swiper {
width: 100%;
height: 760rpx;
background-color: #eee;
}
.photo-empty {
display: flex;
align-items: center;
justify-content: center;
}
.photo {
width: 100%;
height: 100%;
}
.head {
display: flex;
flex-direction: row;
align-items: center;
background: #fff;
padding: 24rpx 30rpx;
}
.nickname {
font-size: 38rpx;
font-weight: bold;
color: #222;
}
.age {
font-size: 30rpx;
color: #666;
margin-left: 16rpx;
}
.online {
font-size: 22rpx;
color: #fff;
background: #4CD964;
border-radius: 20rpx;
padding: 4rpx 14rpx;
margin-left: 16rpx;
}
.tabsbox {
display: flex;
flex-direction: row;
background: #fff;
margin-top: 2rpx;
padding: 20rpx 0;
}
.tabsbox-item {
text-align: center;
font-size: 28rpx;
color: #666;
}
.tabsbox-active {
font-weight: bold;
color: #FF5C8A;
}
.panel {
display: flex;
flex-direction: column;
background: #fff;
margin-top: 16rpx;
padding: 20rpx 30rpx;
}
.panel-title {
font-size: 28rpx;
font-weight: bold;
padding: 10rpx 0;
border-bottom: 1rpx solid #eee;
}
.panel-content {
display: flex;
flex-flow: row wrap;
padding: 24rpx 0;
}
.panel-text {
font-size: 24rpx;
border-radius: 25rpx;
padding: 6rpx 18rpx;
margin: 6rpx;
background: #f3f3f3;
color: #555;
}
.bio {
font-size: 26rpx;
color: #444;
line-height: 40rpx;
}
.tip {
color: #999;
}
.tabbar-op {
width: 100%;
position: fixed;
bottom: var(--window-bottom);
display: flex;
justify-content: space-between;
flex-flow: row nowrap;
align-items: center;
padding: 16rpx 20rpx;
background-color: #fff;
border-top: 1rpx solid #e2e2e2;
}
.tabbar-item {
width: 16%;
display: flex;
justify-content: center;
flex-flow: column nowrap;
align-items: center;
}
.tabbar-text {
font-size: 22rpx;
color: #666;
margin-top: 4rpx;
}
.tabbar-hion {
width: 40%;
flex-flow: row nowrap;
background-image: linear-gradient(to bottom, #ffd2fb, #ffaaff);
border-radius: 40rpx;
padding: 16rpx 0;
}
.gift-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 999;
}
.gift-sheet {
width: 100%;
background-color: #fff;
border-top-left-radius: 28rpx;
border-top-right-radius: 28rpx;
padding: 24rpx 30rpx calc(30rpx + var(--uni-safe-area-inset-bottom));
display: flex;
flex-direction: column;
}
.gift-sheet-head {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
padding-bottom: 16rpx;
}
.gift-sheet-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.gift-sheet-list {
max-height: 420rpx;
display: flex;
flex-flow: row wrap;
}
.gift-sheet-cell {
width: 22%;
margin: 10rpx 1.5%;
padding: 20rpx 0;
border-radius: 16rpx;
background-color: #f7f7f7;
display: flex;
flex-direction: column;
align-items: center;
}
.gift-sheet-cell.active {
background-color: #fff0ec;
border: 2rpx solid #FF6B6B;
}
.gift-sheet-name {
font-size: 26rpx;
color: #333;
}
.gift-sheet-price {
font-size: 22rpx;
color: #FF6B6B;
margin-top: 6rpx;
}
.gift-sheet-empty {
width: 100%;
padding: 60rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.gift-sheet-count {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
margin: 20rpx 0;
}
.count-label {
font-size: 28rpx;
color: #333;
}
.count-ctrl {
display: flex;
flex-flow: row nowrap;
align-items: center;
}
.count-btn {
width: 60rpx;
height: 60rpx;
border-radius: 30rpx;
background-color: #f0f0f0;
text-align: center;
line-height: 60rpx;
font-size: 36rpx;
color: #666;
}
.count-num {
min-width: 60rpx;
text-align: center;
font-size: 30rpx;
color: #333;
}
.gift-sheet-send {
height: 92rpx;
border-radius: 46rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
display: flex;
align-items: center;
justify-content: center;
}
.send-text {
font-size: 32rpx;
font-weight: bold;
color: #fff;
}
</style>
@@ -0,0 +1,451 @@
// message-service.uts
// 实时消息中枢:统一接管 WebSocket 连接、消息分发与会话管理。
//
// 职责:
// 1. 在用户已登录且已拿到后端 appConf.socketUrl 后,自动注入 token 建立连接;
// 2. 监听 wsManager 的 open/message/close/error/reconnect 事件;
// 3. 将服务器下发的消息按 header.type 分发为应用级事件(uni.$emit),
// 各页面(聊天/通知/系统)只需监听对应事件即可,无需关心底层 WS;
// 4. 维护内存中的会话列表与消息列表,提供排序、未读、置顶、免打扰等会话管理;
// 5. 提供 sendChat() 便捷发送单聊/群聊消息。
//
// 注意:连接鉴权采用与后端约定一致的 Authorization 头(Bearer token),
// 由 wsManager.connect 透传到 uni.connectSocket 的 header 中。
import { wsManager } from './websocket.uts'
import {
IMessageBase,
IMessageHeader,
IMessagePayload,
IMessagePreview,
IMessageSession,
IMessageServiceCallbacks,
IChatMessage,
IConnectOptions
} from './socket-types.uts'
import { getUserToken } from '@/stores/user.uts'
import userState from '@/stores/user.uts'
class MessageService {
private initialized = false
private connected = false
private token = ''
// 会话管理状态
private callbacks : IMessageServiceCallbacks = {}
private sessions : Map<string, IMessageSession> = new Map()
private messages : Map<string, IMessageBase[]> = new Map() // session_id -> messages[]
private currentUserId = userState.uid
// 注册 WS 事件监听(幂等,只执行一次)
init() : void {
if (this.initialized == true) {
return
}
this.initialized = true
wsManager.on({
onOpen: (res : any) => {
this.connected = true
console.log('[MessageService] WS 已连接')
uni.$emit('onWsConnected', res)
},
onMessage: (msg : IMessageBase) => {
// 1) 维护会话与消息列表
this.handleWebSocketMessage(msg)
// 2) 应用级事件分发
this.dispatch(msg)
},
onClose: (res : any) => {
this.connected = false
console.log('[MessageService] WS 已关闭')
uni.$emit('onWsDisconnected', res)
},
onError: (err : any) => {
console.error('[MessageService] WS 错误', err)
uni.$emit('onWsError', err)
},
onReconnect: (res : any) => {
console.log('[MessageService] WS 重连中', res)
uni.$emit('onWsReconnect', res)
},
onReconnectFailed: (res : any) => {
console.error('[MessageService] WS 重连失败', res)
uni.$emit('onWsReconnectFailed', res)
}
})
}
/**
* 已登录时按需建立 WS 连接(幂等)。
* @param socketUrl 后端 appConf.socketUrl,为空则跳过
*/
connectIfNeeded(socketUrl : string | null) : void {
this.init()
if (socketUrl == null || socketUrl.length == 0) {
console.warn('[MessageService] 未配置 socketUrl,跳过 WS 连接')
return
}
const tokenResult = getUserToken()
if (tokenResult == null || tokenResult.accessToken == null || tokenResult.accessToken.length == 0) {
console.warn('[MessageService] 未登录或 token 为空,跳过 WS 连接')
return
}
if (wsManager.isConnected() == true) {
return
}
this.token = tokenResult.accessToken!
const options : IConnectOptions = {
reconnectInterval: 3000,
heartbeatInterval: 20000,
maxReconnectCount: 5,
header: {
'Authorization': `Bearer ${this.token}`
}
}
wsManager.connect(socketUrl, options)
}
// 主动断开
disconnect() : void {
wsManager.close(1000, 'user logout')
this.connected = false
}
isConnected() : boolean {
return this.connected
}
/**
* 发送聊天消息(单聊/群聊)。
* 真实送达以服务器回执为准,本方法仅保证消息已提交至 WS。
*/
sendChat(receiver : number, sessionId : string, contentType : string, content : any) : void {
const header : IMessageHeader = {
id: `c_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
type: 'chat',
target: 'single',
seqno: 1,
timestamp: Date.now(),
source: 'client',
version: '1.0.0',
token: this.token
}
const msg : IMessageBase = {
header: header,
payload: {
uid: userState.uid,
receiver: receiver,
session_id: sessionId,
type: contentType,
status: 'sending',
content: content
} as any
}
wsManager.send(msg)
}
// 按消息类型分发为应用级事件
private dispatch(msg : IMessageBase) : void {
const type = msg.header.type
switch (type) {
case 'chat':
uni.$emit('onChatMessage', msg)
break
case 'notify':
uni.$emit('onWsNotify', msg)
break
case 'status':
uni.$emit('onWsStatus', msg)
break
case 'system':
uni.$emit('onWsSystem', msg)
break
case 'pong':
// 心跳回包,wsManager 内部已处理,无需外发
break
default:
uni.$emit('onWsMessage', msg)
}
}
/**
* 处理 WebSocket 消息,维护会话与消息列表
*/
private handleWebSocketMessage(message : IMessageBase) : void {
if (message == null) {
return
}
const { header, payload } = message
const session_id = this.getSessionId(header.target, payload)
// 1. 更新会话信息
this.updateSession(session_id, message)
// 2. 添加到消息列表
this.addMessageToSession(session_id, message)
// 3. 触发 UI 更新
uni.$emit('session_updated', session_id)
}
/**
* 获取会话ID
*/
private getSessionId(target : string, payload : IMessagePayload) : string {
const sessionPayload = payload as IChatMessage
const sessionId = sessionPayload.session_id ?? ''
switch (target) {
case 'single':
return 'single' + sessionId
case 'group':
return 'group' + sessionId
case 'room':
return 'room' + sessionId
case 'system':
default:
return 'system' + sessionId
}
}
/**
* 获取会话名称
*/
private getSessionName(payload : IMessagePayload, type : string) : string {
const chatPayload = payload as IChatMessage
switch (type) {
case 'single':
return chatPayload.nickname ?? '未知用户'
case 'group':
return '群聊'
case 'room':
return chatPayload.nickname ?? '房间'
case 'system':
return '系统消息'
default:
return '会话'
}
}
/**
* 创建会话
*/
private createSession(session_id : string, message : IMessageBase) : IMessageSession {
const { header, payload } = message
const session_type = header.target
const chatPayload = payload as IChatMessage
return {
session_id,
session_type,
session_name: this.getSessionName(payload, session_type),
session_avatar: chatPayload.avatar ?? 'https://cdn.example.com/default_avatar.png',
last_message: this.createMessagePreview(message),
unread_count: 1,
is_pinned: false,
is_muted: false,
last_active_time: message.header.timestamp,
created_time: message.header.timestamp,
status: 'normal',
ext: {}
}
}
/**
* 创建消息预览
*/
private createMessagePreview(message : IMessageBase) : IMessagePreview {
const { header, payload } = message
const chatPayload = payload as IChatMessage
return {
id: header.id,
type: chatPayload.type ?? 'text',
content_preview: this.formatContentPreview(payload),
timestamp: header.timestamp,
msg_status: chatPayload.status ?? 'delivered',
is_mentioned: this.isMentioned(payload),
is_important: this.isImportant(payload),
ext: chatPayload.ext ?? {}
}
}
/**
* 判断是否@我
*/
private isMentioned(payload : IMessagePayload) : boolean {
const mentionPayload = payload as IChatMessage
if (mentionPayload.at_user == null)
return false
else if (mentionPayload.at_user == 'all')
return true
else if ((mentionPayload.at_user as number[]).includes(this.currentUserId))
return true
else
return false
}
/**
* 判断是否重要消息
*/
private isImportant(payload : IMessagePayload) : boolean {
const chatPayload = payload as IChatMessage
// 红包、转账、重要通知等
const importantTypes = ['red_packet', 'transfer', 'system_notify']
return importantTypes.includes(chatPayload.type ?? '')
}
/**
* 格式化消息预览内容
*/
private formatContentPreview(payload : IMessagePayload) : string {
const chatPayload = payload as IChatMessage
if (chatPayload.content == null) return '[消息]'
const type = chatPayload.type ?? 'text'
switch (type) {
case 'text':
return '[文本消息]'
case 'image':
return '[图片]'
case 'audio':
return '[语音]'
case 'video':
return '[视频]'
case 'file':
return '[文件]'
case 'location':
return '[位置]'
case 'emoji':
return '[表情]'
case 'sticker':
return '[贴纸]'
case 'red_packet':
return '[红包]'
case 'recall':
return '[消息已撤回]'
case 'system':
return '[系统消息]'
default:
return '[消息]'
}
}
/**
* 更新会话信息
*/
private updateSession(session_id : string, message : IMessageBase) : void {
const { header, payload } = message
const chatPayload = payload as IChatMessage
// 获取或创建会话
let session = this.sessions.get(session_id)
if (session == null) {
session = this.createSession(session_id, message)
}
// 更新最后消息
session.last_message = this.createMessagePreview(message)
session.last_active_time = header.timestamp
// 增加未读数(如果不是自己发送的消息)
if (chatPayload.uid != this.currentUserId) {
session.unread_count++
}
this.sessions.set(session_id, session)
}
/**
* 添加消息到会话
*/
private addMessageToSession(session_id : string, message : IMessageBase) : void {
if (!this.messages.has(session_id)) {
this.messages.set(session_id, [])
}
const sessionMessages = this.messages.get(session_id)!
sessionMessages.push(message)
// 保持消息按时间排序
sessionMessages.sort((a, b) => a.header.timestamp - b.header.timestamp)
// 限制每会话最多保存100条消息
if (sessionMessages.length > 100) {
sessionMessages.splice(0, sessionMessages.length - 100)
}
}
/**
* 获取排序后的会话列表
*/
getSortedSessions() : IMessageSession[] {
const sessions : IMessageSession[] = []
this.sessions.forEach((value, key) => {
sessions.push(value)
})
// 排序规则:
// 1. 置顶的在前
// 2. 未读消息数多的在前
// 3. 未读且被@的在前
// 4. 最后活跃时间新的在前
sessions.sort((a, b) => {
// 置顶排序
if (a.is_pinned != b.is_pinned) {
return a.is_pinned ? -1 : 1
}
// 未读数排序
if (a.unread_count != b.unread_count) {
return b.unread_count - a.unread_count
}
// @消息排序
const aHasMention = a.last_message?.is_mentioned ?? false
const bHasMention = b.last_message?.is_mentioned ?? false
if (aHasMention != bHasMention) {
return aHasMention ? -1 : 1
}
// 最后活跃时间排序
return b.last_active_time - a.last_active_time
})
return sessions
}
/**
* 获取会话消息列表
*/
getSessionMessages(session_id : string) : any[] {
return (this.messages.get(session_id) as any[] | null) ?? []
}
/**
* 清空会话未读数
*/
clearUnread(session_id : string) : void {
const session = this.sessions.get(session_id)
if (session != null) {
session.unread_count = 0
uni.$emit('session_updated', session_id)
}
}
/**
* 置顶/取消置顶
*/
togglePin(session_id : string, is_pinned : boolean = true) : void {
const session = this.sessions.get(session_id)
if (session != null) {
session.is_pinned = is_pinned
uni.$emit('session_updated', session_id)
}
}
/**
* 免打扰/取消免打扰
*/
toggleMute(session_id : string, is_muted : boolean = true) : void {
const session = this.sessions.get(session_id)
if (session != null) {
session.is_muted = is_muted
uni.$emit('session_updated', session_id)
}
}
/**
* 注册消息回调
*/
on(callbacks : IMessageServiceCallbacks) : void {
if (callbacks.onNewMessage != null) this.callbacks.onNewMessage = callbacks.onNewMessage
if (callbacks.onMessageStatusChange != null) this.callbacks.onMessageStatusChange = callbacks.onMessageStatusChange
}
}
export { IMessageSession }
export const messageService = new MessageService()
export const messageManager = messageService
export default messageService
@@ -0,0 +1,453 @@
export type ContentType =
| 'text' // 文本
| 'image' // 图片
| 'audio' // 音频
| 'video' // 视频
| 'file' // 文件
| 'location' // 位置
| 'emoji' // 表情
| 'sticker' // 贴纸
| 'card' // 卡片
| 'system' // 系统消息
| 'notify' // 通知消息
| 'status' // 状态消息
| 'game' // 游戏消息
| 'room' // 房间消息
| 'user' // 用户消息
| 'error' // 错误消息
| 'red_packet' // 红包
| 'transfer' // 转账
| 'recall' // 撤回
export type MessageStatus =
| 'sending' // 发送中
| 'sent' // 已发送
| 'delivereadBase' //已送达
| 'read' // 已读
| 'failed' // 失败
| 'recalled'; // 已撤回
// ==================== 1. 文本消息 ====================
export type IHeartbeatMessage = {
uid : number; // 用户ID
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
status : MessageStatus;
rtt ?: number; // 往返延迟(毫秒,响应时返回)
type ?: 'text';
content ?: string | IMessageContent
at_all ?: boolean;
at_user ?: null | 'all' | number[];
ext ?: any; // 扩展字段
}
// ==================== 2. 图片消息 ====================
type IImageField = {
// 图片消息特有字段
file_url : string;
file_name ?: string;
file_size ?: number;
file_mime ?: string;
file_width ?: number;
file_height ?: number;
thumbnail_url ?: string;
thumbnail_size ?: number;
at_user ?: number[];
}
// ==================== 3. 音频消息 ====================
type IVoiceField = {
// 音频消息特有字段
file_url : string;
file_name ?: string;
file_size ?: number;
file_mime ?: string;
file_duration ?: number;
thumbnail_url ?: string;
}
// ==================== 4. 视频消息 ====================
type IVideoField = {
// 音频消息特有字段
file_url : string;
file_name ?: string;
file_size ?: number;
file_mime ?: string;
file_duration ?: number;
thumbnail_url ?: string;
}
// ==================== 5. 文件消息 ====================
type IFileField = {
// 文件消息特有字段
file_url : string;
file_name : string;
file_size : number;
file_mime ?: string;
}
// ==================== 6. 位置消息 ====================
type ILocationField = {
// 位置消息特有字段
latitude : number;
longitude : number;
address ?: string;
}
// ==================== 7. 表情消息 ====================
type IEmojiField = {
// 表情消息特有字段
emoji_code : string;
}
// ==================== 8. 贴纸消息 ====================
type IStickerField = {
// 贴纸消息特有字段
sticker_id : string;
sticker_url : string;
}
// ==================== 9. 卡片消息 ====================
type ICardField = {
// 卡片消息特有字段
card_title : string;
card_desc ?: string;
card_image ?: string;
card_url ?: string;
card_data ?: any;
}
// ==================== 10. 系统消息 ====================
type ISystemField = {
// 系统消息特有字段
content : string;
sub_type ?: string;
error_code ?: number;
error_msg ?: string;
error_detail ?: string;
}
export type IMessageContent =
string
| IImageField
| IVoiceField
| IVideoField
| IFileField
| ILocationField
| IEmojiField
| IStickerField
| ICardField
| ISystemField
export interface IChatMessage {
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
receiver : number // 接收用户UID
type : ContentType;
status : MessageStatus;
msg_id ?: string;
ext ?: any;
// 文本消息特有字段
content : IMessageContent;
at_user ?: 'all' | number[];
}
// ==================== 11. 通知消息 ====================
export type NotifyType = 'system' | 'friend' | 'group' | 'game';
export type ActionType = 'accept' | 'reject' | 'ignore';
export interface INotifyMessage {
// 基础字段
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
type : 'notify';
status : MessageStatus;
msg_id ?: string;
receiver : number
at_user ?: number[];
session_id ?: string;
ext ?: any;
content ?: string | IMessageContent
// 通知消息特有字段
notify_type : NotifyType;
notify_title ?: string;
notify_content : string;
notify_icon ?: string;
need_confirm ?: boolean;
action ?: ActionType;
}
// ==================== 12. 状态消息 ====================
export interface IStatusMessage {
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
receiver : number // 接收用户UID
content ?: string | IMessageContent
type : 'status';
status : MessageStatus;
msg_id ?: string;
at_user ?: number[];
ext ?: any;
// 状态消息特有字段
status_type : 'online' | 'offline' | 'typing' | 'recording';
status_data ?: any;
}
// ==================== 13. 游戏消息 ====================
export type GameType = 'chess' | 'poker' | 'mahjong' | 'custom';
export type GameAction = 'start' | 'move' | 'end' | 'ready' | 'leave';
export type GameState = 'waiting' | 'playing' | 'finished';
export interface IGameMessage {
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
receiver : number // 接收用户UID
content ?: string | IMessageContent
type : 'game';
status : MessageStatus;
msg_id ?: string;
at_user ?: number[];
ext ?: any;
// 游戏消息特有字段
game_type : GameType;
game_action : GameAction;
game_data ?: any;
game_status ?: GameState;
current_player ?: string;
game_score ?: number;
}
// ==================== 14. 房间消息 ====================
export type RoomType = 'game' | 'chat';
export type RoomStatus = 'waiting' | 'playing' | 'finished' | 'closed';
export interface IRoomMember {
member_id : string;
nickname ?: string;
avatar ?: string;
role ?: 'owner' | 'admin' | 'member';
join_time ?: number;
online ?: boolean;
}
export interface IRoomMessage {
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
receiver : number // 接收用户UID
content ?: string | IMessageContent
type : 'room';
status : MessageStatus;
msg_id ?: string;
at_user ?: number[];
ext ?: any;
// 房间消息特有字段
room_name ?: string;
room_type : RoomType;
room_status ?: RoomStatus;
room_members ?: IRoomMember[];
room_max_members ?: number;
room_description ?: string;
room_created_time ?: number;
}
// ==================== 15. 用户消息 ====================
export type UserStatus = 'online' | 'offline' | 'away' | 'busy';
export interface IUserMessage {
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
receiver : number // 接收用户UID
content ?: string | IMessageContent
type : 'user';
status : MessageStatus;
msg_id ?: string;
at_user ?: number[];
ext ?: any;
user_status ?: UserStatus;
signature ?: string;
user_data ?: any;
}
// ==================== 16. 错误消息 ====================
export interface IErrorMessage {
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
receiver : number; // 接收用户UID
type? : 'error';
content ?: string | IMessageContent
status? : MessageStatus;
msg_id ?: string;
at_user ?: number[];
ext ?: any;
// 错误消息特有字段
error_code ?: number;
error_msg ?: string;
error_detail ?: string;
error_data ?: any;
}
// ==================== 17. 联合类型组织 ====================
// 聊天消息(用户之间的消息)
// 完整的消息载荷类型
export type IMessagePayload =
IHeartbeatMessage
| IChatMessage // 聊天消息
| INotifyMessage // 通知消息
| IStatusMessage // 状态消息
| IGameMessage // 游戏消息
| IRoomMessage // 房间消息
| IUserMessage // 用户消息
| IErrorMessage; // 错误消息
export type IMessageHeader = {
// 消息唯一ID(雪花算法生成,必填)
id : string;
// 消息类型(必填)
type : 'ping' | 'pong' | 'chat' | 'notify' | 'status' | 'game' | 'system';
// 目标会话类型(single/group/room/system,选填)
target : "single" | "group" | "room" | "system";
// 客户端序列号(用于去重和顺序保证,必填)
seqno : number;
// 时间戳(毫秒,必填)
timestamp : number;
// 消息来源(client/server,选填)
source ?: string;
version : "1.0.0" | string
token ?: string
platform ?: 'ios' | 'android' | 'web'; // 平台类型
device_id ?: string; // 设备ID
network_type ?: 'wifi' | '4g' | '5g' | 'unknown'; // 网络类型
}
export type IMessageBase = {
// 消息头
header : IMessageHeader;
// 消息体
payload : IMessagePayload;
}
export type IMessageSession = {
// 会话唯一标识(user_id/group_id/room_id/system
session_id : string;
// 会话类型(single/group/room/system
session_type : 'single' | 'group' | 'room' | 'system';
// 会话名称
session_name : string;
// 会话头像/封面
session_avatar : string;
// 最后一条消息
last_message : IMessagePreview | null;
// 未读消息数
unread_count : number;
// 是否置顶
is_pinned : boolean;
// 是否免打扰
is_muted : boolean;
// 最后活跃时间(毫秒时间戳)
last_active_time : number;
// 创建时间
created_time : number;
// 会话状态(normal/archived/deleted
status : 'normal' | 'archived' | 'deleted';
// 扩展字段
ext ?: any;
}
export type IMessagePreview = {
// 消息ID
id : string;
// 消息类型
type : string;
// 消息内容预览
content_preview : string;
// 发送者(单聊时显示对方昵称)
sender_name ?: string;
// 发送者头像
sender_avatar ?: string;
// 消息时间
timestamp : number;
// 消息状态
msg_status : string;
// 是否@我
is_mentioned : boolean;
// 是否为重要消息
is_important : boolean;
// 消息扩展信息
ext ?: any;
}
export type IWebSocketPayload = string | UTSJSONObject | ArrayBuffer | IMessageBase
export type IConnectOptions = {
reconnectInterval ?: number
heartbeatInterval ?: number
maxReconnectCount ?: number
protocols ?: Array<string> | null
header ?: UTSJSONObject | null
heartbeatTimeout ?: number
connectTimeout ?: number
debug ?: boolean
}
export type IWebSocketCallbacks = {
onOpen ?: ((res : any) => void) | null
onMessage ?: ((res : IMessageBase) => void) | null
onError ?: ((res : any) => void) | null
onClose ?: ((res : any) => void) | null
onReconnect ?: ((res : any) => void) | null
onReconnectFailed ?: ((res : any) => void) | null
onHeartbeatTimeout ?: ((res : any) => void) | null
}
export type IMessageQueueItem = {
data : IMessageBase | IWebSocketPayload
success : (() => void) | null
fail : ((err : any) => void) | null
}
export type IMessageServiceCallbacks = {
onNewMessage ?: ((msg : IMessageBase) => void) | null
//onConversationUpdate ?: ((conv : IConversation) => void) | null
onMessageStatusChange ?: ((msgId : string, status : string) => void) | null
}
@@ -0,0 +1,244 @@
// 消息类型 文本 图片 音频 视频 文件 // 位置 表情 // 贴纸// 卡片 系统消息
export type ContentType = 'text' | 'image' | 'audio' | 'video' | 'file' | 'location' | 'emoji' | 'sticker' | 'card' | 'system'
// 消息状态 发送中 已发送 已送达 已读 失败 已撤回
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'read' | 'failed' | 'recalled'
export interface IPayloadBase {
msg_id : string; // 消息ID
seq_no : number; // 序列号
timestamp : number; // 时间戳
uid : number; // 发送者ID
msg_status : MessageStatus; // 消息状态
target_msg_id ?: string; // 目标消息ID
to_user_id ?: string; // 会话路由
group_id ?: string;
room_id ?: string;
session_id ?: string;
ext ?: any; // 扩展字段
}
export interface IHeartbeatMessage extends IPayloadBase {
msg_id : string; // 消息ID
seq_no : number; // 序列号
timestamp : number; // 时间戳
uid : number; // 发送者ID
}
// ==================== 3. 聊天消息类型 ====================
// 文本消息
export interface ITextMessage extends IPayloadBase {
content_type : ContentType
content : string;
at_all ?: boolean;
at_users ?: number[];
sub_type ?: string;
}
// 图片消息
export interface IImageMessage extends IPayloadBase {
content_type : ContentType
file_url : string;
file_name ?: string;
file_size ?: number;
file_mime ?: string;
file_width ?: number;
file_height ?: number;
thumbnail_url ?: string;
thumbnail_size ?: number;
at_all ?: boolean;
at_users ?: number[];
}
// 音频消息
export interface IAudioMessage extends IPayloadBase {
content_type : ContentType
file_url : string;
file_name ?: string;
file_size ?: number;
file_mime ?: string;
file_duration ?: number;
thumbnail_url ?: string;
}
// 视频消息
export interface IVideoMessage extends IPayloadBase {
content_type : ContentType
file_url : string;
file_name ?: string;
file_size ?: number;
file_mime ?: string;
file_duration ?: number;
file_width ?: number;
file_height ?: number;
thumbnail_url ?: string;
thumbnail_size ?: number;
}
// 文件消息
export interface IFileMessage extends IPayloadBase {
content_type : ContentType
file_url : string;
file_name : string;
file_size : number;
file_mime ?: string;
}
// 位置消息
export interface ILocationMessage extends IPayloadBase {
content_type : ContentType
latitude : number;
longitude : number;
address ?: string;
}
// 表情消息
export interface IEmojiMessage extends IPayloadBase {
content_type : ContentType
emoji_code : string;
}
// 贴纸消息
export interface IStickerMessage extends IPayloadBase {
content_type : ContentType
sticker_id : string;
sticker_url : string;
}
// 卡片消息
export interface ICardMessage extends IPayloadBase {
content_type : ContentType
card_title : string;
card_desc ?: string;
card_image ?: string;
card_url ?: string;
card_data ?: any;
}
// 系统消息
export interface ISystemMessage extends IPayloadBase {
content_type : ContentType
content : string;
sub_type ?: string;
error_code ?: number;
error_msg ?: string;
error_detail ?: string;
}
// ==================== 4. 通知消息类型 ====================
export type NotifyType = 'system' | 'friend' | 'group' | 'game';
export type ActionType = 'accept' | 'reject' | 'ignore';
export interface INotifyMessage extends IPayloadBase {
notify_type : NotifyType;
notify_title ?: string;
notify_content : string;
notify_icon ?: string;
need_confirm ?: boolean;
action ?: ActionType;
}
// ==================== 5. 状态消息类型 ====================
export interface IStatusMessage extends IPayloadBase {
status_type : 'online' | 'offline' | 'typing' | 'recording';
status_data ?: any;
}
// ==================== 6. 游戏消息类型 ====================
export type GameType = 'chess' | 'poker' | 'mahjong' | 'custom';
export type GameAction = 'start' | 'move' | 'end' | 'ready' | 'leave';
export type GameState = 'waiting' | 'playing' | 'finished';
export interface IGameMessage extends IPayloadBase {
game_type : GameType;
game_action : GameAction;
game_data ?: any;
game_status ?: GameState;
current_player ?: string;
game_score ?: number;
}
// ==================== 7. 房间消息类型 ====================
export type RoomType = 'game' | 'chat';
export type RoomStatus = 'waiting' | 'playing' | 'finished' | 'closed';
export interface IRoomMember {
member_id : string;
nickname ?: string;
avatar ?: string;
role ?: 'owner' | 'admin' | 'member';
join_time ?: number;
online ?: boolean;
}
export interface IRoomMessage extends IPayloadBase {
room_name ?: string;
room_type : RoomType;
room_status ?: RoomStatus;
room_members ?: IRoomMember[];
room_max_members ?: number;
room_description ?: string;
room_created_time ?: number;
}
// ==================== 8. 用户消息类型 ====================
export type UserStatus = 'online' | 'offline' | 'away' | 'busy';
export interface IUserMessage extends IPayloadBase {
nickname ?: string;
avatar ?: string;
user_status ?: UserStatus;
signature ?: string;
user_data ?: any;
}
// ==================== 9. 错误消息类型 ====================
export interface IErrorMessage extends IPayloadBase {
error_code : number;
error_msg : string;
error_detail ?: string;
error_data ?: any;
}
// ==================== 10. 联合类型组织 ====================
// 聊天消息(用户之间的消息)
export type IChatMessage = ITextMessage | IImageMessage | IAudioMessage | IVideoMessage | IFileMessage | ILocationMessage | IEmojiMessage | IStickerMessage | ICardMessage | ISystemMessage;
// 通知消息
export type INotifyMessageUnion = INotifyMessage;
// 系统状态消息
export type IStatusMessageUnion = IStatusMessage;
// 游戏消息
export type IGameMessageUnion = IGameMessage;
// 房间消息
export type IRoomMessageUnion = IRoomMessage;
// 用户消息
export type IUserMessageUnion = IUserMessage;
// 错误消息
export type IErrorMessageUnion = IErrorMessage;
// 完整的消息载荷类型 // 聊天消息 通知消息 状态消息 游戏消息 房间消息 用户消息 错误消息
type IMessagePayload = IHeartbeatMessage | IChatMessage | INotifyMessage | IStatusMessage | IGameMessage | IRoomMessage | IUserMessage | IErrorMessage;
export default IMessagePayload;
@@ -0,0 +1,424 @@
import {
IMessageBase,
IConnectOptions,
IWebSocketCallbacks,
IMessageQueueItem,
IChatMessage,
IHeartbeatMessage,
IWebSocketPayload
} from './socket-types.uts'
import userState from '@/stores/user.uts'
class WebSocketManager {
private static instance : WebSocketManager | null = null
private socketTask : SocketTask | null = null
private url = ''
private protocols : Array<string> = []
private header : UTSJSONObject | null = null
private connected = false
private connecting = false
private manualClose = false
private reconnectTimer : number | null = null
private heartbeatTimer : number | null = null
private lastHeartbeatAt = 0
private waitingHeartbeat = false
private reconnectCount = 0
private reconnectInterval = 3000
private heartbeatInterval = 20000
private heartbeatTimeout = 10000
private connectTimeout = 10000
private maxReconnectCount = 5
private debug = false
private callbacks : IWebSocketCallbacks = {}
private messageQueue : Array<IMessageQueueItem> = []
static getInstance() : WebSocketManager {
if (WebSocketManager.instance == null) {
WebSocketManager.instance = new WebSocketManager()
}
return WebSocketManager.instance
}
connect(url : string, options : IConnectOptions | null = null) : void {
this.log('WebSocket connect')
if (url.length == 0) {
this.log('WebSocket url 不能为空')
return
}
const urlChanged = this.url.length > 0 && this.url != url
this.url = url
this.applyOptions(options)
if (urlChanged == true) {
this.releaseSocket()
}
if (this.connected == true || this.connecting == true) {
return
}
this.manualClose = false
this.connecting = true
this.clearReconnectTimer()
this.createSocketTask()
}
/**
* 应用配置
*/
private applyOptions(options : IConnectOptions | null) : void {
if (options == null) {
return
}
if (options.reconnectInterval != null) this.reconnectInterval = options.reconnectInterval
if (options.heartbeatInterval != null) this.heartbeatInterval = options.heartbeatInterval
if (options.maxReconnectCount != null) this.maxReconnectCount = options.maxReconnectCount
if (options.protocols != null) this.protocols = options.protocols
if (options.header != null) this.header = options.header
if (options.heartbeatTimeout != null) this.heartbeatTimeout = options.heartbeatTimeout
if (options.connectTimeout != null) this.connectTimeout = options.connectTimeout
if (options.debug != null) this.debug = options.debug
}
private createSocketTask() : void {
this.releaseSocket()
let finished = false
const finishConnect = () : void => {
if (finished == true) {
return
}
finished = true
this.connecting = false
}
const connectTimeoutTimer = setTimeout(() => {
finishConnect()
this.handleError({ errMsg: 'WebSocket 连接超时' })
this.scheduleReconnect('connect timeout')
}, this.connectTimeout) as number
try {
this.socketTask = uni.connectSocket({
url: this.url,
header: this.header,
protocols: this.protocols
})
const currentTask = this.socketTask
if (currentTask == null) {
clearTimeout(connectTimeoutTimer)
finishConnect()
return
}
currentTask.onOpen((res : any) => {
clearTimeout(connectTimeoutTimer)
finishConnect()
this.connected = true
this.reconnectCount = 0
this.waitingHeartbeat = false
this.startHeartbeat()
this.flushMessageQueue()
if (this.callbacks.onOpen != null) {
this.callbacks.onOpen(res)
}
uni.$emit('onWsOpen', res)
})
currentTask.onMessage((res : OnSocketMessageCallbackResult) => {
console.log(res)
this.handleMessage(res)
})
currentTask.onError((err : any) => {
clearTimeout(connectTimeoutTimer)
finishConnect()
uni.$emit('onWsError', err)
this.handleError(err)
})
currentTask.onClose((res : any) => {
clearTimeout(connectTimeoutTimer)
finishConnect()
uni.$emit('onWsClose', res)
this.handleClose(res)
})
} catch (error) {
clearTimeout(connectTimeoutTimer)
finishConnect()
this.handleError(error)
}
}
private handleMessage(res : OnSocketMessageCallbackResult) : void {
const dataStr = res.data as string
// #ifndef APP
const rawData = JSON.parse(dataStr) as IMessageBase
// #endif
// #ifdef APP
const rawJson = JSON.parseObject(dataStr)
console.log("OnMessage:", rawJson)
const rawData = rawJson?.parse<IMessageBase>()
// #endif
//if (rawData.type == "ping")
console.log("OnMessage:", rawData)
if (rawData != null && rawData.header.type == "pong") {
this.waitingHeartbeat = false
this.lastHeartbeatAt = Date.now()
return
}
if (rawData != null && this.callbacks.onMessage != null) {
uni.$emit('onWsMessage', rawData)
this.callbacks.onMessage(rawData)
}
}
send(data : IWebSocketPayload, success : (() => void) | null = null, fail : ((err : any) => void) | null = null) : void {
let payload : string | IMessageBase | UTSJSONObject | null = null
try {
payload = JSON.stringify(data)
} catch (error) {
payload = null
}
if (payload == null) {
if (fail != null) {
fail({ errMsg: 'WebSocket 消息序列化失败' })
}
return
}
if (this.socketTask == null || this.connected == false) {
this.enqueueMessage(data, success, fail)
return
}
this.socketTask.send({
data: payload,
success: () => {
if (success != null) {
success()
}
},
fail: (err : any) => {
this.enqueueMessage(data, success, fail)
if (fail != null) {
fail(err)
}
}
})
}
close(code : number = 1000, reason : string = 'manual close') : void {
this.manualClose = true
this.stopHeartbeat()
this.clearReconnectTimer()
this.connected = false
this.connecting = false
if (this.socketTask != null) {
try {
this.socketTask.close({ code, reason })
} catch (error) {
this.log('关闭 WebSocket 失败', error)
}
}
this.releaseSocket()
}
on(callbacks : IWebSocketCallbacks) : void {
if (callbacks.onOpen != null) this.callbacks.onOpen = callbacks.onOpen
if (callbacks.onMessage != null) this.callbacks.onMessage = callbacks.onMessage
if (callbacks.onError != null) this.callbacks.onError = callbacks.onError
if (callbacks.onClose != null) this.callbacks.onClose = callbacks.onClose
if (callbacks.onReconnect != null) this.callbacks.onReconnect = callbacks.onReconnect
if (callbacks.onReconnectFailed != null) this.callbacks.onReconnectFailed = callbacks.onReconnectFailed
if (callbacks.onHeartbeatTimeout != null) this.callbacks.onHeartbeatTimeout = callbacks.onHeartbeatTimeout
}
isConnected() : boolean {
return this.connected
}
getReconnectCount() : number {
return this.reconnectCount
}
getStats() : UTSJSONObject {
return {
url: this.url,
connected: this.connected,
connecting: this.connecting,
reconnectCount: this.reconnectCount,
queueSize: this.messageQueue.length,
lastHeartbeatAt: this.lastHeartbeatAt
} as UTSJSONObject
}
removeAllListeners() : void {
this.callbacks = {}
}
/**
* 开始心跳
*/
private startHeartbeat() : void {
this.stopHeartbeat()
if (this.heartbeatInterval <= 0) return
this.heartbeatTimer = setInterval(() => {
if (this.connected == false) {
this.stopHeartbeat()
return
}
if (this.waitingHeartbeat == true) {
const expired = Date.now() - this.lastHeartbeatAt > this.heartbeatTimeout
if (expired == true) {
if (this.callbacks.onHeartbeatTimeout != null) {
this.callbacks.onHeartbeatTimeout({ errMsg: 'WebSocket 心跳超时' })
}
this.scheduleReconnect('heartbeat timeout')
return
}
}
this.waitingHeartbeat = true
this.lastHeartbeatAt = Date.now()
const heartbeatUserId = userState.uid
const heartData : IMessageBase = {
header: {
id: "heartbeat_1700000000000",
type: "ping",
seqno: 1,
timestamp: Date.now(),
source: "client",
target: "system",
version: "1.0.0",
platform: 'android',
device_id: "device_id",
network_type: 'wifi',
},
payload: {
uid: heartbeatUserId,
status: 'sent',
type: 'text',
content: 'ping'
}
}
this.send(heartData)
}, this.heartbeatInterval) as number
}
/**
* 停止心跳
*/
private stopHeartbeat() : void {
const timer = this.heartbeatTimer
if (timer != null) {
clearInterval(timer)
this.heartbeatTimer = null
}
this.waitingHeartbeat = false
}
/**
* 将消息入队
* @param data IWebSocketPayload
* @param success (() => void) | null
* @param fail fail : ((err : any) => void) | null
* @return { void }
*/
private enqueueMessage(data : IWebSocketPayload, success : (() => void) | null, fail : ((err : any) => void) | null) : void {
this.messageQueue.push({ data, success, fail })
if (this.messageQueue.length > 100) {
this.messageQueue.splice(0, this.messageQueue.length - 100)
}
}
/**
* 刷新消息队列
* @return {void}
*/
private flushMessageQueue() : void {
if (this.messageQueue.length == 0) {
return
}
const queue = this.messageQueue.slice()
this.messageQueue = []
for (let i = 0; i < queue.length; i++) {
const item = queue[i]
this.send(item.data, item.success, item.fail)
}
}
private handleError(err : any) : void {
if (this.callbacks.onError != null) {
this.callbacks.onError(err)
}
this.scheduleReconnect('socket error')
}
private handleClose(res : any) : void {
this.connected = false
this.connecting = false
this.stopHeartbeat()
this.releaseSocket()
if (this.callbacks.onClose != null) {
this.callbacks.onClose(res)
}
if (this.manualClose == false) {
this.scheduleReconnect('socket close')
}
}
private scheduleReconnect(reason : string) : void {
if (this.manualClose == true || this.url.length == 0) {
return
}
if (this.reconnectTimer != null) {
return
}
if (this.reconnectCount >= this.maxReconnectCount) {
if (this.callbacks.onReconnectFailed != null) {
this.callbacks.onReconnectFailed({ errMsg: reason, reconnectCount: this.reconnectCount })
}
return
}
this.connected = false
this.connecting = false
this.stopHeartbeat()
this.releaseSocket()
this.reconnectCount = this.reconnectCount + 1
if (this.callbacks.onReconnect != null) {
this.callbacks.onReconnect({ reason, reconnectCount: this.reconnectCount })
}
const delay = this.reconnectInterval * this.reconnectCount
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect(this.url)
}, delay) as number
}
private clearReconnectTimer() : void {
const timer = this.reconnectTimer
if (timer != null) {
clearTimeout(timer)
this.reconnectTimer = null
}
}
private releaseSocket() : void {
this.socketTask = null
}
private log(message : string, detail : any | null = null) : void {
if (this.debug == false) {
return
}
if (detail != null) {
console.log('[WebSocketManager]', message, detail)
return
}
console.log('[WebSocketManager]', message)
}
}
export type { IConnectOptions, IWebSocketCallbacks }
export const wsManager = WebSocketManager.getInstance()
export default WebSocketManager
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,246 @@
.page {
flex: 1;
/* #ifdef WEB */
min-height: 100vh;
padding-bottom: 2rpx;
/* #endif */
display: flex;
flex-flow: column nowrap;
width: 100%;
height: 100%;
overflow: hidden;
background: linear-gradient(to top right, #ffcbef 0%, #dd98ff 66%);
}
.-status-bar{
top: var(--uni-safe-area-inset-top);
height: var(--status-bar-height);
background-color: #f0f0f0;
}
.page-nav{
height: 96rpx;
position: fixed;
/* #ifdef APP */
top: var(--status-bar-height);
/* #endif */
/* #ifndef APP */
top: 0;
/* #endif */
width: 100%; /* 确保宽度占满整个视口 */
z-index: 100; /* 确保它在其他内容之上 */
border-radius: 10rpx;
background-color: #fff;
box-shadow: 0 5rpx 20rpx rgba(0, 0, 0, 0.1);
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
padding: 5rpx;
}
.page-nav-left{
width: auto;
display: flex;
flex-flow: row nowrap;
justify-content: flex-start;
}
.page-nav-center{
width: auto;
flex:1;
display: flex;
flex-flow: row nowrap;
justify-content: center;
}
.page-nav-right{
width: auto;
padding: 3rpx 6rpx;
display: flex;
flex-flow: row nowrap;
justify-content: flex-end;
}
.page-body{
flex: 1;
top:96rpx;
display: flex;
flex-flow: column nowrap;
}
.avatar-square {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
margin-right: 20rpx;
background-color: #f0f0f0;
}
.avatar-circle {
width: 128rpx;
height: 128rpx;
border-radius: 64rpx;
margin-right: 30rpx;
background-color: #f0f0f0;
}
/* ------------------ 聊天列表样式 ------------------ */
.chat-item{
display: flex;
flex-flow: row nowrap;
margin: 5rpx;
padding: 5rpx 5rpx;
border-radius: 15rpx;
margin-bottom: 20rpx;
background: rgba(255, 255, 255, 0.3);
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
}
.chat-item-left{
display: flex;
padding: 5rpx;
flex-flow: row nowrap;
align-items: center;
}
.chat-item-center{
display: flex;
flex-flow: row nowrap;
flex-grow: 1;
padding: 3rpx;
padding: 5rpx;
align-items: center;
}
.chat-item-right{
display: flex;
flex-flow: row nowrap;
align-items: center;
padding: 5rpx;
width: 96rpx;
}
/* ------------------ 卡片面板样式 ------------------ */
.card-panel{
display: flex;
flex-flow: column nowrap;
margin: 3rpx;
background-color: #fff;
border-radius: 20rpx;
padding: 5rpx;
margin-bottom: 20rpx;
box-shadow: 0 5rpx 20rpx rgba(0, 0, 0, 0.05);
}
.card-panel-header{
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
padding: 10rpx;
border-bottom: 1rpx solid #d4d4d45c;
}
.card-panel-body{
flex:1;
display: flex;
flex-flow: column nowrap;
padding: 10rpx;
}
.card-panel-footer{
border-top: 1rpx solid #d4d4d45c;
display: flex;
flex-flow: row nowrap;
padding: 10rpx;
}
.grid-container{
display: flex;
flex-flow: row wrap;
padding: 5rpx;
}
.grid-container-item{
flex: 1 1 auto;
}
.tabsbox {
display: flex;
flex-flow: row nowrap;
justify-content: center;
align-items: center;
margin: 0 10rpx;
margin-bottom: 10rpx;
border-radius: 50rpx;
padding: 10rpx;
}
.tabsbox-item{
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
margin: 0 3rpx;
padding:5rpx 3rpx;
border-radius: 10rpx;
background-color: rgba(255, 255, 255, 0.5);
}
.tabsbox-txt{
text-align: center;
font-size: 26rpx;
color: #464646;
margin: 0 3rpx;
padding: 3rpx;
border-radius: 10rpx;
background-color: rgba(255, 255, 255, 0.005);
}
.tabsbox-badge{
position: absolute;
top: 0;
right: 0;
min-width: 32rpx;
height: 32rpx;
line-height: 32rpx;
text-align: center;
background-color: #FF6B6B;
color: #fff;
font-size: 20rpx;
border-radius: 16rpx;
padding: 0 3rpx;
transform: translate(30%, -30%);
}
.tabsbox-active{
/*background-color: rgba(255, 107, 107, 0.8);*/
border-radius: 5rpx;
border-bottom: 5rpx solid rgba(255, 107, 107, 0.8);
}
.tabsbox-active-txt{
color: #FF6B6B;
font-weight: bold;
font-size: 32rpx;
}
.icon-btn{
position: relative;
width: 60rpx;
height: 60rpx;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
background-color: #f8f8f8;
}
.icon-badge{
position: absolute;
top: -5rpx;
right: -5rpx;
min-width: 32rpx;
height: 32rpx;
line-height: 32rpx;
text-align: center;
background-color: #FF3B30;
color: #fff;
font-size: 20rpx;
border-radius: 16rpx;
padding: 0 8rpx;
}
/********************** 首页样式**********************/
Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.
+460
View File
@@ -0,0 +1,460 @@
import {
IAppState, type ISystemConfig, type ILaunchData,
type IBaseData, type IRecommendData,
IUnread
} from '@/types/app.uts'
import userState, { isLoggedIn } from './user.uts'
import Api from '@/common/api-service.uts'
import messageService from '@/services/websocket/message-service.uts'
import httpApi from '@/utlis/http-api.uts'
const initialAppState : IAppState = {
baseURL: '',
timeout: 15000,
enableCache: true,
cacheExpire: 5 * 60 * 1000,
enableLog: true,
systemConf: null,
launchData: null,
baseData: null,
recommendData: null,
unread: null,
initialized: false,
// 时间信息
startTime: 0,
endTime: 0,
lastInitTime: 0,
// 网络状态
networkType: 'none',
isConnected: false
}
const appState = reactive<IAppState>(initialAppState)
/**
* 初始化应用
* 注意:系统配置/启动数据/基础数据每次启动都需加载(提供 socketUrl、功能开关、cdn 等),
* 不再依赖 isInitialUse 闸门,避免配置永远不加载。
*/
export const initApp = async function () : Promise<boolean> {
try {
appState.startTime = Date.now()
startNetworkListen()
// 1. 系统配置(socketUrl / 功能开关 / cdn 等)
await loadSystemConfig()
// 2. 启动数据(广告/轮播/快捷入口/新手引导)
await loadLaunchData()
// 3. 基础数据(城市/标签/礼物/等级等)
await loadBaseData()
// 已登录:连接实时消息通道并加载用户态数据
if (isLoggedIn.value) {
connectWebSocket()
await loadRecommendData()
await syncUnreadCounts()
}
appState.initialized = true
appState.endTime = Date.now()
appState.lastInitTime = Date.now()
return true
} catch (error : any) {
console.error('应用初始化失败:', error)
return false
}
}
/**
* 已登录时按需连接 WebSocket 实时消息通道
*/
export const connectWebSocket = function () : void {
messageService.connectIfNeeded(appState.systemConf?.socketUrl ?? null)
}
export const startNetworkListen = async function () {
uni.onNetworkStatusChange((result) => {
console.log('网络状态变化:', result)
appState.networkType = result.networkType //网络类型
appState.isConnected = result.isConnected //是否已连接
})
}
/**
* 加载系统配置
*/
export const loadSystemConfig = async function () : Promise<void> {
try {
const data = await Api.app.appConf()
const result = data.parse<ISystemConfig>()
if (result != null) {
appState.systemConf = result
setCacheData('system_config', data)
} else {
appState.systemConf = getAppDefaultConf()
}
} catch (error) {
console.warn('加载系统配置失败,使用缓存或默认配置', error)
const cachedConfig = getCachedSystemConfig('system_config')
appState.systemConf = cachedConfig != null ? cachedConfig : getAppDefaultConf()
}
// 将后端下发的 apiUrl 同步为 HTTP 客户端基础域名(接口 URL 已含 /wxchat/api 前缀)
applyApiBaseURL(appState.systemConf?.apiUrl)
}
/**
* 设置 HTTP 客户端基础域名(去除末尾斜杠,避免拼接出 //wxchat)
*/
export const applyApiBaseURL = function (apiUrl : string | null | undefined) : void {
if (apiUrl == null || apiUrl.length == 0) {
return
}
const base = apiUrl.replace(/\/+$/, '')
if (base.length > 0) {
httpApi.setBaseURL(base)
console.log('[app] HTTP baseURL 已设置为:', base)
}
}
/**
* 加载启动数据
*/
const loadLaunchData = async function () : Promise<void> {
try {
const data = await Api.app.launchData()
const result = data.parse<ILaunchData>()
if (result != null) {
appState.launchData = result
setCacheData('launch_data', data)
} else {
appState.launchData = getDefaultLaunchData()
}
} catch (error) {
console.warn('加载启动数据失败,使用默认启动数据 :', error)
const cachedData = getCachedLaunchData('launch_data')
appState.launchData = cachedData != null ? cachedData : getDefaultLaunchData()
}
}
/**
* 加载基础数据
*/
const loadBaseData = async function () : Promise<void> {
try {
const data = await Api.app.baseData()
const result = data.parse<IBaseData>()
if (result != null) {
appState.baseData = result
setCacheData('base_data', data)
console.log('基础数据加载成功')
} else {
appState.baseData = getDefaultBaseData()
console.log('基础数据为空,使用默认基础数据')
}
} catch (error) {
console.warn('加载基础数据失败,使用缓存或默认基础数据', error)
const cachedData = getCachedBaseData('base_data')
appState.baseData = cachedData != null ? cachedData : getDefaultBaseData()
}
}
/**
* 加载推荐数据
*/
const loadRecommendData = async function () : Promise<void> {
if (isLoggedIn.value == false) {
appState.recommendData = null
console.log('当前未登录,跳过推荐数据加载')
return
}
try {
const data = await Api.recommend.index()
const result = data.parse<IRecommendData>()
if (result != null) {
appState.recommendData = result
setCacheData('recommend_data', data)
console.log('推荐数据加载成功')
}
} catch (error) {
console.warn('加载推荐数据失败,尝试使用缓存数据', error)
const cachedData = getCachedRecommendData('recommend_data')
if (cachedData != null) {
appState.recommendData = cachedData
console.log('使用缓存的推荐数据')
}
}
}
/**
* 同步未读消息
*/
const syncUnreadCounts = async function () : Promise<void> {
try {
const data = await Api.message.unread()
const result = data.parse<IUnread>()
if (result != null) {
appState.unread = result
console.log('未读消息同步成功')
}
} catch (error) {
console.warn('同步未读消息失败:', error)
}
}
/**
* 获取缓存数据
*/
export const getCachedData = function (key : string) : UTSJSONObject | null {
try {
const cached = uni.getStorageSync(key) as string | null
const cachedTime = uni.getStorageSync(`${key}_time`) as string | null
if (cached == null || cachedTime == null) {
return null
}
const cacheAge = Date.now() - parseInt(cachedTime)
if (cacheAge < appState.cacheExpire) {
const parsed = JSON.parse(cached) as UTSJSONObject
return parsed
}
return null
} catch (error) {
return null
}
}
export const getCachedSystemConfig = function (key : string) : ISystemConfig | null {
const cached = getCachedData(key)
if (cached == null) {
return null
}
return cached.parse<ISystemConfig>()
}
export const getCachedLaunchData = function (key : string) : ILaunchData | null {
const cached = getCachedData(key)
if (cached == null) {
return null
}
return cached.parse<ILaunchData>()
}
export const getCachedBaseData = function (key : string) : IBaseData | null {
const cached = getCachedData(key)
if (cached == null) {
return null
}
return cached.parse<IBaseData>()
}
export const getCachedRecommendData = function (key : string) : IRecommendData | null {
const cached = getCachedData(key)
if (cached == null) {
return null
}
return cached.parse<IRecommendData>()
}
/**
* 缓存数据
*/
export const setCacheData = function (key : string, data : any) : void {
try {
uni.setStorageSync(key, JSON.stringify(data))
uni.setStorageSync(`${key}_time`, Date.now().toString())
} catch (error) {
console.error('缓存数据失败:', error)
}
}
/**
* 获取默认加载数据
*/
export const getDefaultLaunchData = () : ILaunchData{
const launchData : ILaunchData = {
splashAds: [],
banners: [
{
id: 1,
image: 'https://example.com/banner1.jpg',
title: '欢迎使用',
link: '',
type: 'page',
order: 1
}
],
// announcements: [],
activities: [],
quickActions: [
{
id: 1,
name: '匹配',
icon: 'heart',
color: '#FF6B6B',
route: '/pages/match/match',
visible: true
},
{
id: 2,
name: '聊天',
icon: 'message',
color: '#007AFF',
route: '/pages/chat/chat',
visible: true
},
{
id: 3,
name: '动态',
icon: 'image',
color: '#34C759',
route: '/pages/moment/moment',
visible: true
},
{
id: 4,
name: '房间',
icon: 'mic',
color: '#5856D6',
route: '/pages/room/room',
visible: true
}
],
newbieGuide: {
enabled: true,
steps: [
{
id: 1,
title: '欢迎来到心遇',
content: '遇见美好,遇见你',
image: 'https://example.com/guide1.png'
},
{
id: 2,
title: '智能匹配',
content: '根据你的兴趣和喜好,智能推荐合适的伙伴',
image: 'https://example.com/guide2.png'
},
{
id: 3,
title: '实时聊天',
content: '文字、语音、视频,多种方式畅聊无阻',
image: 'https://example.com/guide3.png'
}
]
}
}
return launchData
}
/**
* 获取默认系统配置
*/
export const getAppDefaultConf = () : ISystemConfig {
const defaultConf : ISystemConfig = {
dailyMatchLimit: 50,
dailyMessageLimit: 100,
maxFriends: 500,
maxMomentImages: 9,
maxChatImageSize: 10 * 1024 * 1024, // 10MB
maxVideoDuration: 300, // 5分钟
maxVoiceDuration: 60 // 1分钟
enableAd: false,
splashAdId: '',
bannerAdId: '',
interstitialAdId: '',
rewardAdId: '',
adFrequency: 3
uploadUrl: 'https://upload.example.com',
cdnUrl: 'https://cdn.example.com',
socketUrl: 'wss://socket.example.com',
apiVersion: 'v1'
wechatAppId: '',
qqAppId: '',
appleServiceId: '',
pushService: '',
mapService: 'amap'
minIosVersion: '11.0',
minAndroidVersion: '5.0',
supportedPlatforms: ['ios', 'android', 'web']
}
return defaultConf
}
/**
* 获取默认基础数据
*/
export const getDefaultBaseData = () : IBaseData {
const baseData : IBaseData = {
cities: [
{ id: 1, name: '北京', code: '110000', parentId: 0, level: 1, pinyin: 'beijing', hot: true },
{ id: 2, name: '上海', code: '310000', parentId: 0, level: 1, pinyin: 'shanghai', hot: true },
{ id: 3, name: '广州', code: '440100', parentId: 0, level: 1, pinyin: 'guangzhou', hot: true },
{ id: 4, name: '深圳', code: '440300', parentId: 0, level: 1, pinyin: 'shenzhen', hot: true },
{ id: 5, name: '成都', code: '510100', parentId: 0, level: 1, pinyin: 'chengdu', hot: true }
],
tags: [
{ id: 1, name: '旅行', type: 'hobby', hot: true, count: 1000 },
{ id: 2, name: '美食', type: 'hobby', hot: true, count: 1500 },
{ id: 3, name: '音乐', type: 'hobby', hot: false, count: 800 },
{ id: 4, name: '运动', type: 'hobby', hot: true, count: 1200 },
{ id: 5, name: '游戏', type: 'hobby', hot: true, count: 2000 }
],
gifts: [
{ id: 1, name: '玫瑰花', description: '代表爱意', image: '🌹', price: 10, coinType: 'coin', category: 'love', isHot: true, isNew: false, isVipOnly: false, isLimited: false, sortOrder: 1 },
{ id: 2, name: '棒棒糖', description: '甜甜蜜蜜', image: '🍭', price: 20, coinType: 'coin', category: 'sweet', isHot: true, isNew: false, isVipOnly: false, isLimited: false, sortOrder: 2 },
{ id: 3, name: '爱心', description: '表达心意', image: '❤️', price: 50, coinType: 'coin', category: 'love', isHot: true, isNew: true, isVipOnly: false, isLimited: false, sortOrder: 3 },
{ id: 4, name: '钻戒', description: '永恒的爱', image: '💍', price: 999, coinType: 'diamond', category: 'luxury', isHot: false, isNew: false, isVipOnly: true, isLimited: false, sortOrder: 4 }
],
levels: [
{ level: 1, minExp: 0, maxExp: 100, name: '新手', icon: '🌟', color: '#999999', privileges: ['基础功能'] },
{ level: 2, minExp: 100, maxExp: 300, name: '入门', icon: '🌟', color: '#666666', privileges: ['每日匹配+5'] },
{ level: 3, minExp: 300, maxExp: 600, name: '活跃', icon: '🌟', color: '#FF9500', privileges: ['去广告', '高级筛选'] },
{ level: 4, minExp: 600, maxExp: 1000, name: '资深', icon: '🌟', color: '#FF3B30', privileges: ['VIP标识', '优先展示'] },
{ level: 5, minExp: 1000, maxExp: 2000, name: '大神', icon: '🌟', color: '#5856D6', privileges: ['专属客服', '特权标识'] }
],
vipPackages: [
{ id: 1, name: '月卡', duration: 30, durationUnit: 'day', price: 30, benefits: ['去广告', '每日匹配+20', '聊天背景+5'], isHot: true, isRecommend: true },
{ id: 2, name: '季卡', duration: 90, durationUnit: 'day', price: 80, originalPrice: 90, benefits: ['月卡所有特权', '专属标识', '优先匹配'], isHot: false, isRecommend: true },
{ id: 3, name: '年卡', duration: 365, durationUnit: 'day', price: 288, originalPrice: 360, benefits: ['季卡所有特权', '专属客服', '无限匹配'], isHot: true, isRecommend: false }
],
rechargePackages: [
{ id: 1, name: '小试牛刀', coinAmount: 60, diamondAmount: 6, price: 6, currency: 'CNY', discount: 0, isHot: false, isRecommend: false },
{ id: 2, name: '日常所需', coinAmount: 300, diamondAmount: 30, price: 30, currency: 'CNY', discount: 0, isHot: true, isRecommend: true },
{ id: 3, name: '土豪专享', coinAmount: 980, diamondAmount: 98, price: 98, currency: 'CNY', discount: 10, isHot: true, isRecommend: false },
{ id: 4, name: '至尊VIP', coinAmount: 2980, diamondAmount: 298, price: 298, currency: 'CNY', discount: 20, isHot: false, isRecommend: true }
],
verifications: [
{ id: 1, name: '实名认证', description: '提高信任度', icon: '✅', price: 0, benefits: ['认证标识', '提高匹配率'] },
{ id: 2, name: '学历认证', description: '展示教育背景', icon: '🎓', price: 9.9, benefits: ['学历标识', '优先展示'] },
{ id: 3, name: '职业认证', description: '验证职业信息', icon: '💼', price: 9.9, benefits: ['职业标识', '增加可信度'] }
],
chatBackgrounds: [
{ id: 1, name: '默认背景', image: 'https://example.com/bg1.jpg', price: 0, isFree: true },
{ id: 2, name: '星空', image: 'https://example.com/bg2.jpg', price: 10, isFree: false },
{ id: 3, name: '海洋', image: 'https://example.com/bg3.jpg', price: 10, isFree: false },
{ id: 4, name: '森林', image: 'https://example.com/bg4.jpg', price: 20, isFree: false }
],
emojis: [
{ id: 1, name: '微笑', emoji: '😊', category: '表情' },
{ id: 2, name: '爱心', emoji: '❤️', category: '表情' },
{ id: 3, name: '笑哭', emoji: '😂', category: '表情' },
{ id: 4, name: '点赞', emoji: '👍', category: '手势' },
{ id: 5, name: '加油', emoji: '💪', category: '手势' }
]
}
return baseData
}
export { appState }
export default appState
+114
View File
@@ -0,0 +1,114 @@
//定义一个大写的State类型
import { IUser, IUserInfo } from "@/types/user.uts"
export type State = {
uid : number
tokens : ITokens | null
info : IUserInfo | null
profile : UTSJSONObject | null
// 如有需要,可增加更多属性
}
export type ITokens = {
accessToken : string | null
accessExpired : number | null
refreshToken : string | null
refreshExpired : number | null
}
// 实例化为state
const userState = reactive({
uid: 0,
tokens: null,
info: null,
profile: null
} as State)
export const isLoggedIn = computed(() => {
const tokens = getUserToken()
if (tokens == null) return false
const { accessToken, accessExpired } = tokens
return accessToken != null && accessExpired != null && accessExpired > Date.now()
})
export const setUserToken = (accessToken : string, accessExpired : number | null, refreshToken : string | null, refreshExpired : number | null) : void => {
userState.tokens = {
accessToken: accessToken,
accessExpired: accessExpired ?? 0,
refreshToken: refreshToken ?? userState.tokens?.refreshToken ?? null,
refreshExpired: refreshExpired ?? userState.tokens?.refreshExpired ?? 0
} as ITokens
uni.setStorageSync('user_tokens', JSON.stringify(userState.tokens))
}
export const getUserToken = function () : ITokens | null {
try {
if (userState.tokens == null) {
const storageValue = uni.getStorageSync('user_tokens')
// 增加空字符串判断
if (storageValue != null && storageValue.toString().length > 0) {
// #ifndef APP
const localTokens : string = storageValue.toString()
const objcet = JSON.parse(localTokens) as ITokens
// #endif
// #ifdef APP
const localToken = JSON.parseObject(storageValue.toString())
const objcet = localToken?.parse<ITokens>()
// #endif
userState.tokens = objcet
}
}
if (userState.tokens != null && userState.tokens.accessToken != null) {
return userState.tokens
}
} catch (e) {
// 建议清除无效的存储,避免下次继续报错
uni.removeStorageSync('user_tokens')
return null
}
return null
}
export const clearToken = () => {
userState.tokens = null;
uni.removeStorageSync('user_tokens')
}
export const getUserInfo = function () : IUserInfo | null {
if (userState.info == null) {
const storageValue = uni.getStorageSync('user_info')
const localUserInfo : string = storageValue != null ? storageValue.toString() : ""
if (localUserInfo.length > 0) {
const objcet = JSON.parseObject(localUserInfo)
userState.info = objcet?.parse<IUserInfo>()
userState.uid = userState.info?.uid ?? 0
}
}
if (userState.info != null) {
return userState.info
}
return null
}
export const setUserInfo = (info : IUserInfo | null) => {
userState.info = info
if (info != null) {
uni.setStorageSync('user_info', JSON.stringify(info))
} else {
uni.removeStorageSync('user_info')
}
}
export const initStore = () => {
try {
const tokens = getUserToken()
} catch (e) {
console.error('解析用户Token失败', e)
}
try {
const info = getUserInfo()
} catch (e) {
console.error('解析用户信息失败', e)
}
}
export default userState
+495
View File
@@ -0,0 +1,495 @@
export type IAppState = {
baseURL : string
timeout : number
enableCache : boolean
cacheExpire : number
enableLog : boolean
systemConf : ISystemConfig | null
launchData : ILaunchData | null
baseData : IBaseData | null
recommendData : IRecommendData | null
unread : IUnread | null
initialized : boolean
// 时间信息
startTime : number
endTime : number
lastInitTime : number
// 网络状态
networkType : string
isConnected : boolean
}
export type IUnread = {
unreadMessages : number
unreadNotifications : number
}
export type IFeatures = {
chatEnabled : boolean //聊天功能启用:布尔值
videoCallEnabled : boolean //视频通话功能启用:布尔值
voiceRoomEnabled : boolean //语音聊天室功能启用:布尔值
giftShopEnabled : boolean //礼物商店功能启用:布尔值
rechargeEnabled : boolean //充值功能启用:布尔值
vipEnabled : boolean //VIP功能启用:布尔值
matchEnabled : boolean //匹配功能启用:布尔值
momentEnabled : boolean //动态功能启用:布尔值
}
export type ILimits = {
dailyMatchLimit : number //每日匹配次数限制:数字
dailyMessageLimit : number //每日消息发送次数限制:数字
maxFriends : number //好友数量上限:数字
maxMomentImages : number //动态图片数量上限:数字
maxChatImageSize : number //聊天图片大小上限:数字
maxVideoDuration : number //视频时长上限:数字
maxVoiceDuration : number //音时长上限:数字
}
export type IAdConfig = {
enable : boolean //启用
splashAdId : string //启动广告ID:字符串
bannerAdId : string //横幅广告ID:字符串
interstitialAdId : string //间隙广告ID:字符串
rewardAdId : string //奖励广告ID:字符串
adFrequency : number //广告频率:数字
}
export type IServerConf = {
socket : string
upload : string
cdnurl : string
}
/**
* 第三方
*/
export type IThirdParty = {
wechatAppId : string //微信AppId:字符串
qqAppId : string //qq AppId:字符串
appleServiceId : string //苹果 服务ID:字符串
pushService : string //推送服务:字符串
mapService : 'amap' | 'qqmap' | 'baidumap' //地图服务
}
export type ICustomerService = {
qq : string
wechat : string
phone : string
email : string
onlineTime : string
}
/**
* 兼容性
*/
export type ICompatibility = {
minIosVersion : string //最小iOS版本:字符串
minAndroidVersion : string //最小Android版本:字符串
supportedPlatforms : string[] //支持平台:字符串数组
}
/**
* 系统配置接口
*/
export type ISystemConfig = {
// 系统功能开关
chatEnabled ?: boolean //聊天功能启用:布尔值
videoCallEnabled ?: boolean //视频通话功能启用:布尔值
voiceRoomEnabled ?: boolean //语音聊天室功能启用:布尔值
giftShopEnabled ?: boolean //礼物商店功能启用:布尔值
rechargeEnabled ?: boolean //充值功能启用:布尔值
vipEnabled ?: boolean //VIP功能启用:布尔值
matchEnabled ?: boolean //匹配功能启用:布尔值
momentEnabled ?: boolean //动态功能启用:布尔值
// 业务限制配置
dailyMatchLimit ?: number //每日匹配次数限制:数字
dailyMessageLimit ?: number //每日消息发送次数限制:数字
maxFriends ?: number //好友数量上限:数字
maxMomentImages ?: number //动态图片数量上限:数字
maxChatImageSize ?: number //聊天图片大小上限:数字
maxVideoDuration ?: number //视频时长上限:数字
maxVoiceDuration ?: number //音时长上限:数字
// 广告配置
enableAd ?: boolean //启用
splashAdId ?: string //启动广告ID:字符串
bannerAdId ?: string //横幅广告ID:字符串
interstitialAdId ?: string //间隙广告ID:字符串
rewardAdId ?: string //奖励广告ID:字符串
adFrequency ?: number //广告频率:数字
// 服务器配置
socketUrl ?: string
uploadUrl ?: string
cdnUrl ?: string
apiUrl ?: string
apiVersion ?: string
// 第三方服务配置
wechatAppId ?: string //微信AppId:字符串
qqAppId ?: string //qq AppId:字符串
appleServiceId ?: string //苹果 服务ID:字符串
pushService ?: string //推送服务:字符串
mapService ?: 'amap' | 'qqmap' | 'baidumap' //地图服务
// 客服配置
//customerService ?: ICustomerService
// 版本兼容性
minIosVersion ?: string //最小iOS版本:字符串
minAndroidVersion ?: string //最小Android版本:字符串
supportedPlatforms ?: string[] //支持平台:字符串数组
}
export type IsplashAd = {
id : number
image : string
title : string
link : string
duration : number
skipable : boolean
showCount : number
}
export type IBanner = {
id : number
image : string
title : string
link : string
type : 'url' | 'page' | 'activity'
order : number
}
export type IActivitie = {
id : number
title : string
description : string
icon : string
badge ?: string
link : string
startTime : number
endTime : number
status : 'ongoing' | 'upcoming' | 'ended'
}
export type IQuickAction = {
id : number
name : string
icon : string
color : string
route : string
badge ?: number
visible : boolean
}
export type IStep = {
id : number
title : string
content : string
image : string
action ?: string
}
export type INewbieGuide = {
enabled : boolean
steps : IStep[]
}
// 启动数据接口
export type ILaunchData = {
// 启动页广告
splashAds : IsplashAd[]
// 首页轮播图
banners : IBanner[]
// 运营活动
activities : IActivitie[]
// 快捷入口
quickActions : IQuickAction[]
// 新手引导
newbieGuide : INewbieGuide
}
export type ICity = {
id : number
name : string
code : string
parentId : number
level : number
pinyin : string
hot : boolean
latitude ?: number
longitude ?: number
}
export type ITag = {
id : number
name : string
type : 'interest' | 'personality' | 'hobby' | 'profession'
icon ?: string
color ?: string
hot : boolean
count : number
}
export type IGift = {
id : number
name : string
description : string
image : string
animation ?: string
price : number
coinType : 'coin' | 'diamond'
category : string
isHot : boolean
isNew : boolean
isVipOnly : boolean
isLimited : boolean
stock ?: number
sortOrder : number
}
export type ILevel = {
level : number
minExp : number
maxExp : number
name : string
icon : string
color : string
privileges : string[]
}
export type IVipPackage = {
id : number
name : string
duration : number
durationUnit : 'day' | 'month' | 'year'
price : number
originalPrice ?: number
benefits : string[]
isHot : boolean
isRecommend : boolean
}
export type IRechargePackage = {
id : number
name : string
coinAmount : number
diamondAmount : number
price : number
currency : 'CNY' | 'USD'
discount ?: number
isHot : boolean
isRecommend : boolean
}
export type IVerification = {
id : number
name : string
description : string
icon : string
price : number
benefits : string[]
}
export type IChatBackground = {
id : number
name : string
image : string
price : number
isFree : boolean
}
export type IEmoji = {
id : number
name : string
emoji : string
category : string
}
// 基础数据接口
export type IBaseData = {
// 城市地区数据
cities : ICity[]
// 用户兴趣标签
tags : ITag[]
// 礼物商城数据
gifts : IGift[]
// 用户等级体系
levels : ILevel[]
// VIP套餐
vipPackages : IVipPackage[]
// 充值套餐
rechargePackages : IRechargePackage[]
// 认证类型
verifications : IVerification[]
// 聊天背景
chatBackgrounds : IChatBackground[]
// 表情包
emojis : IEmoji[]
}
export type IState = {
todayMatches : number
todayMessages : number
unreadMessages : number
unreadNotifications : number
unreadSystemMsg : number
newMatches : number
newLikes : number
newVisitors : number
newFollowers : number
}
export type IWallet = {
coinBalance : number
diamondBalance : number
todayEarnings : number
todaySpent : number
totalRecharge : number
totalWithdraw : number
}
export type IDailyTask = {
id : number
name : string
description : string
progress : number
target : number
reward : number
completed : boolean
}
export type ITask = {
dailyTasks : IDailyTask[]
achievementPoints : number
}
export type IDeviceInfo = {
platform : string
version : string
}
export type IOnline = {
isOnline : boolean
lastOnlineTime : number
deviceInfo : IDeviceInfo
}
export type IPrivacy = {
showOnline : boolean
showDistance : boolean
showLastActive : boolean
allowStrangerChat : boolean
allowRecommend : boolean
}
export type INotification = {
newMessage : boolean
newMatch : boolean
newLike : boolean
sound : boolean
vibration : boolean
}
export type ISetting = {
privacy : IPrivacy
notification : INotification
}
export type IUserSummary = {
// 用户基本信息
userId : number
nickname : string
avatar : string
level : number
vipLevel : number
vipExpireTime ?: number
// 统计数据
stats : IState
// 钱包信息
wallet : IWallet
// 任务和成就
tasks : ITask
// 在线状态
online : IOnline
// 用户设置
settings : ISetting
}
export type IRecommendedUser = {
id : number
avatar : string
nickname : string
age : number
gender : 'male' | 'female'
distance : number
online : boolean
tags : string[]
matchScore : number
}
export type IHotMomentUser = {
id : number
nickname : string
avatar : string
}
export type IHotMoment = {
id : number
userId : number
content : string
image ?: string
video ?: string
likeCount : number
commentCount : number
shareCount : number
user : IHotMomentUser
}
export type IVoiceRoomOwer = {
id : number
nickname : string
avatar : string
}
export type IVoiceRoom = {
id : number
title : string
onlineCount : number
maxUsers : number
cover : string
tags : string[]
owner : IVoiceRoomOwer
}
export type IVideoRoom = {
id : number
title : string
onlineCount : number
cover : string
category : string
hotValue : number
}
export type IMiniGame = {
id : number
name : string
icon : string
description : string
onlineCount : number
link : string
}
export type IInterestCircle = {
id : number
name : string
icon : string
memberCount : number
postCount : number
description : string
}
// 推荐数据接口
export type IRecommendData = {
// 推荐用户
recommendedUsers : IRecommendedUser[]
// 热门动态
hotMoments : IHotMoment[]
// 语音房间
voiceRooms : IVoiceRoom[]
// 视频房间
videoRooms : IVideoRoom[]
// 小游戏
miniGames : IMiniGame[]
// 兴趣圈子
interestCircles : IInterestCircle[]
}
export type INetworkConf = {
AttachURL : string
SocketURL : string
}
@@ -0,0 +1,19 @@
// 定义用户类型
export type ISender = {
id : number
nickname : string
avatar : string
online : boolean
vip : boolean
}
// 定义消息类型
export type IMessage = {
id : number
sender : ISender
content : string
timestamp : number
unreadCount : number
pinned : boolean
type : 'text' | 'image' | 'voice' | 'video'
}
@@ -0,0 +1,43 @@
export type ITab = {
name : string
badge : number
}
export type IMoment = {
id : number
userId : number
userName : string
userAvatar : string
userVip : boolean
content : string
images : string[]
videoUrl : string
type : 'text' | 'image' | 'video'
location : string
time : number
likes : number
comments : number
shares : number
isLiked : boolean
isPinned : boolean
tags : string[]
hot : boolean
distance : number
// 详情扩展字段
uid? : number
media? : Array<{ type : 'image' | 'video', url : string, thumbnail? : string }>
hashtags? : Array<{ id : number, name : string }>
stats? : {
like_count : number
comment_count : number
share_count : number
collect_count : number
view_count : number
}
interaction? : { liked : boolean, collected : boolean, followed : boolean }
view? : number
collected? : boolean
followed? : boolean
}
@@ -0,0 +1,52 @@
export type IUser = {}
// 语音房接口
export type IVoiceRoom = {
id : number
title : string
cover : string
onlineCount : number
maxCount : number
hot : number
tags : string[]
users : usera[]
}
export type usera = {
id : number;
avatar : string
}
// 游戏接口
export type IGame = {
id : number
title : string
image : string
description : string
playing : number
reward : number
tags : string[]
}
export type IMenu = {
id : number
icon : string
iconColor ?: string
text : string
bgColor : string
url : string
hot ?: boolean
}
export type IBanner = {
id : number
image : string
tag : string
url : string
}
export type IFilter = {
id : number
text : string
count : number
}
+196
View File
@@ -0,0 +1,196 @@
type IRequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD'
/**
* 请求配置接口 - 兼容 uni-app x 各平台
* 使用 UTSJSONObject 替代具体对象类型,避免类型推断问题
*/
type IRequestOption = {
/** 请求地址 */
url : string
/** 请求方法 */
method ?: IRequestMethod
/** 请求数据 - 使用 UTSJSONObject 兼容任意对象 */
data ?: UTSJSONObject | null
/** 请求头 - 使用 UTSJSONObject 避免 Any? 类型错误 */
header ?: UTSJSONObject | null
/** 超时时间(毫秒) */
timeout ?: number
/** 返回数据类型 */
dataType ?: 'json' | 'text' | 'arraybuffer'
/** 响应数据编码 */
responseType ?: 'text' | 'arraybuffer'
/** 是否显示加载提示 */
showLoading ?: boolean
/** 加载提示文字 */
loadingText ?: string
/** 是否显示错误提示 */
showError ?: boolean
/** 是否需要 token */
needToken ?: boolean
/** 重试次数 */
retryCount ?: number
/** 基础地址(用于覆盖全局 baseURL) */
baseURL ?: string
/** 内容类型 */
contentType ?: 'json' | 'form' | 'multipart'
}
/**
* 请求成功响应 - 泛型提升到接口级别
*/
type IResponse = {
code : number
message : string
data ?: UTSJSONObject
timestamp : number
access_token ?: string
access_expired ?: number
refresh_token ?: string
refresh_expired ?: number
}
/**
* 请求取消错误
*/
class ICancelError extends Error {
public code : string = 'ERR_CANCELED'
public isCancelError : boolean = true
constructor(message : string = 'Request cancelled') {
super(message)
this.name = 'CancelError'
}
}
/**
* 超时错误
*/
class ITimeoutError extends Error {
public code : string = 'ECONNABORTED'
public isTimeoutError : boolean = true
constructor(message : string = 'Request timeout') {
super(message)
this.name = 'TimeoutError'
}
}
/**
* 网络错误
*/
class INetworkError extends Error {
public code : string = 'NETWORK_ERROR'
public isNetworkError : boolean = true
constructor(message : string = 'Network error') {
super(message)
this.name = 'NetworkError'
}
}
type IHttpError = {
errCode : number
errMsg ?: string
cause ?: Error
options ?: IRequestOption | IUploadOption
data ?: any
}
type IUploadOption = {
url : string
filePath ?: string
name ?: string
timeout ?: number
formData ?: UTSJSONObject
header ?: UTSJSONObject
}
type IUploadInfo = {
fid : number
url : string,
name : string
}
// 文件信息接口
type IFileInfo = {
index ?: number // 文件索引
fid ?: number
path : string // 本地文件路径
name ?: string // 自定义文件名(可选)
}
// 上传结果接口
type IUploadResult = {
index : number // 文件索引
success : boolean // 是否成功
data ?: IResponse // 成功时的响应数据
error ?: any // 失败时的错误信息
}
// 请求方法类型
type IDownRespone = {
tempFilePath : string,
statusCode : number
}
// 请求配置接口
type IResponseToken = {
access_token : string
refresh_token : string
expires_in : number
}
// Token 信息接口
type ITokenInfo = {
accessToken : string
refreshToken : string
expireTime : number
tokenType ?: string
}
// 网络状态类型
interface INetworkInfo {
isConnected : boolean
networkType : string
isWifi : boolean
isCellular : boolean
}
// 创建错误对象
/**
*
* ECONNABORTED ERR_CANCELED NETWORK_ERROR UNKNOWN_ERROR
*/
function HttpError(options : IHttpError) : Error {
const errMsg = options.errMsg ?? `HTTP Error: ${options.errCode}`
// #ifdef APP
const error = new UniError()
error.errSubject = 'http-request'
error.errCode = options.errCode
error.errMsg = errMsg
if (options.cause != null) error.cause = options.cause
if (options.data != null) error.data = options.data
return error
// #endif
// #ifndef APP
const error = new Error(errMsg);
(error as any).errSubject = 'http-request';
(error as any).errCode = options.errCode;
(error as any).errMsg = errMsg
if (options.cause != null) (error as any).cause = options.cause
if (options.data != null) (error as any).data = options.data
return error
// #endif
}
export default HttpError
export {
IRequestMethod, IRequestOption, IResponse,
ITokenInfo, INetworkInfo,
IUploadOption, IUploadResult, IFileInfo, IUploadInfo,
IDownRespone, IResponseToken,
IHttpError, INetworkError, ITimeoutError, ICancelError
}
@@ -0,0 +1,25 @@
export type IGrid = {
name : string
desc : string
bgColor : string
icon : string
path : string
}
export type IUser = {
id: number,
nickname: string,
realname: boolean,
location: string,
age: number,
height: number,
profession: string,
signature: string,
avatar: string,
vip: boolean,
verified: boolean,
live: boolean
}
@@ -0,0 +1,38 @@
export type IMoment ={
id : number
uid : number
nickname : string
avatar : string
location : string
content : string
media : IMedia[]
createTime : number
like : number
likede : boolean
comment : number
share:number
view?: number
expanded? :boolean
}
export type IComment ={
id:number
mid:number
uid:number
nickname:string
avatar:string
content:string
createTime:number
like:number
liked:boolean
replyCount:number
replies:UTSJSONObject[]
}
export type IMedia = {
type : 'image' | 'video'
url : string
thumbnail ?: boolean
}
@@ -0,0 +1,74 @@
export type TProfile = {
uid : number // 用户id
age : string // 年龄
height : string // 身高
weight : string // 体重
live : string // 居住地
hometown : string // 家乡
education : string // 学历
profession : string // 工作
annualIncome : string // 收入
constellation : string // 星座
}
// export interface ISender { }
// 用户信息接口
export type IVoiceMessages = {
id : number
senderId : number
receiverId : number
duration : number
timestamp : number
type : string
playing : boolean
}
// 用户信息接口
export type IImageMessages = {
id : number
senderId : number
receiverId : number
url : string
timestamp : number
type : string
}
// 用户信息接口
export type IUser = {
id : number
nickname : string
avatar : string
age ?: number
gender ?: 'male' | 'female'
signature ?: string
online : boolean
vip : boolean
distance ?: number
tags ?: string[]
likes ?: number
views ?: number
}
export type IUserInfo = {
uid : number
account : string
nickname : string
create_ip : string
update_time : string
update_ip : string
avatar : string
email : string
mobile : string
gid : number
status : number
email_verified : number
create_time : string
status_text : string
last_login : string
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//基本色
$uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2C405A; // 文章标题颜色
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;
@@ -0,0 +1,44 @@
## 2.0.122025-08-26
- 优化 uni-app x 下 size 类型问题
## 2.0.112025-08-18
- 修复 图标点击事件返回
## 2.0.92024-01-12
fix: 修复图标大小默认值错误的问题
## 2.0.82023-12-14
- 修复 项目未使用 ts 情况下,打包报错的bug
## 2.0.72023-12-14
- 修复 size 属性为 string 时,不加单位导致尺寸异常的bug
## 2.0.62023-12-11
- 优化 兼容老版本icon类型,如 top bottom 等
## 2.0.52023-12-11
- 优化 兼容老版本icon类型,如 top bottom 等
## 2.0.42023-12-06
- 优化 uni-app x 下示例项目图标排序
## 2.0.32023-12-06
- 修复 nvue下引入组件报错的bug
## 2.0.22023-12-05
-优化 size 属性支持单位
## 2.0.12023-12-05
- 新增 uni-app x 支持定义图标
## 1.3.52022-01-24
- 优化 size 属性可以传入不带单位的字符串数值
## 1.3.42022-01-24
- 优化 size 支持其他单位
## 1.3.32022-01-17
- 修复 nvue 有些图标不显示的bug,兼容老版本图标
## 1.3.22021-12-01
- 优化 示例可复制图标名称
## 1.3.12021-11-23
- 优化 兼容旧组件 type 值
## 1.3.02021-11-19
- 新增 更多图标
- 优化 自定义图标使用方式
- 优化 组件UI,并提供设计资源,详见:[https://uniapp.dcloud.io/component/uniui/resource](https://uniapp.dcloud.io/component/uniui/resource)
- 文档迁移,详见:[https://uniapp.dcloud.io/component/uniui/uni-icons](https://uniapp.dcloud.io/component/uniui/uni-icons)
## 1.1.72021-11-08
## 1.2.02021-07-30
- 组件兼容 vue3,如何创建vue3项目,详见 [uni-app 项目支持 vue3 介绍](https://ask.dcloud.net.cn/article/37834)
## 1.1.52021-05-12
- 新增 组件示例地址
## 1.1.42021-02-05
- 调整为uni_modules目录规范
@@ -0,0 +1,91 @@
<template>
<text class="uni-icons" :style="styleObj">
<slot>{{unicode}}</slot>
</text>
</template>
<script>
import { fontData, IconsDataItem } from './uniicons_file'
/**
* Icons 图标
* @description 用于展示 icon 图标
* @tutorial https://ext.dcloud.net.cn/plugin?id=28
* @property {Number} size 图标大小
* @property {String} type 图标图案,参考示例
* @property {String} color 图标颜色
* @property {String} customPrefix 自定义图标
* @event {Function} click 点击 Icon 触发事件
*/
export default {
name: "uni-icons",
props: {
type: {
type: String,
default: ''
},
color: {
type: String,
default: '#333333'
},
size: {
type: [Number, String],
default: 16
},
fontFamily: {
type: String,
default: ''
}
},
data() {
return {};
},
computed: {
unicode() : string {
let codes = fontData.find((item : IconsDataItem) : boolean => { return item.font_class == this.type })
if (codes !== null) {
return codes.unicode
}
return ''
},
iconSize() : string {
const size = this.size
if (typeof size == 'string') {
const reg = /^[0-9]*$/g
return reg.test(size as string) ? '' + size + 'px' : '' + size;
// return '' + this.size
}
return this.getFontSize(size as number)
},
styleObj() : UTSJSONObject {
if (this.fontFamily !== '') {
return { color: this.color, fontSize: this.iconSize, fontFamily: this.fontFamily }
}
return { color: this.color, fontSize: this.iconSize }
}
},
created() { },
methods: {
/**
* 字体大小
*/
getFontSize(size : number) : string {
return size + 'px';
},
},
}
</script>
<style scoped>
@font-face {
font-family: UniIconsFontFamily;
src: url('./uniicons.ttf');
}
.uni-icons {
font-family: UniIconsFontFamily;
font-size: 18px;
font-style: normal;
color: #333;
}
</style>
@@ -0,0 +1,110 @@
<template>
<!-- #ifdef APP-NVUE -->
<text :style="styleObj" class="uni-icons" @click="_onClick">{{unicode}}</text>
<!-- #endif -->
<!-- #ifndef APP-NVUE -->
<text :style="styleObj" class="uni-icons" :class="['uniui-'+type,customPrefix,customPrefix?type:'']" @click="_onClick">
<slot></slot>
</text>
<!-- #endif -->
</template>
<script>
import { fontData } from './uniicons_file_vue.js';
const getVal = (val) => {
const reg = /^[0-9]*$/g
return (typeof val === 'number' || reg.test(val)) ? val + 'px' : val;
}
// #ifdef APP-NVUE
var domModule = weex.requireModule('dom');
import iconUrl from './uniicons.ttf'
domModule.addRule('fontFace', {
'fontFamily': "uniicons",
'src': "url('" + iconUrl + "')"
});
// #endif
/**
* Icons 图标
* @description 用于展示 icons 图标
* @tutorial https://ext.dcloud.net.cn/plugin?id=28
* @property {Number} size 图标大小
* @property {String} type 图标图案,参考示例
* @property {String} color 图标颜色
* @property {String} customPrefix 自定义图标
* @event {Function} click 点击 Icon 触发事件
*/
export default {
name: 'UniIcons',
emits: ['click'],
props: {
type: {
type: String,
default: ''
},
color: {
type: String,
default: '#333333'
},
size: {
type: [Number, String],
default: 16
},
customPrefix: {
type: String,
default: ''
},
fontFamily: {
type: String,
default: ''
}
},
data() {
return {
icons: fontData
}
},
computed: {
unicode() {
let code = this.icons.find(v => v.font_class === this.type)
if (code) {
return code.unicode
}
return ''
},
iconSize() {
return getVal(this.size)
},
styleObj() {
if (this.fontFamily !== '') {
return `color: ${this.color}; font-size: ${this.iconSize}; font-family: ${this.fontFamily};`
}
return `color: ${this.color}; font-size: ${this.iconSize};`
}
},
methods: {
_onClick(e) {
this.$emit('click', e)
}
}
}
</script>
<style lang="scss">
/* #ifndef APP-NVUE */
@import './uniicons.css';
@font-face {
font-family: uniicons;
src: url('./uniicons.ttf');
}
/* #endif */
.uni-icons {
font-family: uniicons;
text-decoration: none;
text-align: center;
}
</style>
@@ -0,0 +1,664 @@
.uniui-cart-filled:before {
content: "\e6d0";
}
.uniui-gift-filled:before {
content: "\e6c4";
}
.uniui-color:before {
content: "\e6cf";
}
.uniui-wallet:before {
content: "\e6b1";
}
.uniui-settings-filled:before {
content: "\e6ce";
}
.uniui-auth-filled:before {
content: "\e6cc";
}
.uniui-shop-filled:before {
content: "\e6cd";
}
.uniui-staff-filled:before {
content: "\e6cb";
}
.uniui-vip-filled:before {
content: "\e6c6";
}
.uniui-plus-filled:before {
content: "\e6c7";
}
.uniui-folder-add-filled:before {
content: "\e6c8";
}
.uniui-color-filled:before {
content: "\e6c9";
}
.uniui-tune-filled:before {
content: "\e6ca";
}
.uniui-calendar-filled:before {
content: "\e6c0";
}
.uniui-notification-filled:before {
content: "\e6c1";
}
.uniui-wallet-filled:before {
content: "\e6c2";
}
.uniui-medal-filled:before {
content: "\e6c3";
}
.uniui-fire-filled:before {
content: "\e6c5";
}
.uniui-refreshempty:before {
content: "\e6bf";
}
.uniui-location-filled:before {
content: "\e6af";
}
.uniui-person-filled:before {
content: "\e69d";
}
.uniui-personadd-filled:before {
content: "\e698";
}
.uniui-arrowthinleft:before {
content: "\e6d2";
}
.uniui-arrowthinup:before {
content: "\e6d3";
}
.uniui-arrowthindown:before {
content: "\e6d4";
}
.uniui-back:before {
content: "\e6b9";
}
.uniui-forward:before {
content: "\e6ba";
}
.uniui-arrow-right:before {
content: "\e6bb";
}
.uniui-arrow-left:before {
content: "\e6bc";
}
.uniui-arrow-up:before {
content: "\e6bd";
}
.uniui-arrow-down:before {
content: "\e6be";
}
.uniui-arrowthinright:before {
content: "\e6d1";
}
.uniui-down:before {
content: "\e6b8";
}
.uniui-bottom:before {
content: "\e6b8";
}
.uniui-arrowright:before {
content: "\e6d5";
}
.uniui-right:before {
content: "\e6b5";
}
.uniui-up:before {
content: "\e6b6";
}
.uniui-top:before {
content: "\e6b6";
}
.uniui-left:before {
content: "\e6b7";
}
.uniui-arrowup:before {
content: "\e6d6";
}
.uniui-eye:before {
content: "\e651";
}
.uniui-eye-filled:before {
content: "\e66a";
}
.uniui-eye-slash:before {
content: "\e6b3";
}
.uniui-eye-slash-filled:before {
content: "\e6b4";
}
.uniui-info-filled:before {
content: "\e649";
}
.uniui-reload:before {
content: "\e6b2";
}
.uniui-micoff-filled:before {
content: "\e6b0";
}
.uniui-map-pin-ellipse:before {
content: "\e6ac";
}
.uniui-map-pin:before {
content: "\e6ad";
}
.uniui-location:before {
content: "\e6ae";
}
.uniui-starhalf:before {
content: "\e683";
}
.uniui-star:before {
content: "\e688";
}
.uniui-star-filled:before {
content: "\e68f";
}
.uniui-calendar:before {
content: "\e6a0";
}
.uniui-fire:before {
content: "\e6a1";
}
.uniui-medal:before {
content: "\e6a2";
}
.uniui-font:before {
content: "\e6a3";
}
.uniui-gift:before {
content: "\e6a4";
}
.uniui-link:before {
content: "\e6a5";
}
.uniui-notification:before {
content: "\e6a6";
}
.uniui-staff:before {
content: "\e6a7";
}
.uniui-vip:before {
content: "\e6a8";
}
.uniui-folder-add:before {
content: "\e6a9";
}
.uniui-tune:before {
content: "\e6aa";
}
.uniui-auth:before {
content: "\e6ab";
}
.uniui-person:before {
content: "\e699";
}
.uniui-email-filled:before {
content: "\e69a";
}
.uniui-phone-filled:before {
content: "\e69b";
}
.uniui-phone:before {
content: "\e69c";
}
.uniui-email:before {
content: "\e69e";
}
.uniui-personadd:before {
content: "\e69f";
}
.uniui-chatboxes-filled:before {
content: "\e692";
}
.uniui-contact:before {
content: "\e693";
}
.uniui-chatbubble-filled:before {
content: "\e694";
}
.uniui-contact-filled:before {
content: "\e695";
}
.uniui-chatboxes:before {
content: "\e696";
}
.uniui-chatbubble:before {
content: "\e697";
}
.uniui-upload-filled:before {
content: "\e68e";
}
.uniui-upload:before {
content: "\e690";
}
.uniui-weixin:before {
content: "\e691";
}
.uniui-compose:before {
content: "\e67f";
}
.uniui-qq:before {
content: "\e680";
}
.uniui-download-filled:before {
content: "\e681";
}
.uniui-pyq:before {
content: "\e682";
}
.uniui-sound:before {
content: "\e684";
}
.uniui-trash-filled:before {
content: "\e685";
}
.uniui-sound-filled:before {
content: "\e686";
}
.uniui-trash:before {
content: "\e687";
}
.uniui-videocam-filled:before {
content: "\e689";
}
.uniui-spinner-cycle:before {
content: "\e68a";
}
.uniui-weibo:before {
content: "\e68b";
}
.uniui-videocam:before {
content: "\e68c";
}
.uniui-download:before {
content: "\e68d";
}
.uniui-help:before {
content: "\e679";
}
.uniui-navigate-filled:before {
content: "\e67a";
}
.uniui-plusempty:before {
content: "\e67b";
}
.uniui-smallcircle:before {
content: "\e67c";
}
.uniui-minus-filled:before {
content: "\e67d";
}
.uniui-micoff:before {
content: "\e67e";
}
.uniui-closeempty:before {
content: "\e66c";
}
.uniui-clear:before {
content: "\e66d";
}
.uniui-navigate:before {
content: "\e66e";
}
.uniui-minus:before {
content: "\e66f";
}
.uniui-image:before {
content: "\e670";
}
.uniui-mic:before {
content: "\e671";
}
.uniui-paperplane:before {
content: "\e672";
}
.uniui-close:before {
content: "\e673";
}
.uniui-help-filled:before {
content: "\e674";
}
.uniui-paperplane-filled:before {
content: "\e675";
}
.uniui-plus:before {
content: "\e676";
}
.uniui-mic-filled:before {
content: "\e677";
}
.uniui-image-filled:before {
content: "\e678";
}
.uniui-locked-filled:before {
content: "\e668";
}
.uniui-info:before {
content: "\e669";
}
.uniui-locked:before {
content: "\e66b";
}
.uniui-camera-filled:before {
content: "\e658";
}
.uniui-chat-filled:before {
content: "\e659";
}
.uniui-camera:before {
content: "\e65a";
}
.uniui-circle:before {
content: "\e65b";
}
.uniui-checkmarkempty:before {
content: "\e65c";
}
.uniui-chat:before {
content: "\e65d";
}
.uniui-circle-filled:before {
content: "\e65e";
}
.uniui-flag:before {
content: "\e65f";
}
.uniui-flag-filled:before {
content: "\e660";
}
.uniui-gear-filled:before {
content: "\e661";
}
.uniui-home:before {
content: "\e662";
}
.uniui-home-filled:before {
content: "\e663";
}
.uniui-gear:before {
content: "\e664";
}
.uniui-smallcircle-filled:before {
content: "\e665";
}
.uniui-map-filled:before {
content: "\e666";
}
.uniui-map:before {
content: "\e667";
}
.uniui-refresh-filled:before {
content: "\e656";
}
.uniui-refresh:before {
content: "\e657";
}
.uniui-cloud-upload:before {
content: "\e645";
}
.uniui-cloud-download-filled:before {
content: "\e646";
}
.uniui-cloud-download:before {
content: "\e647";
}
.uniui-cloud-upload-filled:before {
content: "\e648";
}
.uniui-redo:before {
content: "\e64a";
}
.uniui-images-filled:before {
content: "\e64b";
}
.uniui-undo-filled:before {
content: "\e64c";
}
.uniui-more:before {
content: "\e64d";
}
.uniui-more-filled:before {
content: "\e64e";
}
.uniui-undo:before {
content: "\e64f";
}
.uniui-images:before {
content: "\e650";
}
.uniui-paperclip:before {
content: "\e652";
}
.uniui-settings:before {
content: "\e653";
}
.uniui-search:before {
content: "\e654";
}
.uniui-redo-filled:before {
content: "\e655";
}
.uniui-list:before {
content: "\e644";
}
.uniui-mail-open-filled:before {
content: "\e63a";
}
.uniui-hand-down-filled:before {
content: "\e63c";
}
.uniui-hand-down:before {
content: "\e63d";
}
.uniui-hand-up-filled:before {
content: "\e63e";
}
.uniui-hand-up:before {
content: "\e63f";
}
.uniui-heart-filled:before {
content: "\e641";
}
.uniui-mail-open:before {
content: "\e643";
}
.uniui-heart:before {
content: "\e639";
}
.uniui-loop:before {
content: "\e633";
}
.uniui-pulldown:before {
content: "\e632";
}
.uniui-scan:before {
content: "\e62a";
}
.uniui-bars:before {
content: "\e627";
}
.uniui-checkbox:before {
content: "\e62b";
}
.uniui-checkbox-filled:before {
content: "\e62c";
}
.uniui-shop:before {
content: "\e62f";
}
.uniui-headphones:before {
content: "\e630";
}
.uniui-cart:before {
content: "\e631";
}
@@ -0,0 +1,664 @@
export type IconsData = {
id : string
name : string
font_family : string
css_prefix_text : string
description : string
glyphs : Array<IconsDataItem>
}
export type IconsDataItem = {
font_class : string
unicode : string
}
export const fontData = [
{
"font_class": "arrow-down",
"unicode": "\ue6be"
},
{
"font_class": "arrow-left",
"unicode": "\ue6bc"
},
{
"font_class": "arrow-right",
"unicode": "\ue6bb"
},
{
"font_class": "arrow-up",
"unicode": "\ue6bd"
},
{
"font_class": "auth",
"unicode": "\ue6ab"
},
{
"font_class": "auth-filled",
"unicode": "\ue6cc"
},
{
"font_class": "back",
"unicode": "\ue6b9"
},
{
"font_class": "bars",
"unicode": "\ue627"
},
{
"font_class": "calendar",
"unicode": "\ue6a0"
},
{
"font_class": "calendar-filled",
"unicode": "\ue6c0"
},
{
"font_class": "camera",
"unicode": "\ue65a"
},
{
"font_class": "camera-filled",
"unicode": "\ue658"
},
{
"font_class": "cart",
"unicode": "\ue631"
},
{
"font_class": "cart-filled",
"unicode": "\ue6d0"
},
{
"font_class": "chat",
"unicode": "\ue65d"
},
{
"font_class": "chat-filled",
"unicode": "\ue659"
},
{
"font_class": "chatboxes",
"unicode": "\ue696"
},
{
"font_class": "chatboxes-filled",
"unicode": "\ue692"
},
{
"font_class": "chatbubble",
"unicode": "\ue697"
},
{
"font_class": "chatbubble-filled",
"unicode": "\ue694"
},
{
"font_class": "checkbox",
"unicode": "\ue62b"
},
{
"font_class": "checkbox-filled",
"unicode": "\ue62c"
},
{
"font_class": "checkmarkempty",
"unicode": "\ue65c"
},
{
"font_class": "circle",
"unicode": "\ue65b"
},
{
"font_class": "circle-filled",
"unicode": "\ue65e"
},
{
"font_class": "clear",
"unicode": "\ue66d"
},
{
"font_class": "close",
"unicode": "\ue673"
},
{
"font_class": "closeempty",
"unicode": "\ue66c"
},
{
"font_class": "cloud-download",
"unicode": "\ue647"
},
{
"font_class": "cloud-download-filled",
"unicode": "\ue646"
},
{
"font_class": "cloud-upload",
"unicode": "\ue645"
},
{
"font_class": "cloud-upload-filled",
"unicode": "\ue648"
},
{
"font_class": "color",
"unicode": "\ue6cf"
},
{
"font_class": "color-filled",
"unicode": "\ue6c9"
},
{
"font_class": "compose",
"unicode": "\ue67f"
},
{
"font_class": "contact",
"unicode": "\ue693"
},
{
"font_class": "contact-filled",
"unicode": "\ue695"
},
{
"font_class": "down",
"unicode": "\ue6b8"
},
{
"font_class": "bottom",
"unicode": "\ue6b8"
},
{
"font_class": "download",
"unicode": "\ue68d"
},
{
"font_class": "download-filled",
"unicode": "\ue681"
},
{
"font_class": "email",
"unicode": "\ue69e"
},
{
"font_class": "email-filled",
"unicode": "\ue69a"
},
{
"font_class": "eye",
"unicode": "\ue651"
},
{
"font_class": "eye-filled",
"unicode": "\ue66a"
},
{
"font_class": "eye-slash",
"unicode": "\ue6b3"
},
{
"font_class": "eye-slash-filled",
"unicode": "\ue6b4"
},
{
"font_class": "fire",
"unicode": "\ue6a1"
},
{
"font_class": "fire-filled",
"unicode": "\ue6c5"
},
{
"font_class": "flag",
"unicode": "\ue65f"
},
{
"font_class": "flag-filled",
"unicode": "\ue660"
},
{
"font_class": "folder-add",
"unicode": "\ue6a9"
},
{
"font_class": "folder-add-filled",
"unicode": "\ue6c8"
},
{
"font_class": "font",
"unicode": "\ue6a3"
},
{
"font_class": "forward",
"unicode": "\ue6ba"
},
{
"font_class": "gear",
"unicode": "\ue664"
},
{
"font_class": "gear-filled",
"unicode": "\ue661"
},
{
"font_class": "gift",
"unicode": "\ue6a4"
},
{
"font_class": "gift-filled",
"unicode": "\ue6c4"
},
{
"font_class": "hand-down",
"unicode": "\ue63d"
},
{
"font_class": "hand-down-filled",
"unicode": "\ue63c"
},
{
"font_class": "hand-up",
"unicode": "\ue63f"
},
{
"font_class": "hand-up-filled",
"unicode": "\ue63e"
},
{
"font_class": "headphones",
"unicode": "\ue630"
},
{
"font_class": "heart",
"unicode": "\ue639"
},
{
"font_class": "heart-filled",
"unicode": "\ue641"
},
{
"font_class": "help",
"unicode": "\ue679"
},
{
"font_class": "help-filled",
"unicode": "\ue674"
},
{
"font_class": "home",
"unicode": "\ue662"
},
{
"font_class": "home-filled",
"unicode": "\ue663"
},
{
"font_class": "image",
"unicode": "\ue670"
},
{
"font_class": "image-filled",
"unicode": "\ue678"
},
{
"font_class": "images",
"unicode": "\ue650"
},
{
"font_class": "images-filled",
"unicode": "\ue64b"
},
{
"font_class": "info",
"unicode": "\ue669"
},
{
"font_class": "info-filled",
"unicode": "\ue649"
},
{
"font_class": "left",
"unicode": "\ue6b7"
},
{
"font_class": "link",
"unicode": "\ue6a5"
},
{
"font_class": "list",
"unicode": "\ue644"
},
{
"font_class": "location",
"unicode": "\ue6ae"
},
{
"font_class": "location-filled",
"unicode": "\ue6af"
},
{
"font_class": "locked",
"unicode": "\ue66b"
},
{
"font_class": "locked-filled",
"unicode": "\ue668"
},
{
"font_class": "loop",
"unicode": "\ue633"
},
{
"font_class": "mail-open",
"unicode": "\ue643"
},
{
"font_class": "mail-open-filled",
"unicode": "\ue63a"
},
{
"font_class": "map",
"unicode": "\ue667"
},
{
"font_class": "map-filled",
"unicode": "\ue666"
},
{
"font_class": "map-pin",
"unicode": "\ue6ad"
},
{
"font_class": "map-pin-ellipse",
"unicode": "\ue6ac"
},
{
"font_class": "medal",
"unicode": "\ue6a2"
},
{
"font_class": "medal-filled",
"unicode": "\ue6c3"
},
{
"font_class": "mic",
"unicode": "\ue671"
},
{
"font_class": "mic-filled",
"unicode": "\ue677"
},
{
"font_class": "micoff",
"unicode": "\ue67e"
},
{
"font_class": "micoff-filled",
"unicode": "\ue6b0"
},
{
"font_class": "minus",
"unicode": "\ue66f"
},
{
"font_class": "minus-filled",
"unicode": "\ue67d"
},
{
"font_class": "more",
"unicode": "\ue64d"
},
{
"font_class": "more-filled",
"unicode": "\ue64e"
},
{
"font_class": "navigate",
"unicode": "\ue66e"
},
{
"font_class": "navigate-filled",
"unicode": "\ue67a"
},
{
"font_class": "notification",
"unicode": "\ue6a6"
},
{
"font_class": "notification-filled",
"unicode": "\ue6c1"
},
{
"font_class": "paperclip",
"unicode": "\ue652"
},
{
"font_class": "paperplane",
"unicode": "\ue672"
},
{
"font_class": "paperplane-filled",
"unicode": "\ue675"
},
{
"font_class": "person",
"unicode": "\ue699"
},
{
"font_class": "person-filled",
"unicode": "\ue69d"
},
{
"font_class": "personadd",
"unicode": "\ue69f"
},
{
"font_class": "personadd-filled",
"unicode": "\ue698"
},
{
"font_class": "personadd-filled-copy",
"unicode": "\ue6d1"
},
{
"font_class": "phone",
"unicode": "\ue69c"
},
{
"font_class": "phone-filled",
"unicode": "\ue69b"
},
{
"font_class": "plus",
"unicode": "\ue676"
},
{
"font_class": "plus-filled",
"unicode": "\ue6c7"
},
{
"font_class": "plusempty",
"unicode": "\ue67b"
},
{
"font_class": "pulldown",
"unicode": "\ue632"
},
{
"font_class": "pyq",
"unicode": "\ue682"
},
{
"font_class": "qq",
"unicode": "\ue680"
},
{
"font_class": "redo",
"unicode": "\ue64a"
},
{
"font_class": "redo-filled",
"unicode": "\ue655"
},
{
"font_class": "refresh",
"unicode": "\ue657"
},
{
"font_class": "refresh-filled",
"unicode": "\ue656"
},
{
"font_class": "refreshempty",
"unicode": "\ue6bf"
},
{
"font_class": "reload",
"unicode": "\ue6b2"
},
{
"font_class": "right",
"unicode": "\ue6b5"
},
{
"font_class": "scan",
"unicode": "\ue62a"
},
{
"font_class": "search",
"unicode": "\ue654"
},
{
"font_class": "settings",
"unicode": "\ue653"
},
{
"font_class": "settings-filled",
"unicode": "\ue6ce"
},
{
"font_class": "shop",
"unicode": "\ue62f"
},
{
"font_class": "shop-filled",
"unicode": "\ue6cd"
},
{
"font_class": "smallcircle",
"unicode": "\ue67c"
},
{
"font_class": "smallcircle-filled",
"unicode": "\ue665"
},
{
"font_class": "sound",
"unicode": "\ue684"
},
{
"font_class": "sound-filled",
"unicode": "\ue686"
},
{
"font_class": "spinner-cycle",
"unicode": "\ue68a"
},
{
"font_class": "staff",
"unicode": "\ue6a7"
},
{
"font_class": "staff-filled",
"unicode": "\ue6cb"
},
{
"font_class": "star",
"unicode": "\ue688"
},
{
"font_class": "star-filled",
"unicode": "\ue68f"
},
{
"font_class": "starhalf",
"unicode": "\ue683"
},
{
"font_class": "trash",
"unicode": "\ue687"
},
{
"font_class": "trash-filled",
"unicode": "\ue685"
},
{
"font_class": "tune",
"unicode": "\ue6aa"
},
{
"font_class": "tune-filled",
"unicode": "\ue6ca"
},
{
"font_class": "undo",
"unicode": "\ue64f"
},
{
"font_class": "undo-filled",
"unicode": "\ue64c"
},
{
"font_class": "up",
"unicode": "\ue6b6"
},
{
"font_class": "top",
"unicode": "\ue6b6"
},
{
"font_class": "upload",
"unicode": "\ue690"
},
{
"font_class": "upload-filled",
"unicode": "\ue68e"
},
{
"font_class": "videocam",
"unicode": "\ue68c"
},
{
"font_class": "videocam-filled",
"unicode": "\ue689"
},
{
"font_class": "vip",
"unicode": "\ue6a8"
},
{
"font_class": "vip-filled",
"unicode": "\ue6c6"
},
{
"font_class": "wallet",
"unicode": "\ue6b1"
},
{
"font_class": "wallet-filled",
"unicode": "\ue6c2"
},
{
"font_class": "weibo",
"unicode": "\ue68b"
},
{
"font_class": "weixin",
"unicode": "\ue691"
}
] as IconsDataItem[]
// export const fontData = JSON.parse<IconsDataItem>(fontDataJson)
@@ -0,0 +1,649 @@
export const fontData = [
{
"font_class": "arrow-down",
"unicode": "\ue6be"
},
{
"font_class": "arrow-left",
"unicode": "\ue6bc"
},
{
"font_class": "arrow-right",
"unicode": "\ue6bb"
},
{
"font_class": "arrow-up",
"unicode": "\ue6bd"
},
{
"font_class": "auth",
"unicode": "\ue6ab"
},
{
"font_class": "auth-filled",
"unicode": "\ue6cc"
},
{
"font_class": "back",
"unicode": "\ue6b9"
},
{
"font_class": "bars",
"unicode": "\ue627"
},
{
"font_class": "calendar",
"unicode": "\ue6a0"
},
{
"font_class": "calendar-filled",
"unicode": "\ue6c0"
},
{
"font_class": "camera",
"unicode": "\ue65a"
},
{
"font_class": "camera-filled",
"unicode": "\ue658"
},
{
"font_class": "cart",
"unicode": "\ue631"
},
{
"font_class": "cart-filled",
"unicode": "\ue6d0"
},
{
"font_class": "chat",
"unicode": "\ue65d"
},
{
"font_class": "chat-filled",
"unicode": "\ue659"
},
{
"font_class": "chatboxes",
"unicode": "\ue696"
},
{
"font_class": "chatboxes-filled",
"unicode": "\ue692"
},
{
"font_class": "chatbubble",
"unicode": "\ue697"
},
{
"font_class": "chatbubble-filled",
"unicode": "\ue694"
},
{
"font_class": "checkbox",
"unicode": "\ue62b"
},
{
"font_class": "checkbox-filled",
"unicode": "\ue62c"
},
{
"font_class": "checkmarkempty",
"unicode": "\ue65c"
},
{
"font_class": "circle",
"unicode": "\ue65b"
},
{
"font_class": "circle-filled",
"unicode": "\ue65e"
},
{
"font_class": "clear",
"unicode": "\ue66d"
},
{
"font_class": "close",
"unicode": "\ue673"
},
{
"font_class": "closeempty",
"unicode": "\ue66c"
},
{
"font_class": "cloud-download",
"unicode": "\ue647"
},
{
"font_class": "cloud-download-filled",
"unicode": "\ue646"
},
{
"font_class": "cloud-upload",
"unicode": "\ue645"
},
{
"font_class": "cloud-upload-filled",
"unicode": "\ue648"
},
{
"font_class": "color",
"unicode": "\ue6cf"
},
{
"font_class": "color-filled",
"unicode": "\ue6c9"
},
{
"font_class": "compose",
"unicode": "\ue67f"
},
{
"font_class": "contact",
"unicode": "\ue693"
},
{
"font_class": "contact-filled",
"unicode": "\ue695"
},
{
"font_class": "down",
"unicode": "\ue6b8"
},
{
"font_class": "bottom",
"unicode": "\ue6b8"
},
{
"font_class": "download",
"unicode": "\ue68d"
},
{
"font_class": "download-filled",
"unicode": "\ue681"
},
{
"font_class": "email",
"unicode": "\ue69e"
},
{
"font_class": "email-filled",
"unicode": "\ue69a"
},
{
"font_class": "eye",
"unicode": "\ue651"
},
{
"font_class": "eye-filled",
"unicode": "\ue66a"
},
{
"font_class": "eye-slash",
"unicode": "\ue6b3"
},
{
"font_class": "eye-slash-filled",
"unicode": "\ue6b4"
},
{
"font_class": "fire",
"unicode": "\ue6a1"
},
{
"font_class": "fire-filled",
"unicode": "\ue6c5"
},
{
"font_class": "flag",
"unicode": "\ue65f"
},
{
"font_class": "flag-filled",
"unicode": "\ue660"
},
{
"font_class": "folder-add",
"unicode": "\ue6a9"
},
{
"font_class": "folder-add-filled",
"unicode": "\ue6c8"
},
{
"font_class": "font",
"unicode": "\ue6a3"
},
{
"font_class": "forward",
"unicode": "\ue6ba"
},
{
"font_class": "gear",
"unicode": "\ue664"
},
{
"font_class": "gear-filled",
"unicode": "\ue661"
},
{
"font_class": "gift",
"unicode": "\ue6a4"
},
{
"font_class": "gift-filled",
"unicode": "\ue6c4"
},
{
"font_class": "hand-down",
"unicode": "\ue63d"
},
{
"font_class": "hand-down-filled",
"unicode": "\ue63c"
},
{
"font_class": "hand-up",
"unicode": "\ue63f"
},
{
"font_class": "hand-up-filled",
"unicode": "\ue63e"
},
{
"font_class": "headphones",
"unicode": "\ue630"
},
{
"font_class": "heart",
"unicode": "\ue639"
},
{
"font_class": "heart-filled",
"unicode": "\ue641"
},
{
"font_class": "help",
"unicode": "\ue679"
},
{
"font_class": "help-filled",
"unicode": "\ue674"
},
{
"font_class": "home",
"unicode": "\ue662"
},
{
"font_class": "home-filled",
"unicode": "\ue663"
},
{
"font_class": "image",
"unicode": "\ue670"
},
{
"font_class": "image-filled",
"unicode": "\ue678"
},
{
"font_class": "images",
"unicode": "\ue650"
},
{
"font_class": "images-filled",
"unicode": "\ue64b"
},
{
"font_class": "info",
"unicode": "\ue669"
},
{
"font_class": "info-filled",
"unicode": "\ue649"
},
{
"font_class": "left",
"unicode": "\ue6b7"
},
{
"font_class": "link",
"unicode": "\ue6a5"
},
{
"font_class": "list",
"unicode": "\ue644"
},
{
"font_class": "location",
"unicode": "\ue6ae"
},
{
"font_class": "location-filled",
"unicode": "\ue6af"
},
{
"font_class": "locked",
"unicode": "\ue66b"
},
{
"font_class": "locked-filled",
"unicode": "\ue668"
},
{
"font_class": "loop",
"unicode": "\ue633"
},
{
"font_class": "mail-open",
"unicode": "\ue643"
},
{
"font_class": "mail-open-filled",
"unicode": "\ue63a"
},
{
"font_class": "map",
"unicode": "\ue667"
},
{
"font_class": "map-filled",
"unicode": "\ue666"
},
{
"font_class": "map-pin",
"unicode": "\ue6ad"
},
{
"font_class": "map-pin-ellipse",
"unicode": "\ue6ac"
},
{
"font_class": "medal",
"unicode": "\ue6a2"
},
{
"font_class": "medal-filled",
"unicode": "\ue6c3"
},
{
"font_class": "mic",
"unicode": "\ue671"
},
{
"font_class": "mic-filled",
"unicode": "\ue677"
},
{
"font_class": "micoff",
"unicode": "\ue67e"
},
{
"font_class": "micoff-filled",
"unicode": "\ue6b0"
},
{
"font_class": "minus",
"unicode": "\ue66f"
},
{
"font_class": "minus-filled",
"unicode": "\ue67d"
},
{
"font_class": "more",
"unicode": "\ue64d"
},
{
"font_class": "more-filled",
"unicode": "\ue64e"
},
{
"font_class": "navigate",
"unicode": "\ue66e"
},
{
"font_class": "navigate-filled",
"unicode": "\ue67a"
},
{
"font_class": "notification",
"unicode": "\ue6a6"
},
{
"font_class": "notification-filled",
"unicode": "\ue6c1"
},
{
"font_class": "paperclip",
"unicode": "\ue652"
},
{
"font_class": "paperplane",
"unicode": "\ue672"
},
{
"font_class": "paperplane-filled",
"unicode": "\ue675"
},
{
"font_class": "person",
"unicode": "\ue699"
},
{
"font_class": "person-filled",
"unicode": "\ue69d"
},
{
"font_class": "personadd",
"unicode": "\ue69f"
},
{
"font_class": "personadd-filled",
"unicode": "\ue698"
},
{
"font_class": "personadd-filled-copy",
"unicode": "\ue6d1"
},
{
"font_class": "phone",
"unicode": "\ue69c"
},
{
"font_class": "phone-filled",
"unicode": "\ue69b"
},
{
"font_class": "plus",
"unicode": "\ue676"
},
{
"font_class": "plus-filled",
"unicode": "\ue6c7"
},
{
"font_class": "plusempty",
"unicode": "\ue67b"
},
{
"font_class": "pulldown",
"unicode": "\ue632"
},
{
"font_class": "pyq",
"unicode": "\ue682"
},
{
"font_class": "qq",
"unicode": "\ue680"
},
{
"font_class": "redo",
"unicode": "\ue64a"
},
{
"font_class": "redo-filled",
"unicode": "\ue655"
},
{
"font_class": "refresh",
"unicode": "\ue657"
},
{
"font_class": "refresh-filled",
"unicode": "\ue656"
},
{
"font_class": "refreshempty",
"unicode": "\ue6bf"
},
{
"font_class": "reload",
"unicode": "\ue6b2"
},
{
"font_class": "right",
"unicode": "\ue6b5"
},
{
"font_class": "scan",
"unicode": "\ue62a"
},
{
"font_class": "search",
"unicode": "\ue654"
},
{
"font_class": "settings",
"unicode": "\ue653"
},
{
"font_class": "settings-filled",
"unicode": "\ue6ce"
},
{
"font_class": "shop",
"unicode": "\ue62f"
},
{
"font_class": "shop-filled",
"unicode": "\ue6cd"
},
{
"font_class": "smallcircle",
"unicode": "\ue67c"
},
{
"font_class": "smallcircle-filled",
"unicode": "\ue665"
},
{
"font_class": "sound",
"unicode": "\ue684"
},
{
"font_class": "sound-filled",
"unicode": "\ue686"
},
{
"font_class": "spinner-cycle",
"unicode": "\ue68a"
},
{
"font_class": "staff",
"unicode": "\ue6a7"
},
{
"font_class": "staff-filled",
"unicode": "\ue6cb"
},
{
"font_class": "star",
"unicode": "\ue688"
},
{
"font_class": "star-filled",
"unicode": "\ue68f"
},
{
"font_class": "starhalf",
"unicode": "\ue683"
},
{
"font_class": "trash",
"unicode": "\ue687"
},
{
"font_class": "trash-filled",
"unicode": "\ue685"
},
{
"font_class": "tune",
"unicode": "\ue6aa"
},
{
"font_class": "tune-filled",
"unicode": "\ue6ca"
},
{
"font_class": "undo",
"unicode": "\ue64f"
},
{
"font_class": "undo-filled",
"unicode": "\ue64c"
},
{
"font_class": "up",
"unicode": "\ue6b6"
},
{
"font_class": "top",
"unicode": "\ue6b6"
},
{
"font_class": "upload",
"unicode": "\ue690"
},
{
"font_class": "upload-filled",
"unicode": "\ue68e"
},
{
"font_class": "videocam",
"unicode": "\ue68c"
},
{
"font_class": "videocam-filled",
"unicode": "\ue689"
},
{
"font_class": "vip",
"unicode": "\ue6a8"
},
{
"font_class": "vip-filled",
"unicode": "\ue6c6"
},
{
"font_class": "wallet",
"unicode": "\ue6b1"
},
{
"font_class": "wallet-filled",
"unicode": "\ue6c2"
},
{
"font_class": "weibo",
"unicode": "\ue68b"
},
{
"font_class": "weixin",
"unicode": "\ue691"
}
]
// export const fontData = JSON.parse<IconsDataItem>(fontDataJson)
@@ -0,0 +1,111 @@
{
"id": "uni-icons",
"displayName": "uni-icons 图标",
"version": "2.0.12",
"description": "图标组件,用于展示移动端常见的图标,可自定义颜色、大小。",
"keywords": [
"uni-ui",
"uniui",
"icon",
"图标"
],
"repository": "https://github.com/dcloudio/uni-ui",
"engines": {
"HBuilderX": "^3.2.14",
"uni-app": "^4.08",
"uni-app-x": "^4.61"
},
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "https://www.npmjs.com/package/@dcloudio/uni-ui",
"type": "component-vue",
"darkmode": "x",
"i18n": "x",
"widescreen": "x"
},
"uni_modules": {
"dependencies": [
"uni-scss"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app": {
"vue": {
"vue2": "√",
"vue3": "√"
},
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"vue": "√",
"nvue": "-",
"android": {
"extVersion": "",
"minVersion": "29"
},
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√",
"alipay": "√",
"toutiao": "√",
"baidu": "√",
"kuaishou": "-",
"jd": "-",
"harmony": "-",
"qq": "√",
"lark": "-"
},
"quickapp": {
"huawei": "√",
"union": "√"
}
},
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": {
"extVersion": "",
"minVersion": "29"
},
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
}
}
}
}
@@ -0,0 +1,8 @@
## Icons 图标
> **组件名:uni-icons**
> 代码块: `uIcons`
用于展示 icons 图标 。
### [查看文档](https://uniapp.dcloud.io/component/uniui/uni-icons)
#### 如使用过程中有任何问题,或者您对uni-ui有一些好的建议,欢迎加入 uni-ui 交流群:871950839
@@ -0,0 +1,8 @@
## 1.0.32022-01-21
- 优化 组件示例
## 1.0.22021-11-22
- 修复 / 符号在 vue 不同版本兼容问题引起的报错问题
## 1.0.12021-11-22
- 修复 vue3中scss语法兼容问题
## 1.0.02021-11-18
- init
@@ -0,0 +1 @@
@import './styles/index.scss';
@@ -0,0 +1,82 @@
{
"id": "uni-scss",
"displayName": "uni-scss 辅助样式",
"version": "1.0.3",
"description": "uni-sass是uni-ui提供的一套全局样式 ,通过一些简单的类名和sass变量,实现简单的页面布局操作,比如颜色、边距、圆角等。",
"keywords": [
"uni-scss",
"uni-ui",
"辅助样式"
],
"repository": "https://github.com/dcloudio/uni-ui",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"category": [
"JS SDK",
"通用 SDK"
],
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "https://www.npmjs.com/package/@dcloudio/uni-ui"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"App": {
"app-vue": "y",
"app-nvue": "u"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y"
},
"快应用": {
"华为": "n",
"联盟": "n"
},
"Vue": {
"vue2": "y",
"vue3": "y"
}
}
}
}
}
@@ -0,0 +1,4 @@
`uni-sass``uni-ui`提供的一套全局样式 ,通过一些简单的类名和`sass`变量,实现简单的页面布局操作,比如颜色、边距、圆角等。
### [查看文档](https://uniapp.dcloud.io/component/uniui/uni-sass)
#### 如使用过程中有任何问题,或者您对uni-ui有一些好的建议,欢迎加入 uni-ui 交流群:871950839
@@ -0,0 +1,7 @@
@import './setting/_variables.scss';
@import './setting/_border.scss';
@import './setting/_color.scss';
@import './setting/_space.scss';
@import './setting/_radius.scss';
@import './setting/_text.scss';
@import './setting/_styles.scss';
@@ -0,0 +1,3 @@
.uni-border {
border: 1px $uni-border-1 solid;
}
@@ -0,0 +1,66 @@
// TODO 暂时不需要 class ,需要用户使用变量实现 ,如果使用类名其实并不推荐
// @mixin get-styles($k,$c) {
// @if $k == size or $k == weight{
// font-#{$k}:#{$c}
// }@else{
// #{$k}:#{$c}
// }
// }
$uni-ui-color:(
// 主色
primary: $uni-primary,
primary-disable: $uni-primary-disable,
primary-light: $uni-primary-light,
// 辅助色
success: $uni-success,
success-disable: $uni-success-disable,
success-light: $uni-success-light,
warning: $uni-warning,
warning-disable: $uni-warning-disable,
warning-light: $uni-warning-light,
error: $uni-error,
error-disable: $uni-error-disable,
error-light: $uni-error-light,
info: $uni-info,
info-disable: $uni-info-disable,
info-light: $uni-info-light,
// 中性色
main-color: $uni-main-color,
base-color: $uni-base-color,
secondary-color: $uni-secondary-color,
extra-color: $uni-extra-color,
// 背景色
bg-color: $uni-bg-color,
// 边框颜色
border-1: $uni-border-1,
border-2: $uni-border-2,
border-3: $uni-border-3,
border-4: $uni-border-4,
// 黑色
black:$uni-black,
// 白色
white:$uni-white,
// 透明
transparent:$uni-transparent
) !default;
@each $key, $child in $uni-ui-color {
.uni-#{"" + $key} {
color: $child;
}
.uni-#{"" + $key}-bg {
background-color: $child;
}
}
.uni-shadow-sm {
box-shadow: $uni-shadow-sm;
}
.uni-shadow-base {
box-shadow: $uni-shadow-base;
}
.uni-shadow-lg {
box-shadow: $uni-shadow-lg;
}
.uni-mask {
background-color:$uni-mask;
}

Some files were not shown because too many files have changed in this diff Show More