feat(circle): 添加酒友圈社交模块

- 新增酒友圈、圈子详情、发布动态、好友列表、活动创建等页面配置
- 添加酒友圈相关API接口,包括动态、评论、好友、活动等功能
- 实现模拟数据用于前端开发和测试
- 创建评论项、空状态、活动卡片、动态卡片、好友项等组件
- 编写酒友圈模块详细的接口文档,定义数据结构和业务规则
This commit is contained in:
cg
2026-07-20 22:53:04 +08:00
parent 9f5c69e1e5
commit fabbb167cf
18 changed files with 3992 additions and 18 deletions
+104
View File
@@ -154,3 +154,107 @@ export async function refreshAuthToken() {
return false
}
}
// ==========================================
// 酒友圈 API(当前返回 Mock 数据,后端就绪后切换为 SDK 调用)
// ==========================================
import { MOCK_FEEDS, MOCK_COMMENTS, MOCK_FRIENDS, MOCK_FRIEND_REQUESTS, MOCK_EVENTS } from './mock-data'
/** 模拟网络延迟 */
function mockDelay(data, ms = 300) {
return new Promise(resolve => setTimeout(() => resolve({ data }), ms))
}
/** 获取动态信息流 */
export function GetCircleFeeds({ page = 1, pageSize = 10 } = {}) {
// TODO: 后端就绪后替换为 client.GetCircleFeeds(...)
const start = (page - 1) * pageSize
const list = MOCK_FEEDS.slice(start, start + pageSize)
return mockDelay({ list, total: MOCK_FEEDS.length, hasMore: start + pageSize < MOCK_FEEDS.length })
}
/** 点赞动态 */
export function LikeFeed({ feedId }) {
return mockDelay({ success: true })
}
/** 取消点赞 */
export function UnlikeFeed({ feedId }) {
return mockDelay({ success: true })
}
/** 获取动态评论 */
export function GetFeedComments({ feedId }) {
const list = MOCK_COMMENTS[feedId] || []
return mockDelay({ list })
}
/** 发表评论 */
export function AddComment({ feedId, content }) {
return mockDelay({ success: true, comment: { id: 'c_' + Date.now(), content, time: '刚刚' } })
}
/** 发布动态 */
export function PublishFeed({ text, images, recordId, visibility }) {
return mockDelay({ success: true, feedId: 'feed_' + Date.now() })
}
/** 获取酒友列表 */
export function GetFriends({ keyword = '' } = {}) {
const list = keyword
? MOCK_FRIENDS.filter(f => f.nickname.includes(keyword))
: MOCK_FRIENDS
return mockDelay({ list })
}
/** 获取好友请求 */
export function GetFriendRequests() {
return mockDelay({ list: MOCK_FRIEND_REQUESTS })
}
/** 发送好友请求 */
export function SendFriendRequest({ userId }) {
return mockDelay({ success: true })
}
/** 接受好友请求 */
export function AcceptFriendRequest({ requestId }) {
return mockDelay({ success: true })
}
/** 删除酒友 */
export function RemoveFriend({ userId }) {
return mockDelay({ success: true })
}
/** 获取酒局列表 */
export function GetEvents({ status = '' } = {}) {
const list = status ? MOCK_EVENTS.filter(e => e.status === status) : MOCK_EVENTS
return mockDelay({ list })
}
/** 获取酒局详情 */
export function GetEventDetail({ eventId }) {
const evt = MOCK_EVENTS.find(e => e.id === eventId) || MOCK_EVENTS[0]
return mockDelay(evt)
}
/** 发起酒局 */
export function CreateEvent({ title, time, location, maxPeople, note }) {
return mockDelay({ success: true, eventId: 'evt_' + Date.now() })
}
/** 报名酒局 */
export function JoinEvent({ eventId }) {
return mockDelay({ success: true })
}
/** 取消报名 */
export function QuitEvent({ eventId }) {
return mockDelay({ success: true })
}
/** 酒局签到 */
export function CheckInEvent({ eventId }) {
return mockDelay({ success: true })
}
+181
View File
@@ -148,3 +148,184 @@ export function getUserInfo() {
}
return MOCK_USER
}
// ==========================================
// 酒友圈 Mock 数据
// ==========================================
// 模拟酒友列表
export const MOCK_FRIENDS = [
{ id: 'f001', nickname: '老张', avatar: '', lastDrink: '昨晚喝了飞天茅台', lastDrinkTime: '2小时前', online: true },
{ id: 'f002', nickname: '酒仙李白', avatar: '', lastDrink: '青岛啤酒×3', lastDrinkTime: '5小时前', online: true },
{ id: 'f003', nickname: '小红', avatar: '', lastDrink: '梅见青梅酒', lastDrinkTime: '昨天', online: false },
{ id: 'f004', nickname: '阿伟', avatar: '', lastDrink: '百威经典×2', lastDrinkTime: '昨天', online: false },
{ id: 'f005', nickname: '品酒师Tony', avatar: '', lastDrink: '麦卡伦12年', lastDrinkTime: '3天前', online: true },
{ id: 'f006', nickname: '醉翁之意', avatar: '', lastDrink: '奔富Bin389', lastDrinkTime: '3天前', online: false }
]
// 模拟好友请求
export const MOCK_FRIEND_REQUESTS = [
{ id: 'req001', userId: 'u101', nickname: '夜猫子', avatar: '', message: '一起喝过酒,加个好友', time: '1小时前' },
{ id: 'req002', userId: 'u102', nickname: '杯中物', avatar: '', message: '酒友圈看到你的动态', time: '3小时前' }
]
// 模拟动态信息流
export const MOCK_FEEDS = [
{
id: 'feed001',
userId: 'f001',
nickname: '老张',
avatar: '',
time: '2小时前',
text: '今晚和几个老兄弟整了瓶飞天,配重庆火锅,巴适得很!',
drinks: [{ category: 'baijiu', name: '飞天茅台53度', amount: '100ml' }],
feeling: 'tipsy',
images: [],
likes: 12,
liked: false,
comments: 3
},
{
id: 'feed002',
userId: 'f002',
nickname: '酒仙李白',
avatar: '',
time: '5小时前',
text: '夏天就是要冰啤酒配小龙虾,三瓶百威下肚,人生圆满。',
drinks: [{ category: 'beer', name: '百威经典', amount: '3瓶' }],
feeling: 'buzzed',
images: [],
likes: 8,
liked: true,
comments: 5
},
{
id: 'feed003',
userId: 'f003',
nickname: '小红',
avatar: '',
time: '昨天 21:30',
text: '第一次尝试青梅酒,酸酸甜甜的,适合女生微醺~',
drinks: [{ category: 'fruit', name: '梅见青梅酒', amount: '200ml' }],
feeling: 'tipsy',
images: [],
likes: 15,
liked: false,
comments: 7
},
{
id: 'feed004',
userId: 'f005',
nickname: '品酒师Tony',
avatar: '',
time: '昨天 19:00',
text: '麦卡伦12年雪莉桶,闻香有太妃糖和干果的气息,入口丝滑。推荐搭配黑巧克力。',
drinks: [{ category: 'whisky', name: '麦卡伦12年', amount: '3 shot' }],
feeling: 'tipsy',
images: [],
likes: 22,
liked: false,
comments: 4
},
{
id: 'feed005',
userId: 'f004',
nickname: '阿伟',
avatar: '',
time: '前天 20:15',
text: '公司团建,五粮液配水煮鱼,喝到位了。明天又要搬砖...',
drinks: [{ category: 'baijiu', name: '五粮液普五52度', amount: '150ml' }, { category: 'beer', name: '雪花勇闯天涯', amount: '1瓶' }],
feeling: 'drunk',
images: [],
likes: 6,
liked: false,
comments: 2
}
]
// 模拟评论数据
export const MOCK_COMMENTS = {
feed001: [
{ id: 'c001', userId: 'f002', nickname: '酒仙李白', avatar: '', content: '茅台配火锅,土豪啊老张!', time: '1小时前' },
{ id: 'c002', userId: 'f003', nickname: '小红', avatar: '', content: '看着就馋了,下次带上我', time: '45分钟前' },
{ id: 'c003', userId: 'f005', nickname: '品酒师Tony', avatar: '', content: '飞天53度确实是经典,好酒!', time: '30分钟前' }
],
feed002: [
{ id: 'c004', userId: 'f001', nickname: '老张', avatar: '', content: '夏天标配!', time: '4小时前' },
{ id: 'c005', userId: 'f004', nickname: '阿伟', avatar: '', content: '小龙虾哪买的?看着不错', time: '3小时前' }
],
feed003: [
{ id: 'c006', userId: 'f001', nickname: '老张', avatar: '', content: '梅见确实适合入门', time: '昨天' },
{ id: 'c007', userId: 'f002', nickname: '酒仙李白', avatar: '', content: '下次试试蜜桃味', time: '昨天' }
]
}
// 模拟酒局活动
export const MOCK_EVENTS = [
{
id: 'evt001',
title: '周五夜·老地方火锅局',
organizer: { id: 'f001', nickname: '老张', avatar: '' },
time: '2026-07-25 19:00',
location: '渝味晓宇火锅(解放碑店)',
maxPeople: 8,
joined: 5,
participants: [
{ id: 'f001', nickname: '老张', avatar: '' },
{ id: 'f002', nickname: '酒仙李白', avatar: '' },
{ id: 'f003', nickname: '小红', avatar: '' },
{ id: 'f004', nickname: '阿伟', avatar: '' },
{ id: 'f005', nickname: '品酒师Tony', avatar: '' }
],
status: 'open',
note: '自带酒水,AA制餐费',
isJoined: false,
isOrganizer: false
},
{
id: 'evt002',
title: '威士忌品鉴之夜',
organizer: { id: 'f005', nickname: '品酒师Tony', avatar: '' },
time: '2026-07-27 20:00',
location: 'Malt Bar(国贸店)',
maxPeople: 6,
joined: 3,
participants: [
{ id: 'f005', nickname: '品酒师Tony', avatar: '' },
{ id: 'f001', nickname: '老张', avatar: '' },
{ id: 'f006', nickname: '醉翁之意', avatar: '' }
],
status: 'open',
note: '品鉴3款单一麦芽,费用AA',
isJoined: true,
isOrganizer: false
},
{
id: 'evt003',
title: '啤酒花园烧烤趴',
organizer: { id: 'f002', nickname: '酒仙李白', avatar: '' },
time: '2026-07-20 18:00',
location: '望京啤酒花园',
maxPeople: 12,
joined: 10,
participants: [],
status: 'ongoing',
note: '畅饮模式,不醉不归',
isJoined: false,
isOrganizer: false
},
{
id: 'evt004',
title: '红酒配牛排·优雅之夜',
organizer: { id: 'f003', nickname: '小红', avatar: '' },
time: '2026-07-15 19:30',
location: 'The Grill(三里屯)',
maxPeople: 4,
joined: 4,
participants: [],
status: 'ended',
note: '已圆满结束,下次再约',
isJoined: false,
isOrganizer: false
}
]
+90
View File
@@ -0,0 +1,90 @@
<template>
<view class="comment-item">
<view class="comment-avatar-wrap">
<image v-if="comment.avatar" class="comment-avatar" :src="comment.avatar" mode="aspectFill"></image>
<view v-else class="comment-avatar comment-avatar-ph">
<text class="comment-avatar-text">{{ comment.nickname ? comment.nickname[0] : '酒' }}</text>
</view>
</view>
<view class="comment-body">
<view class="comment-top">
<text class="comment-name">{{ comment.nickname }}</text>
<text class="comment-time">{{ comment.time }}</text>
</view>
<text class="comment-content">{{ comment.content }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'CommentItem',
props: {
comment: { type: Object, default: () => ({}) }
}
}
</script>
<style lang="scss" scoped>
.comment-item {
display: flex;
gap: $sp-md;
padding: $sp-lg 0;
&:not(:last-child) {
border-bottom: 1rpx solid var(--divider-color, rgba(255,255,255,0.08));
}
}
.comment-avatar-wrap {
flex-shrink: 0;
}
.comment-avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
}
.comment-avatar-ph {
background: $bg-elevated;
display: flex;
align-items: center;
justify-content: center;
}
.comment-avatar-text {
font-size: $fs-sm;
font-weight: $fw-bold;
color: $lavender;
}
.comment-body {
flex: 1;
min-width: 0;
}
.comment-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
}
.comment-name {
font-size: $fs-sm;
font-weight: $fw-bold;
color: $text-secondary;
}
.comment-time {
font-size: $fs-xs;
color: $text-tertiary;
}
.comment-content {
font-size: $fs-base;
color: $text-primary;
line-height: $lh-normal;
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<template>
<view class="empty-state">
<text class="empty-icon">{{ icon }}</text>
<text class="empty-title">{{ title }}</text>
<text class="empty-desc" v-if="desc">{{ desc }}</text>
<button v-if="actionText" class="empty-action" @click="$emit('action')">{{ actionText }}</button>
</view>
</template>
<script>
export default {
name: 'EmptyState',
props: {
icon: { type: String, default: '🍻' },
title: { type: String, default: '暂无内容' },
desc: { type: String, default: '' },
actionText: { type: String, default: '' }
}
}
</script>
<style lang="scss" scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx $sp-xl;
}
.empty-icon {
font-size: 96rpx;
margin-bottom: $sp-lg;
opacity: 0.6;
}
.empty-title {
font-size: $fs-base;
font-weight: $fw-medium;
color: $text-secondary;
margin-bottom: $sp-sm;
}
.empty-desc {
font-size: $fs-sm;
color: $text-tertiary;
text-align: center;
line-height: $lh-normal;
}
.empty-action {
margin-top: $sp-xl;
padding: $sp-md $sp-2xl;
background: linear-gradient(135deg, $amber, $amber-deep);
color: $text-on-amber;
font-size: $fs-base;
font-weight: $fw-bold;
border-radius: $radius-full;
border: none;
&::after {
border: none;
}
&:active {
opacity: 0.85;
transform: scale(0.96);
}
}
</style>
+179
View File
@@ -0,0 +1,179 @@
<template>
<view class="event-card" @click="$emit('tap', event)">
<!-- 状态标签 -->
<view class="event-status" :class="'status-' + event.status">
<text class="event-status-text">{{ statusLabel }}</text>
</view>
<!-- 标题 -->
<text class="event-title">{{ event.title }}</text>
<!-- 信息行 -->
<view class="event-info">
<view class="event-info-row">
<text class="event-info-icon">🕐</text>
<text class="event-info-text">{{ event.time }}</text>
</view>
<view class="event-info-row">
<text class="event-info-icon">📍</text>
<text class="event-info-text">{{ event.location }}</text>
</view>
<view class="event-info-row">
<text class="event-info-icon">👥</text>
<text class="event-info-text">{{ event.joined }}/{{ event.maxPeople }} 人已报名</text>
</view>
</view>
<!-- 底部发起人 + 操作 -->
<view class="event-footer">
<view class="event-organizer">
<view class="event-org-avatar">
<text class="event-org-text">{{ event.organizer ? event.organizer.nickname[0] : '酒' }}</text>
</view>
<text class="event-org-name">{{ event.organizer ? event.organizer.nickname : '' }}</text>
</view>
<view class="event-action" v-if="event.status === 'open'">
<text class="event-action-text" v-if="event.isJoined"> 已报名</text>
<text class="event-action-text event-action-join" v-else>报名</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'EventCard',
props: {
event: { type: Object, default: () => ({}) }
},
computed: {
statusLabel() {
const map = { open: '报名中', ongoing: '进行中', ended: '已结束' }
return map[this.event.status] || '报名中'
}
}
}
</script>
<style lang="scss" scoped>
.event-card {
background: $bg-card;
border-radius: $radius-xl;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
padding: $sp-xl;
margin-bottom: $sp-md;
position: relative;
overflow: hidden;
&:active {
background: $bg-card-alt;
}
}
.event-status {
display: inline-flex;
padding: 6rpx 16rpx;
border-radius: $radius-full;
margin-bottom: $sp-md;
&.status-open {
background: rgba(94,198,160,0.12);
}
&.status-ongoing {
background: rgba(232,168,56,0.12);
}
&.status-ended {
background: $bg-elevated;
}
}
.event-status-text {
font-size: $fs-xs;
font-weight: $fw-medium;
.status-open & { color: $mint; }
.status-ongoing & { color: $amber; }
.status-ended & { color: $text-tertiary; }
}
.event-title {
display: block;
font-size: $fs-lg;
font-weight: $fw-bold;
color: $text-primary;
margin-bottom: $sp-lg;
}
.event-info {
display: flex;
flex-direction: column;
gap: $sp-sm;
margin-bottom: $sp-lg;
}
.event-info-row {
display: flex;
align-items: center;
gap: $sp-sm;
}
.event-info-icon {
font-size: $fs-sm;
width: 36rpx;
text-align: center;
}
.event-info-text {
font-size: $fs-sm;
color: $text-secondary;
}
.event-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: $sp-md;
border-top: 1rpx solid var(--divider-color, rgba(255,255,255,0.08));
}
.event-organizer {
display: flex;
align-items: center;
gap: $sp-sm;
}
.event-org-avatar {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: $bg-elevated;
display: flex;
align-items: center;
justify-content: center;
}
.event-org-text {
font-size: $fs-xs;
font-weight: $fw-bold;
color: $lavender;
}
.event-org-name {
font-size: $fs-sm;
color: $text-tertiary;
}
.event-action-text {
font-size: $fs-sm;
color: $text-tertiary;
padding: 8rpx 24rpx;
border-radius: $radius-full;
background: $bg-elevated;
}
.event-action-join {
color: $text-on-amber;
background: linear-gradient(135deg, $amber, $amber-deep);
font-weight: $fw-bold;
}
</style>
+239
View File
@@ -0,0 +1,239 @@
<template>
<view class="feed-card" @click="$emit('tap', feed)">
<!-- 头部头像+昵称+时间 -->
<view class="feed-header">
<view class="feed-avatar-wrap">
<image v-if="feed.avatar" class="feed-avatar" :src="feed.avatar" mode="aspectFill"></image>
<view v-else class="feed-avatar feed-avatar-ph">
<text class="feed-avatar-text">{{ feed.nickname ? feed.nickname[0] : '酒' }}</text>
</view>
</view>
<view class="feed-meta">
<text class="feed-name">{{ feed.nickname }}</text>
<text class="feed-time">{{ feed.time }}</text>
</view>
</view>
<!-- 正文 -->
<text class="feed-text">{{ feed.text }}</text>
<!-- 饮酒标签 -->
<view class="feed-drinks" v-if="feed.drinks && feed.drinks.length">
<view class="feed-drink-tag" v-for="(d, i) in feed.drinks" :key="i">
<text class="feed-drink-emoji">{{ categoryEmoji(d.category) }}</text>
<text class="feed-drink-name">{{ d.name }} {{ d.amount }}</text>
</view>
</view>
<!-- 感受标签 -->
<view class="feed-feeling" v-if="feed.feeling">
<text class="feed-feeling-text">{{ feelingLabel(feed.feeling) }}</text>
</view>
<!-- 图片 -->
<view class="feed-images" v-if="feed.images && feed.images.length">
<image
v-for="(img, i) in feed.images"
:key="i"
class="feed-img"
:class="'feed-img-' + Math.min(feed.images.length, 3)"
:src="img"
mode="aspectFill"
></image>
</view>
<!-- 操作栏 -->
<view class="feed-actions">
<view class="feed-action" :class="{ active: feed.liked }" @click.stop="$emit('like', feed)">
<text class="feed-action-icon">{{ feed.liked ? '❤️' : '🤍' }}</text>
<text class="feed-action-num">{{ feed.likes || '' }}</text>
</view>
<view class="feed-action" @click.stop="$emit('comment', feed)">
<text class="feed-action-icon">💬</text>
<text class="feed-action-num">{{ feed.comments || '' }}</text>
</view>
<view class="feed-action" @click.stop="$emit('share', feed)">
<text class="feed-action-icon"></text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'FeedCard',
props: {
feed: { type: Object, default: () => ({}) }
},
methods: {
categoryEmoji(cat) {
const map = { baijiu: '🏺', beer: '🍺', wine: '🍷', whisky: '🥃', huangjiu: '🫖', sake: '🍶', fruit: '🍹', cocktail: '🍸' }
return map[cat] || '🍻'
},
feelingLabel(f) {
const map = { sober: '😌 清醒', buzzed: '😊 微醺', tipsy: '😄 小醉', drunk: '🥴 喝多了' }
return map[f] || '🍻 喝了点'
}
}
}
</script>
<style lang="scss" scoped>
.feed-card {
background: $bg-card;
border-radius: $radius-xl;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
padding: $sp-xl;
margin-bottom: $sp-md;
}
.feed-header {
display: flex;
align-items: center;
gap: $sp-md;
margin-bottom: $sp-lg;
}
.feed-avatar-wrap {
flex-shrink: 0;
}
.feed-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
}
.feed-avatar-ph {
background: linear-gradient(135deg, rgba(232,168,56,0.2), rgba(139,133,184,0.2));
display: flex;
align-items: center;
justify-content: center;
}
.feed-avatar-text {
font-size: $fs-lg;
font-weight: $fw-bold;
color: $amber;
}
.feed-meta {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.feed-name {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.feed-time {
font-size: $fs-xs;
color: $text-tertiary;
}
.feed-text {
display: block;
font-size: $fs-base;
color: $text-primary;
line-height: $lh-loose;
margin-bottom: $sp-md;
}
.feed-drinks {
display: flex;
flex-wrap: wrap;
gap: $sp-sm;
margin-bottom: $sp-md;
}
.feed-drink-tag {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 20rpx;
background: $bg-elevated;
border-radius: $radius-full;
border: 1rpx solid var(--border-micro, rgba(255,255,255,0.05));
}
.feed-drink-emoji {
font-size: $fs-sm;
}
.feed-drink-name {
font-size: $fs-xs;
color: $text-secondary;
}
.feed-feeling {
margin-bottom: $sp-md;
}
.feed-feeling-text {
font-size: $fs-xs;
color: $text-tertiary;
padding: 6rpx 16rpx;
background: rgba(232,168,56,0.06);
border-radius: $radius-full;
}
.feed-images {
display: flex;
flex-wrap: wrap;
gap: $sp-sm;
margin-bottom: $sp-md;
}
.feed-img {
border-radius: $radius-md;
height: 200rpx;
}
.feed-img-1 {
width: 100%;
height: 320rpx;
}
.feed-img-2 {
width: calc(50% - 8rpx);
}
.feed-img-3 {
width: calc(33.33% - 11rpx);
}
.feed-actions {
display: flex;
align-items: center;
gap: $sp-xl;
padding-top: $sp-md;
border-top: 1rpx solid var(--divider-color, rgba(255,255,255,0.08));
}
.feed-action {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 0;
&.active .feed-action-num {
color: $coral;
}
&:active {
opacity: 0.7;
}
}
.feed-action-icon {
font-size: $fs-base;
}
.feed-action-num {
font-size: $fs-sm;
color: $text-tertiary;
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<template>
<view class="friend-item" @click="$emit('tap', friend)">
<view class="friend-avatar-wrap">
<image v-if="friend.avatar" class="friend-avatar" :src="friend.avatar" mode="aspectFill"></image>
<view v-else class="friend-avatar friend-avatar-ph">
<text class="friend-avatar-text">{{ friend.nickname ? friend.nickname[0] : '酒' }}</text>
</view>
<view v-if="friend.online" class="friend-online"></view>
</view>
<view class="friend-info">
<text class="friend-name">{{ friend.nickname }}</text>
<text class="friend-last">{{ friend.lastDrink }} · {{ friend.lastDrinkTime }}</text>
</view>
<slot name="action"></slot>
</view>
</template>
<script>
export default {
name: 'FriendItem',
props: {
friend: { type: Object, default: () => ({}) }
}
}
</script>
<style lang="scss" scoped>
.friend-item {
display: flex;
align-items: center;
gap: $sp-md;
padding: $sp-lg;
background: $bg-card;
border-radius: $radius-lg;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
margin-bottom: $sp-sm;
&:active {
background: $bg-card-alt;
}
}
.friend-avatar-wrap {
position: relative;
flex-shrink: 0;
}
.friend-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
}
.friend-avatar-ph {
background: linear-gradient(135deg, rgba(232,168,56,0.15), rgba(139,133,184,0.15));
display: flex;
align-items: center;
justify-content: center;
}
.friend-avatar-text {
font-size: $fs-lg;
font-weight: $fw-bold;
color: $amber;
}
.friend-online {
position: absolute;
bottom: 4rpx;
right: 4rpx;
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: $mint;
border: 3rpx solid $bg-card;
}
.friend-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.friend-name {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.friend-last {
font-size: $fs-xs;
color: $text-tertiary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+675
View File
@@ -0,0 +1,675 @@
# 干杯日记 - 酒友圈模块接口文档
> 版本: v1.0
> 日期: 2026-07-20
> 模块: 酒友圈(社交模块)
> 小程序: 干杯日记(uni-app 微信小程序)
> 基础URL: `https://your-domain.com/api/v1`
---
## 1. 模块概述
酒友圈是干杯日记的核心社交模块,包含三大子功能:
| 子功能 | 说明 |
|--------|------|
| 动态信息流 | 酒友发布饮酒动态,支持点赞、评论 |
| 酒友管理 | 好友请求、酒友列表、搜索、删除 |
| 约酒活动 | 发起酒局、报名、取消、签到 |
**前端当前状态:** 所有接口已在前端 `common/api.js` 中预留调用方法,暂用 Mock 数据。后端实现后前端仅需切换调用方式,无需改动页面逻辑。**请严格按照本文档的字段名和数据结构实现**,确保前端零改动对接。
---
## 2. 通用约定
### 2.1 鉴权
- 请求头:`Authorization: Bearer {token}`
- 所有接口均需登录态
### 2.2 统一响应格式
```json
{
"code": 0,
"message": "success",
"data": {}
}
```
### 2.3 错误码
| code | 含义 |
|------|------|
| 0 | 成功 |
| 400 | 参数错误 |
| 401 | 未登录/token过期 |
| 403 | 无权限(如:非酒友关系、非发起人) |
| 404 | 资源不存在 |
| 409 | 冲突(如:重复报名、已是酒友) |
| 500 | 服务器错误 |
### 2.4 时间格式
- 所有时间字段使用 ISO 8601`2026-07-20T22:30:00.000Z`
- 前端会自行转换为相对时间("2小时前"等)
---
## 3. 动态信息流
### 3.1 获取动态列表
```
GET /circle/feeds
```
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | int | 否 | 页码,默认1 |
| pageSize | int | 否 | 每页数量,默认10 |
**响应 data**
```json
{
"list": [
{
"id": "feed001",
"userId": "user_002",
"nickname": "老张",
"avatar": "https://xxx/avatar.jpg",
"time": "2026-07-20T20:30:00.000Z",
"text": "今晚和几个老兄弟整了瓶飞天,配重庆火锅,巴适得很!",
"drinks": [
{ "category": "baijiu", "name": "飞天茅台53度", "amount": "100ml" }
],
"feeling": "tipsy",
"images": ["https://xxx/img1.jpg"],
"likes": 12,
"liked": false,
"comments": 3
}
],
"total": 50,
"hasMore": true
}
```
**Feed 字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 动态ID |
| userId | string | 发布者用户ID |
| nickname | string | 发布者昵称 |
| avatar | string | 发布者头像URL(可为空字符串) |
| time | string | 发布时间 ISO 8601 |
| text | string | 动态文字内容 |
| drinks | DrinkTag[] | 关联酒水标签(见下方结构) |
| feeling | string/null | 感受IDsober/buzzed/tipsy/drunk |
| images | string[] | 配图URL数组(最多9张) |
| likes | int | 点赞数 |
| liked | boolean | 当前用户是否已点赞 |
| comments | int | 评论数 |
**DrinkTag 结构:**
| 字段 | 类型 | 说明 |
|------|------|------|
| category | string | 酒类IDbaijiu/beer/wine/whisky/huangjiu/sake/fruit/cocktail |
| name | string | 酒名(如"飞天茅台53度" |
| amount | string | 用量展示文本(如"100ml"、"3瓶" |
**业务规则:**
- 仅返回当前用户**酒友**发布的动态(visibility=friends+ 公开动态(visibility=public
- 按发布时间倒序
- 自己的动态也包含在内
---
### 3.2 发布动态
```
POST /circle/feeds
```
**请求参数:**
```json
{
"text": "今晚小酌一杯",
"images": ["https://xxx/img1.jpg"],
"recordId": "record_1689234567890",
"visibility": "friends"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| text | string | 否 | 文字内容(最长500字),text和images至少一项非空 |
| images | string[] | 否 | 图片URL数组(最多9张,先调上传接口获取URL) |
| recordId | string/null | 否 | 关联的饮酒记录ID,传入时自动提取酒水信息生成drinks标签 |
| visibility | string | 否 | 可见范围:friends(默认)/private |
**响应 data**
```json
{
"success": true,
"feedId": "feed_1689234567890"
}
```
**业务规则:**
- 传入 recordId 时,后端从记录中提取 drinks 信息填充到动态的 drinks 字段
- UGC 内容(text)需接入微信内容安全审核(msgSecCheck
---
### 3.3 点赞动态
```
POST /circle/feeds/:id/like
```
**响应 data**
```json
{ "success": true }
```
### 3.4 取消点赞
```
POST /circle/feeds/:id/unlike
```
**响应 data**
```json
{ "success": true }
```
> 重复点赞/取消应幂等处理,不报错。
---
### 3.5 获取动态评论
```
GET /circle/feeds/:id/comments
```
**响应 data**
```json
{
"list": [
{
"id": "c001",
"userId": "user_003",
"nickname": "酒仙李白",
"avatar": "https://xxx/avatar3.jpg",
"content": "茅台配火锅,土豪啊老张!",
"time": "2026-07-20T21:00:00.000Z"
}
]
}
```
**Comment 字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 评论ID |
| userId | string | 评论者ID |
| nickname | string | 评论者昵称 |
| avatar | string | 评论者头像 |
| content | string | 评论内容 |
| time | string | 评论时间 ISO 8601 |
> 按时间正序返回(最早在前)。
---
### 3.6 发表评论
```
POST /circle/feeds/:id/comments
```
**请求参数:**
```json
{ "content": "看着就馋了,下次带上我" }
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| content | string | 是 | 评论内容(最长200字) |
**响应 data**
```json
{
"success": true,
"comment": {
"id": "c_1689234567890",
"content": "看着就馋了,下次带上我",
"time": "2026-07-20T22:00:00.000Z"
}
}
```
**业务规则:**
- 评论需接入内容安全审核
- 评论成功后,动态的 comments 计数 +1
---
## 4. 酒友管理
### 4.1 获取酒友列表
```
GET /circle/friends
```
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| keyword | string | 否 | 按昵称模糊搜索 |
**响应 data**
```json
{
"list": [
{
"id": "user_002",
"nickname": "老张",
"avatar": "https://xxx/avatar.jpg",
"lastDrink": "昨晚喝了飞天茅台",
"lastDrinkTime": "2026-07-20T20:30:00.000Z",
"online": true
}
]
}
```
**Friend 字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 酒友用户ID |
| nickname | string | 昵称 |
| avatar | string | 头像URL |
| lastDrink | string | 最近一次饮酒摘要文本(如"昨晚喝了飞天茅台"),无记录则为空 |
| lastDrinkTime | string | 最近饮酒时间 ISO 8601 |
| online | boolean | 是否在线(24小时内活跃视为在线) |
---
### 4.2 获取好友请求列表
```
GET /circle/friend-requests
```
**响应 data**
```json
{
"list": [
{
"id": "req001",
"userId": "user_101",
"nickname": "夜猫子",
"avatar": "https://xxx/avatar101.jpg",
"message": "一起喝过酒,加个好友",
"time": "2026-07-20T21:00:00.000Z"
}
]
}
```
> 仅返回**待处理**的请求(status=pending),按时间倒序。
---
### 4.3 发送好友请求
```
POST /circle/friend-requests
```
**请求参数:**
```json
{
"userId": "user_101",
"message": "一起喝过酒,加个好友"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| userId | string | 是 | 目标用户ID |
| message | string | 否 | 验证消息(最长50字) |
**响应 data**
```json
{ "success": true }
```
**业务规则:**
- 已是酒友返回 409
- 已有待处理请求则幂等返回成功
---
### 4.4 接受好友请求
```
POST /circle/friend-requests/:id/accept
```
**响应 data**
```json
{ "success": true }
```
**业务规则:**
- 接受后双方建立酒友关系
- 请求状态更新为 accepted
---
### 4.5 删除酒友
```
DELETE /circle/friends/:userId
```
**响应 data**
```json
{ "success": true }
```
**业务规则:**
- 双向解除酒友关系
- 删除后对方动态不再出现在自己的信息流中
---
## 5. 约酒活动(酒局)
### 5.1 获取酒局列表
```
GET /circle/events
```
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| status | string | 否 | 状态筛选:open/ongoing/ended,不传返回全部 |
**响应 data**
```json
{
"list": [
{
"id": "evt001",
"title": "周五夜·老地方火锅局",
"organizer": {
"id": "user_002",
"nickname": "老张",
"avatar": "https://xxx/avatar.jpg"
},
"time": "2026-07-25T19:00:00.000Z",
"location": "渝味晓宇火锅(解放碑店)",
"maxPeople": 8,
"joined": 5,
"participants": [
{ "id": "user_002", "nickname": "老张", "avatar": "https://xxx/avatar.jpg" }
],
"status": "open",
"note": "自带酒水,AA制餐费",
"isJoined": false,
"isOrganizer": false
}
]
}
```
**Event 字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 酒局ID |
| title | string | 酒局名称(最长30字) |
| organizer | UserInfo | 发起人信息 |
| time | string | 开始时间 ISO 8601 |
| location | string | 地点文本 |
| maxPeople | int | 人数上限(2-50 |
| joined | int | 已报名人数(含发起人) |
| participants | UserInfo[] | 参与者列表 |
| status | string | 状态:open(报名中)/ongoing(进行中)/ended(已结束) |
| note | string | 备注 |
| isJoined | boolean | 当前用户是否已报名 |
| isOrganizer | boolean | 当前用户是否为发起人 |
**状态流转规则(后端自动维护):**
- `open`:当前时间 < 开始时间
- `ongoing`:开始时间 <= 当前时间 < 开始时间+6小时
- `ended`:当前时间 >= 开始时间+6小时
**可见范围:** 仅酒友可见(同动态信息流规则)
---
### 5.2 获取酒局详情
```
GET /circle/events/:id
```
**响应 data** 同 5.1 中单个 Event 结构
---
### 5.3 发起酒局
```
POST /circle/events
```
**请求参数:**
```json
{
"title": "周五夜·老地方火锅局",
"time": "2026-07-25T19:00:00.000Z",
"location": "渝味晓宇火锅(解放碑店)",
"maxPeople": 8,
"note": "自带酒水,AA制餐费"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| title | string | 是 | 酒局名称(最长30字) |
| time | string | 是 | 开始时间(必须为未来时间) |
| location | string | 是 | 地点(最长50字) |
| maxPeople | int | 是 | 人数上限(2-50 |
| note | string | 否 | 备注(最长200字) |
**响应 data**
```json
{
"success": true,
"eventId": "evt_1689234567890"
}
```
**业务规则:**
- 发起人自动算作已报名(joined 初始为1)
- 发起人 isOrganizer = true
---
### 5.4 报名酒局
```
POST /circle/events/:id/join
```
**响应 data**
```json
{ "success": true }
```
**业务规则:**
- 人数已满返回 409
- 酒局非 open 状态返回 403
- 重复报名幂等处理
---
### 5.5 取消报名
```
POST /circle/events/:id/quit
```
**响应 data**
```json
{ "success": true }
```
**业务规则:**
- 发起人不可取消报名(返回 403
- 未报名状态幂等处理
---
### 5.6 酒局签到
```
POST /circle/events/:id/checkin
```
**响应 data**
```json
{ "success": true }
```
**业务规则:**
- 仅 ongoing 状态可签到(返回 403
- 仅已报名用户可签到
- 签到后可在参与者信息中标记 checkedIn(可选扩展)
---
## 6. 数据库表设计参考
### 6.1 酒友关系表 (friendships)
```sql
CREATE TABLE friendships (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(32) NOT NULL,
friend_id VARCHAR(32) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX idx_user_friend (user_id, friend_id),
INDEX idx_friend (friend_id)
);
```
> 双向关系存两条记录(A->B 和 B->A),便于查询。
### 6.2 好友请求表 (friend_requests)
```sql
CREATE TABLE friend_requests (
id VARCHAR(32) PRIMARY KEY,
from_user_id VARCHAR(32) NOT NULL,
to_user_id VARCHAR(32) NOT NULL,
message VARCHAR(64),
status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_to_status (to_user_id, status),
INDEX idx_from_to (from_user_id, to_user_id)
);
```
### 6.3 动态表 (feeds)
```sql
CREATE TABLE feeds (
id VARCHAR(32) PRIMARY KEY,
user_id VARCHAR(32) NOT NULL,
text VARCHAR(512),
drinks JSON,
feeling VARCHAR(16),
images JSON,
record_id VARCHAR(32),
visibility ENUM('friends', 'private') DEFAULT 'friends',
like_count INT DEFAULT 0,
comment_count INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_time (user_id, created_at),
INDEX idx_created (created_at)
);
```
### 6.4 评论表 (comments)
```sql
CREATE TABLE comments (
id VARCHAR(32) PRIMARY KEY,
feed_id VARCHAR(32) NOT NULL,
user_id VARCHAR(32) NOT NULL,
content VARCHAR(256) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_feed (feed_id, created_at)
);
```
### 6.5 酒局表 (events)
```sql
CREATE TABLE events (
id VARCHAR(32) PRIMARY KEY,
organizer_id VARCHAR(32) NOT NULL,
title VARCHAR(64) NOT NULL,
start_time DATETIME NOT NULL,
location VARCHAR(128) NOT NULL,
max_people INT NOT NULL DEFAULT 6,
joined_count INT NOT NULL DEFAULT 1,
note VARCHAR(256),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_organizer (organizer_id),
INDEX idx_start_time (start_time)
);
```
### 6.6 酒局报名表 (event_participants)
```sql
CREATE TABLE event_participants (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(32) NOT NULL,
user_id VARCHAR(32) NOT NULL,
checked_in TINYINT(1) DEFAULT 0,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX idx_event_user (event_id, user_id),
INDEX idx_user (user_id)
);
```
---
## 7. 前端对接说明
### 7.1 前端调用位置
所有接口调用集中在 `common/api.js`,当前为 Mock 实现,方法签名如下:
```js
// 动态
GetCircleFeeds({ page, pageSize }) // GET /circle/feeds
PublishFeed({ text, images, recordId, visibility }) // POST /circle/feeds
LikeFeed({ feedId }) // POST /circle/feeds/:id/like
UnlikeFeed({ feedId }) // POST /circle/feeds/:id/unlike
GetFeedComments({ feedId }) // GET /circle/feeds/:id/comments
AddComment({ feedId, content }) // POST /circle/feeds/:id/comments
// 酒友
GetFriends({ keyword }) // GET /circle/friends
GetFriendRequests() // GET /circle/friend-requests
SendFriendRequest({ userId }) // POST /circle/friend-requests
AcceptFriendRequest({ requestId }) // POST /circle/friend-requests/:id/accept
RemoveFriend({ userId }) // DELETE /circle/friends/:userId
// 酒局
GetEvents({ status }) // GET /circle/events
GetEventDetail({ eventId }) // GET /circle/events/:id
CreateEvent({ title, time, location, maxPeople, note }) // POST /circle/events
JoinEvent({ eventId }) // POST /circle/events/:id/join
QuitEvent({ eventId }) // POST /circle/events/:id/quit
CheckInEvent({ eventId }) // POST /circle/events/:id/checkin
```
### 7.2 响应解包约定
前端 SDK 会自动解包 `data.data`,因此接口实际返回给前端的结构为:
```json
{ "code": 0, "data": { ... } }
```
前端拿到的就是 `data` 内的对象。
### 7.3 注意事项
1. **字段名严格一致**:前端已按本文档字段名渲染,请勿更改命名(如 `likes` 不能改为 `likeCount`
2. **头像可为空**:avatar 字段允许空字符串,前端有默认占位头像
3. **时间格式**:统一 ISO 8601,前端自行格式化
4. **内容安全**text、content、title、note 等 UGC 字段需接入微信 msgSecCheck 审核
5. **幂等性**:点赞/取消、报名/取消等操作需幂等,避免前端重试导致计数错误
+48
View File
@@ -48,6 +48,48 @@
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
},
{
"path": "pages/circle/circle",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
},
{
"path": "pages/circle-detail/circle-detail",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
},
{
"path": "pages/circle-publish/circle-publish",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
},
{
"path": "pages/friends/friends",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
},
{
"path": "pages/event-create/event-create",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
},
{
"path": "pages/event-detail/event-detail",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
}
],
"globalStyle": {
@@ -70,6 +112,12 @@
"iconPath": "static/tab-home.png",
"selectedIconPath": "static/tab-home-active.png"
},
{
"pagePath": "pages/circle/circle",
"text": "酒友圈",
"iconPath": "static/tab-circle.png",
"selectedIconPath": "static/tab-circle-active.png"
},
{
"pagePath": "pages/profile/profile",
"text": "我的",
+267
View File
@@ -0,0 +1,267 @@
<template>
<view :class="themeClass" class="page-container detail-page">
<!-- 顶部导航 -->
<view class="detail-nav" :style="{ paddingTop: navPaddingTop }">
<view class="detail-nav-inner">
<view class="detail-back" @click="goBack">
<text class="detail-back-icon"></text>
</view>
<text class="detail-nav-title">动态详情</text>
<view class="detail-nav-ph"></view>
</view>
</view>
<!-- 动态内容 -->
<scroll-view class="detail-scroll" scroll-y>
<view class="detail-body">
<FeedCard
v-if="feed"
:feed="feed"
@like="handleLike"
/>
<!-- 评论区 -->
<view class="comments-section">
<view class="comments-header">
<text class="comments-title">评论</text>
<text class="comments-count">{{ comments.length }}</text>
</view>
<CommentItem
v-for="c in comments"
:key="c.id"
:comment="c"
/>
<EmptyState
v-if="!comments.length"
icon="💬"
title="暂无评论"
desc="来说点什么吧"
/>
</view>
</view>
</scroll-view>
<!-- 底部评论输入 -->
<view class="comment-bar">
<input
class="comment-input"
v-model="commentText"
placeholder="写评论..."
placeholder-class="comment-placeholder"
confirm-type="send"
@confirm="submitComment"
/>
<view class="comment-send" :class="{ disabled: !commentText.trim() }" @click="submitComment">
<text class="comment-send-text">发送</text>
</view>
</view>
</view>
</template>
<script>
import FeedCard from '../../components/FeedCard.vue'
import CommentItem from '../../components/CommentItem.vue'
import EmptyState from '../../components/EmptyState.vue'
import { GetCircleFeeds, GetFeedComments, AddComment, LikeFeed, UnlikeFeed } from '../../common/api'
import { MOCK_FEEDS } from '../../common/mock-data'
import themeMixin from '../../common/theme-mixin'
export default {
mixins: [themeMixin],
components: { FeedCard, CommentItem, EmptyState },
data() {
const menuBtn = uni.getMenuButtonBoundingClientRect()
return {
navPaddingTop: menuBtn.top + 'px',
feedId: '',
feed: null,
comments: [],
commentText: ''
}
},
onLoad(options) {
this.feedId = options.id || ''
this.loadFeed()
this.loadComments()
},
methods: {
goBack() {
uni.navigateBack()
},
async loadFeed() {
// 从 Mock 数据中查找对应动态
this.feed = MOCK_FEEDS.find(f => f.id === this.feedId) || MOCK_FEEDS[0]
},
async loadComments() {
try {
const res = await GetFeedComments({ feedId: this.feedId })
this.comments = res.data.list
} catch (e) {
console.warn('加载评论失败', e)
}
},
async handleLike(feed) {
feed.liked = !feed.liked
feed.likes += feed.liked ? 1 : -1
try {
if (feed.liked) await LikeFeed({ feedId: feed.id })
else await UnlikeFeed({ feedId: feed.id })
} catch (e) { /* ignore */ }
},
async submitComment() {
const text = this.commentText.trim()
if (!text) return
try {
await AddComment({ feedId: this.feedId, content: text })
this.comments.push({
id: 'c_' + Date.now(),
userId: 'me',
nickname: '我',
avatar: '',
content: text,
time: '刚刚'
})
this.commentText = ''
if (this.feed) this.feed.comments = (this.feed.comments || 0) + 1
} catch (e) {
uni.showToast({ title: '发送失败', icon: 'none' })
}
}
}
}
</script>
<style lang="scss" scoped>
.detail-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.detail-nav {
background: $bg-base;
padding-left: $sp-lg;
padding-right: $sp-lg;
position: relative;
z-index: 10;
}
.detail-nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
}
.detail-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $bg-card;
&:active {
background: $bg-card-alt;
}
}
.detail-back-icon {
font-size: $fs-xl;
color: $text-primary;
font-weight: $fw-bold;
}
.detail-nav-title {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.detail-nav-ph {
width: 64rpx;
}
.detail-scroll {
flex: 1;
height: 0;
}
.detail-body {
padding: $sp-md $sp-lg;
padding-bottom: $sp-xl;
}
.comments-section {
margin-top: $sp-lg;
background: $bg-card;
border-radius: $radius-xl;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
padding: $sp-xl;
}
.comments-header {
display: flex;
align-items: center;
gap: $sp-sm;
margin-bottom: $sp-md;
}
.comments-title {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.comments-count {
font-size: $fs-sm;
color: $text-tertiary;
}
/* 底部评论栏 */
.comment-bar {
display: flex;
align-items: center;
gap: $sp-md;
padding: $sp-md $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + #{$sp-md});
background: $bg-card;
border-top: 1rpx solid var(--divider-color, rgba(255,255,255,0.08));
}
.comment-input {
flex: 1;
height: 72rpx;
padding: 0 $sp-lg;
background: $bg-elevated;
border-radius: $radius-full;
font-size: $fs-base;
color: $text-primary;
}
.comment-placeholder {
color: $text-tertiary;
}
.comment-send {
padding: $sp-sm $sp-lg;
background: linear-gradient(135deg, $amber, $amber-deep);
border-radius: $radius-full;
&.disabled {
opacity: 0.4;
}
&:active {
transform: scale(0.95);
}
}
.comment-send-text {
font-size: $fs-sm;
font-weight: $fw-bold;
color: $text-on-amber;
}
</style>
+430
View File
@@ -0,0 +1,430 @@
<template>
<view :class="themeClass" class="page-container publish-page">
<!-- 顶部导航 -->
<view class="publish-nav" :style="{ paddingTop: navPaddingTop }">
<view class="publish-nav-inner">
<view class="publish-back" @click="goBack">
<text class="publish-back-icon"></text>
</view>
<text class="publish-nav-title">发布动态</text>
<view class="publish-send" :class="{ disabled: !canSubmit }" @click="handlePublish">
<text class="publish-send-text">发布</text>
</view>
</view>
</view>
<scroll-view class="publish-scroll" scroll-y>
<view class="publish-body">
<!-- 关联饮酒记录 -->
<view class="publish-record" v-if="lastRecord">
<view class="record-badge">
<text class="record-badge-icon">🍻</text>
</view>
<view class="record-info">
<text class="record-title">关联最近饮酒记录</text>
<text class="record-desc">{{ recordSummary }}</text>
</view>
<view class="record-check" :class="{ checked: linkRecord }" @click="linkRecord = !linkRecord">
<text class="record-check-icon">{{ linkRecord ? '✓' : '' }}</text>
</view>
</view>
<!-- 文字输入 -->
<textarea
class="publish-textarea"
v-model="text"
placeholder="分享你的饮酒故事..."
placeholder-class="publish-placeholder"
:maxlength="500"
auto-height
/>
<!-- 图片选择 -->
<view class="publish-images">
<view class="publish-img-item" v-for="(img, i) in images" :key="i">
<image class="publish-img" :src="img" mode="aspectFill"></image>
<view class="publish-img-del" @click="removeImage(i)">
<text class="publish-img-del-icon">×</text>
</view>
</view>
<view v-if="images.length < 9" class="publish-img-add" @click="chooseImage">
<text class="publish-img-add-icon">+</text>
<text class="publish-img-add-text">{{ images.length }}/9</text>
</view>
</view>
<!-- 可见范围 -->
<view class="publish-visibility">
<text class="publish-vis-label">谁可以看</text>
<view class="publish-vis-options">
<view
class="publish-vis-opt"
:class="{ active: visibility === 'friends' }"
@click="visibility = 'friends'"
>
<text>所有酒友</text>
</view>
<view
class="publish-vis-opt"
:class="{ active: visibility === 'private' }"
@click="visibility = 'private'"
>
<text>仅自己</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { PublishFeed } from '../../common/api'
import { getRecords } from '../../common/mock-data'
import themeMixin from '../../common/theme-mixin'
export default {
mixins: [themeMixin],
data() {
const menuBtn = uni.getMenuButtonBoundingClientRect()
return {
navPaddingTop: menuBtn.top + 'px',
text: '',
images: [],
visibility: 'friends',
linkRecord: true,
lastRecord: null,
publishing: false
}
},
computed: {
canSubmit() {
return (this.text.trim() || this.images.length) && !this.publishing
},
recordSummary() {
if (!this.lastRecord) return ''
const drinks = this.lastRecord.drinks || []
if (!drinks.length) return '戒酒打卡'
return drinks.map(d => `${d.brand || d.product || d.category} ${d.amount}${d.unit}`).join('、')
}
},
onLoad() {
this.loadLastRecord()
},
methods: {
goBack() {
uni.navigateBack()
},
loadLastRecord() {
try {
const records = getRecords()
const drank = records.find(r => r.mode === 'drank')
if (drank) this.lastRecord = drank
} catch (e) { /* ignore */ }
},
chooseImage() {
const remaining = 9 - this.images.length
uni.chooseImage({
count: remaining,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
this.images = [...this.images, ...res.tempFilePaths].slice(0, 9)
}
})
},
removeImage(index) {
this.images.splice(index, 1)
},
async handlePublish() {
if (!this.canSubmit) return
this.publishing = true
try {
await PublishFeed({
text: this.text,
images: this.images,
recordId: this.linkRecord && this.lastRecord ? this.lastRecord.id : null,
visibility: this.visibility
})
uni.showToast({ title: '发布成功 🍻', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
} catch (e) {
uni.showToast({ title: '发布失败', icon: 'none' })
}
this.publishing = false
}
}
}
</script>
<style lang="scss" scoped>
.publish-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.publish-nav {
background: $bg-base;
padding-left: $sp-lg;
padding-right: $sp-lg;
position: relative;
z-index: 10;
}
.publish-nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
}
.publish-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $bg-card;
&:active { background: $bg-card-alt; }
}
.publish-back-icon {
font-size: $fs-xl;
color: $text-primary;
font-weight: $fw-bold;
}
.publish-nav-title {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.publish-send {
padding: $sp-sm $sp-lg;
background: linear-gradient(135deg, $amber, $amber-deep);
border-radius: $radius-full;
&.disabled { opacity: 0.4; }
&:active { transform: scale(0.95); }
}
.publish-send-text {
font-size: $fs-sm;
font-weight: $fw-bold;
color: $text-on-amber;
}
.publish-scroll {
flex: 1;
height: 0;
}
.publish-body {
padding: $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + #{$sp-2xl});
}
/* 关联记录 */
.publish-record {
display: flex;
align-items: center;
gap: $sp-md;
padding: $sp-lg;
background: rgba(232,168,56,0.06);
border: 1rpx solid rgba(232,168,56,0.15);
border-radius: $radius-lg;
margin-bottom: $sp-xl;
}
.record-badge {
width: 72rpx;
height: 72rpx;
border-radius: $radius-md;
background: rgba(232,168,56,0.12);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.record-badge-icon {
font-size: 36rpx;
}
.record-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.record-title {
font-size: $fs-sm;
font-weight: $fw-medium;
color: $text-primary;
}
.record-desc {
font-size: $fs-xs;
color: $text-tertiary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.record-check {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
border: 2rpx solid var(--border-subtle, rgba(255,255,255,0.10));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.checked {
background: $amber;
border-color: $amber;
}
}
.record-check-icon {
font-size: $fs-sm;
color: $text-on-amber;
font-weight: $fw-bold;
}
/* 文字输入 */
.publish-textarea {
width: 100%;
min-height: 240rpx;
font-size: $fs-base;
color: $text-primary;
line-height: $lh-loose;
padding: $sp-lg;
background: $bg-card;
border-radius: $radius-xl;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
margin-bottom: $sp-xl;
box-sizing: border-box;
}
.publish-placeholder {
color: $text-tertiary;
}
/* 图片 */
.publish-images {
display: flex;
flex-wrap: wrap;
gap: $sp-sm;
margin-bottom: $sp-xl;
}
.publish-img-item {
position: relative;
width: calc(33.33% - 11rpx);
height: 200rpx;
}
.publish-img {
width: 100%;
height: 100%;
border-radius: $radius-md;
}
.publish-img-del {
position: absolute;
top: -12rpx;
right: -12rpx;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: $coral;
display: flex;
align-items: center;
justify-content: center;
}
.publish-img-del-icon {
font-size: $fs-sm;
color: #fff;
font-weight: $fw-bold;
}
.publish-img-add {
width: calc(33.33% - 11rpx);
height: 200rpx;
border-radius: $radius-md;
border: 2rpx dashed var(--border-dashed, rgba(255,255,255,0.12));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
&:active {
background: var(--bg-hover, rgba(255,255,255,0.08));
}
}
.publish-img-add-icon {
font-size: $fs-xl;
color: $text-tertiary;
}
.publish-img-add-text {
font-size: $fs-xs;
color: $text-tertiary;
}
/* 可见范围 */
.publish-visibility {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-lg;
background: $bg-card;
border-radius: $radius-lg;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
}
.publish-vis-label {
font-size: $fs-base;
color: $text-primary;
font-weight: $fw-medium;
}
.publish-vis-options {
display: flex;
gap: $sp-sm;
}
.publish-vis-opt {
padding: 8rpx 24rpx;
border-radius: $radius-full;
background: $bg-elevated;
border: 1rpx solid var(--border-micro, rgba(255,255,255,0.05));
text {
font-size: $fs-sm;
color: $text-tertiary;
}
&.active {
background: rgba(232,168,56,0.12);
border-color: rgba(232,168,56,0.3);
text {
color: $amber;
font-weight: $fw-medium;
}
}
}
</style>
+378
View File
@@ -0,0 +1,378 @@
<template>
<view :class="themeClass" class="page-container circle-page">
<!-- 顶部导航 -->
<view class="circle-header" :style="{ paddingTop: headerPaddingTop }">
<text class="circle-title">酒友圈</text>
<!-- Tab 切换 -->
<view class="circle-tabs">
<view
v-for="(tab, i) in tabs"
:key="i"
class="circle-tab"
:class="{ active: currentTab === i }"
@click="switchTab(i)"
>
<text class="circle-tab-text">{{ tab }}</text>
<view v-if="currentTab === i" class="circle-tab-line"></view>
</view>
</view>
</view>
<!-- 动态 Tab -->
<scroll-view
v-if="currentTab === 0"
class="circle-scroll"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<view class="circle-content">
<FeedCard
v-for="feed in feeds"
:key="feed.id"
:feed="feed"
@like="handleLike"
@comment="goDetail"
@tap="goDetail"
/>
<view v-if="loading" class="circle-loading">
<text class="circle-loading-text">加载中...</text>
</view>
<view v-if="!hasMore && feeds.length" class="circle-nomore">
<text class="circle-nomore-text"> 没有更多了 </text>
</view>
<EmptyState
v-if="!feeds.length && !loading"
icon="🍻"
title="还没有动态"
desc="发布你的第一条饮酒动态吧"
actionText="发动态"
@action="goPublish"
/>
</view>
</scroll-view>
<!-- 酒友 Tab -->
<scroll-view v-if="currentTab === 1" class="circle-scroll" scroll-y>
<view class="circle-content">
<!-- 好友请求提醒 -->
<view v-if="friendRequests.length" class="request-banner" @click="goFriends">
<text class="request-icon">🔔</text>
<text class="request-text">{{ friendRequests.length }} 条新的好友请求</text>
<text class="request-arrow"></text>
</view>
<FriendItem
v-for="f in friends"
:key="f.id"
:friend="f"
@tap="goFriends"
/>
<EmptyState
v-if="!friends.length"
icon="👥"
title="还没有酒友"
desc="邀请好友一起记录饮酒生活"
actionText="邀请好友"
@action="goFriends"
/>
</view>
</scroll-view>
<!-- 酒局 Tab -->
<scroll-view v-if="currentTab === 2" class="circle-scroll" scroll-y>
<view class="circle-content">
<EventCard
v-for="evt in events"
:key="evt.id"
:event="evt"
@tap="goEventDetail"
/>
<EmptyState
v-if="!events.length"
icon="🎉"
title="暂无酒局"
desc="发起一场酒局,约上酒友一起喝"
actionText="发起酒局"
@action="goCreateEvent"
/>
</view>
</scroll-view>
<!-- FAB 发布按钮 -->
<view class="fab" @click="handleFab">
<text class="fab-icon">{{ currentTab === 2 ? '🎉' : '✏️' }}</text>
</view>
</view>
</template>
<script>
import FeedCard from '../../components/FeedCard.vue'
import FriendItem from '../../components/FriendItem.vue'
import EventCard from '../../components/EventCard.vue'
import EmptyState from '../../components/EmptyState.vue'
import { GetCircleFeeds, LikeFeed, UnlikeFeed, GetFriends, GetFriendRequests, GetEvents } from '../../common/api'
import themeMixin from '../../common/theme-mixin'
export default {
mixins: [themeMixin],
components: { FeedCard, FriendItem, EventCard, EmptyState },
data() {
const menuBtn = uni.getMenuButtonBoundingClientRect()
return {
tabs: ['动态', '酒友', '酒局'],
currentTab: 0,
headerPaddingTop: (menuBtn.top + 8) + 'px',
// 动态
feeds: [],
page: 1,
hasMore: true,
loading: false,
refreshing: false,
// 酒友
friends: [],
friendRequests: [],
// 酒局
events: []
}
},
onShow() {
this.loadFeeds(true)
this.loadFriends()
this.loadEvents()
},
methods: {
switchTab(i) {
this.currentTab = i
},
// === 动态 ===
async loadFeeds(reset = false) {
if (this.loading) return
if (reset) {
this.page = 1
this.hasMore = true
}
this.loading = true
try {
const res = await GetCircleFeeds({ page: this.page, pageSize: 10 })
const data = res.data
if (reset) {
this.feeds = data.list
} else {
this.feeds = [...this.feeds, ...data.list]
}
this.hasMore = data.hasMore
this.page++
} catch (e) {
console.warn('加载动态失败', e)
}
this.loading = false
this.refreshing = false
},
onRefresh() {
this.refreshing = true
this.loadFeeds(true)
},
loadMore() {
if (this.hasMore && !this.loading) {
this.loadFeeds(false)
}
},
async handleLike(feed) {
feed.liked = !feed.liked
feed.likes += feed.liked ? 1 : -1
try {
if (feed.liked) {
await LikeFeed({ feedId: feed.id })
} else {
await UnlikeFeed({ feedId: feed.id })
}
} catch (e) { /* ignore */ }
},
goDetail(feed) {
uni.navigateTo({ url: `/pages/circle-detail/circle-detail?id=${feed.id}` })
},
goPublish() {
uni.navigateTo({ url: '/pages/circle-publish/circle-publish' })
},
// === 酒友 ===
async loadFriends() {
try {
const [friendsRes, reqRes] = await Promise.all([
GetFriends({}),
GetFriendRequests()
])
this.friends = friendsRes.data.list
this.friendRequests = reqRes.data.list
} catch (e) {
console.warn('加载酒友失败', e)
}
},
goFriends() {
uni.navigateTo({ url: '/pages/friends/friends' })
},
// === 酒局 ===
async loadEvents() {
try {
const res = await GetEvents({})
this.events = res.data.list
} catch (e) {
console.warn('加载酒局失败', e)
}
},
goEventDetail(evt) {
uni.navigateTo({ url: `/pages/event-detail/event-detail?id=${evt.id}` })
},
goCreateEvent() {
uni.navigateTo({ url: '/pages/event-create/event-create' })
},
// === FAB ===
handleFab() {
if (this.currentTab === 2) {
this.goCreateEvent()
} else {
this.goPublish()
}
}
}
}
</script>
<style lang="scss" scoped>
.circle-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.circle-header {
padding-left: $sp-lg;
padding-right: $sp-lg;
padding-bottom: $sp-md;
background: $bg-base;
position: relative;
z-index: 10;
}
.circle-title {
display: block;
font-size: $fs-2xl;
font-weight: $fw-black;
color: $text-primary;
margin-bottom: $sp-lg;
}
.circle-tabs {
display: flex;
gap: $sp-xl;
}
.circle-tab {
position: relative;
padding-bottom: $sp-sm;
}
.circle-tab-text {
font-size: $fs-base;
color: $text-tertiary;
font-weight: $fw-medium;
transition: color $duration-fast $ease-out;
.circle-tab.active & {
color: $text-primary;
font-weight: $fw-bold;
}
}
.circle-tab-line {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background: $amber;
}
.circle-scroll {
flex: 1;
height: 0;
}
.circle-content {
padding: $sp-md $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + 140rpx);
}
.circle-loading,
.circle-nomore {
text-align: center;
padding: $sp-xl 0;
}
.circle-loading-text,
.circle-nomore-text {
font-size: $fs-sm;
color: $text-tertiary;
}
/* 好友请求横幅 */
.request-banner {
display: flex;
align-items: center;
gap: $sp-sm;
padding: $sp-lg;
background: rgba(232,168,56,0.08);
border: 1rpx solid rgba(232,168,56,0.2);
border-radius: $radius-lg;
margin-bottom: $sp-md;
&:active {
background: rgba(232,168,56,0.12);
}
}
.request-icon {
font-size: $fs-base;
}
.request-text {
flex: 1;
font-size: $fs-sm;
color: $amber;
font-weight: $fw-medium;
}
.request-arrow {
font-size: $fs-xl;
color: $amber;
opacity: 0.6;
}
/* FAB */
.fab {
position: fixed;
right: $sp-xl;
bottom: calc(env(safe-area-inset-bottom) + 180rpx);
width: 108rpx;
height: 108rpx;
border-radius: 50%;
background: linear-gradient(135deg, $amber, $amber-deep);
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-amber;
z-index: 50;
&:active {
transform: scale(0.9);
}
}
.fab-icon {
font-size: 44rpx;
}
</style>
+340
View File
@@ -0,0 +1,340 @@
<template>
<view :class="themeClass" class="page-container create-page">
<!-- 顶部导航 -->
<view class="create-nav" :style="{ paddingTop: navPaddingTop }">
<view class="create-nav-inner">
<view class="create-back" @click="goBack">
<text class="create-back-icon"></text>
</view>
<text class="create-nav-title">发起酒局</text>
<view class="create-nav-ph"></view>
</view>
</view>
<scroll-view class="create-scroll" scroll-y>
<view class="create-body">
<!-- 标题 -->
<view class="form-group">
<text class="form-label">酒局名称</text>
<input
class="form-input"
v-model="form.title"
placeholder="例如:周五夜·老地方火锅局"
placeholder-class="form-placeholder"
:maxlength="30"
/>
</view>
<!-- 时间 -->
<view class="form-group">
<text class="form-label">时间</text>
<picker mode="date" :value="form.date" @change="onDateChange">
<view class="form-picker">
<text :class="form.date ? 'form-picker-text' : 'form-picker-ph'">{{ form.date || '选择日期' }}</text>
<text class="form-picker-arrow"></text>
</view>
</picker>
<picker mode="time" :value="form.time" @change="onTimeChange">
<view class="form-picker" style="margin-top: 16rpx;">
<text :class="form.time ? 'form-picker-text' : 'form-picker-ph'">{{ form.time || '选择时间' }}</text>
<text class="form-picker-arrow"></text>
</view>
</picker>
</view>
<!-- 地点 -->
<view class="form-group">
<text class="form-label">地点</text>
<input
class="form-input"
v-model="form.location"
placeholder="输入聚会地点"
placeholder-class="form-placeholder"
:maxlength="50"
/>
</view>
<!-- 人数 -->
<view class="form-group">
<text class="form-label">人数上限</text>
<view class="form-stepper">
<view class="stepper-btn" @click="changePeople(-1)">
<text class="stepper-btn-text"></text>
</view>
<text class="stepper-value">{{ form.maxPeople }}</text>
<view class="stepper-btn" @click="changePeople(1)">
<text class="stepper-btn-text">+</text>
</view>
</view>
</view>
<!-- 备注 -->
<view class="form-group">
<text class="form-label">备注选填</text>
<textarea
class="form-textarea"
v-model="form.note"
placeholder="例如:自带酒水,AA制餐费"
placeholder-class="form-placeholder"
:maxlength="200"
/>
</view>
<!-- 发布按钮 -->
<button class="create-submit" :class="{ disabled: !canSubmit }" @click="handleSubmit">
发布酒局
</button>
</view>
</scroll-view>
</view>
</template>
<script>
import { CreateEvent } from '../../common/api'
import themeMixin from '../../common/theme-mixin'
export default {
mixins: [themeMixin],
data() {
const menuBtn = uni.getMenuButtonBoundingClientRect()
return {
navPaddingTop: menuBtn.top + 'px',
form: {
title: '',
date: '',
time: '',
location: '',
maxPeople: 6,
note: ''
},
submitting: false
}
},
computed: {
canSubmit() {
return this.form.title.trim() && this.form.date && this.form.time && this.form.location.trim() && !this.submitting
}
},
methods: {
goBack() {
uni.navigateBack()
},
onDateChange(e) {
this.form.date = e.detail.value
},
onTimeChange(e) {
this.form.time = e.detail.value
},
changePeople(delta) {
const val = this.form.maxPeople + delta
if (val >= 2 && val <= 50) {
this.form.maxPeople = val
}
},
async handleSubmit() {
if (!this.canSubmit) return
this.submitting = true
try {
await CreateEvent({
title: this.form.title,
time: `${this.form.date} ${this.form.time}`,
location: this.form.location,
maxPeople: this.form.maxPeople,
note: this.form.note
})
uni.showToast({ title: '酒局已发布 🎉', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
} catch (e) {
uni.showToast({ title: '发布失败', icon: 'none' })
}
this.submitting = false
}
}
}
</script>
<style lang="scss" scoped>
.create-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.create-nav {
background: $bg-base;
padding-left: $sp-lg;
padding-right: $sp-lg;
}
.create-nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
}
.create-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $bg-card;
&:active { background: $bg-card-alt; }
}
.create-back-icon {
font-size: $fs-xl;
color: $text-primary;
font-weight: $fw-bold;
}
.create-nav-title {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.create-nav-ph {
width: 64rpx;
}
.create-scroll {
flex: 1;
height: 0;
}
.create-body {
padding: $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + #{$sp-2xl});
}
.form-group {
margin-bottom: $sp-xl;
}
.form-label {
display: block;
font-size: $fs-sm;
font-weight: $fw-medium;
color: $text-secondary;
margin-bottom: $sp-md;
}
.form-input {
width: 100%;
height: 88rpx;
padding: 0 $sp-lg;
background: $bg-card;
border-radius: $radius-lg;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
font-size: $fs-base;
color: $text-primary;
box-sizing: border-box;
}
.form-placeholder {
color: $text-tertiary;
}
.form-picker {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 $sp-lg;
background: $bg-card;
border-radius: $radius-lg;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
}
.form-picker-text {
font-size: $fs-base;
color: $text-primary;
}
.form-picker-ph {
font-size: $fs-base;
color: $text-tertiary;
}
.form-picker-arrow {
font-size: $fs-xl;
color: $text-tertiary;
}
.form-textarea {
width: 100%;
min-height: 160rpx;
padding: $sp-lg;
background: $bg-card;
border-radius: $radius-lg;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
font-size: $fs-base;
color: $text-primary;
line-height: $lh-normal;
box-sizing: border-box;
}
/* 人数步进器 */
.form-stepper {
display: flex;
align-items: center;
gap: $sp-xl;
}
.stepper-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: $bg-card;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
display: flex;
align-items: center;
justify-content: center;
&:active { background: $bg-card-alt; }
}
.stepper-btn-text {
font-size: $fs-xl;
color: $text-primary;
font-weight: $fw-bold;
}
.stepper-value {
font-size: $fs-xl;
font-weight: $fw-black;
color: $amber;
min-width: 60rpx;
text-align: center;
}
/* 提交 */
.create-submit {
width: 100%;
height: 96rpx;
line-height: 96rpx;
text-align: center;
background: linear-gradient(135deg, $amber, $amber-deep);
color: $text-on-amber;
font-size: $fs-lg;
font-weight: $fw-bold;
border-radius: $radius-full;
border: none;
margin-top: $sp-xl;
&::after { border: none; }
&.disabled {
opacity: 0.4;
}
&:active {
transform: scale(0.98);
}
}
</style>
+450
View File
@@ -0,0 +1,450 @@
<template>
<view :class="themeClass" class="page-container evt-detail-page">
<!-- 顶部导航 -->
<view class="evt-nav" :style="{ paddingTop: navPaddingTop }">
<view class="evt-nav-inner">
<view class="evt-back" @click="goBack">
<text class="evt-back-icon"></text>
</view>
<text class="evt-nav-title">酒局详情</text>
<view class="evt-nav-ph"></view>
</view>
</view>
<scroll-view class="evt-scroll" scroll-y>
<view class="evt-body" v-if="event">
<!-- 状态 -->
<view class="evt-status" :class="'status-' + event.status">
<text class="evt-status-text">{{ statusLabel }}</text>
</view>
<!-- 标题 -->
<text class="evt-title">{{ event.title }}</text>
<!-- 信息卡片 -->
<view class="evt-info-card">
<view class="evt-info-row">
<text class="evt-info-icon">🕐</text>
<view class="evt-info-content">
<text class="evt-info-label">时间</text>
<text class="evt-info-value">{{ event.time }}</text>
</view>
</view>
<view class="evt-info-row">
<text class="evt-info-icon">📍</text>
<view class="evt-info-content">
<text class="evt-info-label">地点</text>
<text class="evt-info-value">{{ event.location }}</text>
</view>
</view>
<view class="evt-info-row">
<text class="evt-info-icon">👥</text>
<view class="evt-info-content">
<text class="evt-info-label">人数</text>
<text class="evt-info-value">{{ event.joined }}/{{ event.maxPeople }} 人已报名</text>
</view>
</view>
<view class="evt-info-row" v-if="event.note">
<text class="evt-info-icon">📝</text>
<view class="evt-info-content">
<text class="evt-info-label">备注</text>
<text class="evt-info-value">{{ event.note }}</text>
</view>
</view>
</view>
<!-- 发起人 -->
<view class="evt-organizer">
<text class="evt-section-label">发起人</text>
<view class="evt-org-row">
<view class="evt-org-avatar">
<text class="evt-org-avatar-text">{{ event.organizer ? event.organizer.nickname[0] : '酒' }}</text>
</view>
<text class="evt-org-name">{{ event.organizer ? event.organizer.nickname : '' }}</text>
</view>
</view>
<!-- 参与者 -->
<view class="evt-participants" v-if="event.participants && event.participants.length">
<text class="evt-section-label">已报名 ({{ event.participants.length }})</text>
<view class="evt-people-grid">
<view class="evt-person" v-for="p in event.participants" :key="p.id">
<view class="evt-person-avatar">
<text class="evt-person-text">{{ p.nickname[0] }}</text>
</view>
<text class="evt-person-name">{{ p.nickname }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="evt-action-bar" v-if="event">
<button
v-if="event.status === 'open' && !event.isJoined && !event.isOrganizer"
class="evt-btn evt-btn-primary"
@click="handleJoin"
>
报名参加
</button>
<button
v-if="event.status === 'open' && event.isJoined && !event.isOrganizer"
class="evt-btn evt-btn-ghost"
@click="handleQuit"
>
取消报名
</button>
<button
v-if="event.status === 'ongoing' && event.isJoined"
class="evt-btn evt-btn-primary"
@click="handleCheckIn"
>
到场签到
</button>
<view v-if="event.status === 'ended'" class="evt-ended-tip">
<text class="evt-ended-text">酒局已结束</text>
</view>
<view v-if="event.isOrganizer" class="evt-ended-tip">
<text class="evt-ended-text">你是发起人</text>
</view>
</view>
</view>
</template>
<script>
import { GetEventDetail, JoinEvent, QuitEvent, CheckInEvent } from '../../common/api'
import themeMixin from '../../common/theme-mixin'
export default {
mixins: [themeMixin],
data() {
const menuBtn = uni.getMenuButtonBoundingClientRect()
return {
navPaddingTop: menuBtn.top + 'px',
eventId: '',
event: null
}
},
computed: {
statusLabel() {
if (!this.event) return ''
const map = { open: '报名中', ongoing: '进行中', ended: '已结束' }
return map[this.event.status] || '报名中'
}
},
onLoad(options) {
this.eventId = options.id || ''
this.loadDetail()
},
methods: {
goBack() {
uni.navigateBack()
},
async loadDetail() {
try {
const res = await GetEventDetail({ eventId: this.eventId })
this.event = res.data
} catch (e) {
console.warn('加载酒局详情失败', e)
}
},
async handleJoin() {
try {
await JoinEvent({ eventId: this.eventId })
this.event.isJoined = true
this.event.joined++
uni.showToast({ title: '报名成功 🎉', icon: 'none' })
} catch (e) {
uni.showToast({ title: '报名失败', icon: 'none' })
}
},
async handleQuit() {
uni.showModal({
title: '取消报名',
content: '确定要退出这个酒局吗?',
confirmText: '退出',
confirmColor: '#FF6B6B',
success: async (res) => {
if (res.confirm) {
try {
await QuitEvent({ eventId: this.eventId })
this.event.isJoined = false
this.event.joined--
uni.showToast({ title: '已取消报名', icon: 'none' })
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
},
async handleCheckIn() {
try {
await CheckInEvent({ eventId: this.eventId })
uni.showToast({ title: '签到成功 🍻', icon: 'none' })
} catch (e) {
uni.showToast({ title: '签到失败', icon: 'none' })
}
}
}
}
</script>
<style lang="scss" scoped>
.evt-detail-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.evt-nav {
background: $bg-base;
padding-left: $sp-lg;
padding-right: $sp-lg;
}
.evt-nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
}
.evt-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $bg-card;
&:active { background: $bg-card-alt; }
}
.evt-back-icon {
font-size: $fs-xl;
color: $text-primary;
font-weight: $fw-bold;
}
.evt-nav-title {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.evt-nav-ph {
width: 64rpx;
}
.evt-scroll {
flex: 1;
height: 0;
}
.evt-body {
padding: $sp-lg;
padding-bottom: $sp-xl;
}
.evt-status {
display: inline-flex;
padding: 8rpx 20rpx;
border-radius: $radius-full;
margin-bottom: $sp-lg;
&.status-open { background: rgba(94,198,160,0.12); }
&.status-ongoing { background: rgba(232,168,56,0.12); }
&.status-ended { background: $bg-elevated; }
}
.evt-status-text {
font-size: $fs-sm;
font-weight: $fw-medium;
.status-open & { color: $mint; }
.status-ongoing & { color: $amber; }
.status-ended & { color: $text-tertiary; }
}
.evt-title {
display: block;
font-size: $fs-2xl;
font-weight: $fw-black;
color: $text-primary;
margin-bottom: $sp-xl;
line-height: $lh-tight;
}
/* 信息卡片 */
.evt-info-card {
background: $bg-card;
border-radius: $radius-xl;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
padding: $sp-xl;
margin-bottom: $sp-xl;
}
.evt-info-row {
display: flex;
align-items: flex-start;
gap: $sp-md;
padding: $sp-md 0;
&:not(:last-child) {
border-bottom: 1rpx solid var(--divider-color, rgba(255,255,255,0.08));
}
}
.evt-info-icon {
font-size: $fs-base;
width: 40rpx;
text-align: center;
flex-shrink: 0;
margin-top: 4rpx;
}
.evt-info-content {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.evt-info-label {
font-size: $fs-xs;
color: $text-tertiary;
}
.evt-info-value {
font-size: $fs-base;
color: $text-primary;
font-weight: $fw-medium;
}
/* 发起人 & 参与者 */
.evt-section-label {
display: block;
font-size: $fs-sm;
color: $text-tertiary;
font-weight: $fw-medium;
margin-bottom: $sp-md;
}
.evt-organizer {
margin-bottom: $sp-xl;
}
.evt-org-row {
display: flex;
align-items: center;
gap: $sp-md;
}
.evt-org-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(232,168,56,0.15), rgba(139,133,184,0.15));
display: flex;
align-items: center;
justify-content: center;
}
.evt-org-avatar-text {
font-size: $fs-base;
font-weight: $fw-bold;
color: $amber;
}
.evt-org-name {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.evt-people-grid {
display: flex;
flex-wrap: wrap;
gap: $sp-lg;
}
.evt-person {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
width: 100rpx;
}
.evt-person-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: $bg-elevated;
display: flex;
align-items: center;
justify-content: center;
}
.evt-person-text {
font-size: $fs-sm;
font-weight: $fw-bold;
color: $lavender;
}
.evt-person-name {
font-size: $fs-xs;
color: $text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100rpx;
text-align: center;
}
/* 底部操作栏 */
.evt-action-bar {
padding: $sp-md $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + #{$sp-md});
background: $bg-card;
border-top: 1rpx solid var(--divider-color, rgba(255,255,255,0.08));
}
.evt-btn {
width: 100%;
height: 96rpx;
line-height: 96rpx;
text-align: center;
border-radius: $radius-full;
font-size: $fs-lg;
font-weight: $fw-bold;
border: none;
&::after { border: none; }
&:active { transform: scale(0.98); }
}
.evt-btn-primary {
background: linear-gradient(135deg, $amber, $amber-deep);
color: $text-on-amber;
}
.evt-btn-ghost {
background: $bg-elevated;
color: $coral;
border: 1rpx solid rgba(255,107,107,0.2);
}
.evt-ended-tip {
text-align: center;
padding: $sp-md 0;
}
.evt-ended-text {
font-size: $fs-base;
color: $text-tertiary;
}
</style>
+378
View File
@@ -0,0 +1,378 @@
<template>
<view :class="themeClass" class="page-container friends-page">
<!-- 顶部导航 -->
<view class="friends-nav" :style="{ paddingTop: navPaddingTop }">
<view class="friends-nav-inner">
<view class="friends-back" @click="goBack">
<text class="friends-back-icon"></text>
</view>
<text class="friends-nav-title">我的酒友</text>
<view class="friends-nav-ph"></view>
</view>
</view>
<!-- 搜索栏 -->
<view class="friends-search">
<view class="search-box">
<text class="search-icon">🔍</text>
<input
class="search-input"
v-model="keyword"
placeholder="搜索酒友"
placeholder-class="search-placeholder"
confirm-type="search"
@confirm="doSearch"
@input="onSearchInput"
/>
<view v-if="keyword" class="search-clear" @click="clearSearch">
<text class="search-clear-icon">×</text>
</view>
</view>
</view>
<scroll-view class="friends-scroll" scroll-y>
<view class="friends-body">
<!-- 好友请求区 -->
<view v-if="requests.length && !keyword" class="requests-section">
<text class="section-label">新的好友请求</text>
<view class="request-item" v-for="req in requests" :key="req.id">
<view class="request-avatar">
<text class="request-avatar-text">{{ req.nickname[0] }}</text>
</view>
<view class="request-info">
<text class="request-name">{{ req.nickname }}</text>
<text class="request-msg">{{ req.message }}</text>
</view>
<view class="request-accept" @click="acceptRequest(req)">
<text class="request-accept-text">接受</text>
</view>
</view>
</view>
<!-- 酒友列表 -->
<view v-if="friends.length" class="friends-list">
<text class="section-label" v-if="!keyword">全部酒友 ({{ friends.length }})</text>
<text class="section-label" v-else>搜索结果 ({{ friends.length }})</text>
<FriendItem
v-for="f in friends"
:key="f.id"
:friend="f"
>
<template #action>
<view class="friend-del" @click.stop="confirmRemove(f)">
<text class="friend-del-text">删除</text>
</view>
</template>
</FriendItem>
</view>
<!-- 空状态 -->
<EmptyState
v-if="!friends.length && !requests.length"
icon="👥"
title="还没有酒友"
desc="分享小程序给好友,邀请他们加入"
actionText="邀请好友"
@action="inviteFriends"
/>
<EmptyState
v-if="keyword && !friends.length"
icon="🔍"
title="未找到相关酒友"
desc="换个关键词试试"
/>
</view>
</scroll-view>
</view>
</template>
<script>
import FriendItem from '../../components/FriendItem.vue'
import EmptyState from '../../components/EmptyState.vue'
import { GetFriends, GetFriendRequests, AcceptFriendRequest, RemoveFriend } from '../../common/api'
import themeMixin from '../../common/theme-mixin'
export default {
mixins: [themeMixin],
components: { FriendItem, EmptyState },
data() {
const menuBtn = uni.getMenuButtonBoundingClientRect()
return {
navPaddingTop: menuBtn.top + 'px',
keyword: '',
friends: [],
requests: [],
searchTimer: null
}
},
onLoad() {
this.loadData()
},
methods: {
goBack() {
uni.navigateBack()
},
async loadData() {
try {
const [friendsRes, reqRes] = await Promise.all([
GetFriends({}),
GetFriendRequests()
])
this.friends = friendsRes.data.list
this.requests = reqRes.data.list
} catch (e) {
console.warn('加载酒友失败', e)
}
},
onSearchInput() {
clearTimeout(this.searchTimer)
this.searchTimer = setTimeout(() => this.doSearch(), 300)
},
async doSearch() {
try {
const res = await GetFriends({ keyword: this.keyword.trim() })
this.friends = res.data.list
} catch (e) { /* ignore */ }
},
clearSearch() {
this.keyword = ''
this.doSearch()
},
async acceptRequest(req) {
try {
await AcceptFriendRequest({ requestId: req.id })
this.requests = this.requests.filter(r => r.id !== req.id)
uni.showToast({ title: '已添加酒友 🍻', icon: 'none' })
this.loadData()
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
},
confirmRemove(friend) {
uni.showModal({
title: '删除酒友',
content: `确定要删除「${friend.nickname}」吗?`,
confirmText: '删除',
confirmColor: '#FF6B6B',
success: async (res) => {
if (res.confirm) {
try {
await RemoveFriend({ userId: friend.id })
this.friends = this.friends.filter(f => f.id !== friend.id)
uni.showToast({ title: '已删除', icon: 'none' })
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
},
inviteFriends() {
// 触发分享
uni.showToast({ title: '请点击右上角分享', icon: 'none' })
}
},
onShareAppMessage() {
return {
title: '来「干杯日记」一起记录饮酒生活吧!',
path: '/pages/index/index'
}
}
}
</script>
<style lang="scss" scoped>
.friends-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.friends-nav {
background: $bg-base;
padding-left: $sp-lg;
padding-right: $sp-lg;
}
.friends-nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
}
.friends-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $bg-card;
&:active { background: $bg-card-alt; }
}
.friends-back-icon {
font-size: $fs-xl;
color: $text-primary;
font-weight: $fw-bold;
}
.friends-nav-title {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.friends-nav-ph {
width: 64rpx;
}
/* 搜索 */
.friends-search {
padding: $sp-md $sp-lg;
}
.search-box {
display: flex;
align-items: center;
gap: $sp-sm;
padding: 0 $sp-lg;
height: 76rpx;
background: $bg-card;
border-radius: $radius-full;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
}
.search-icon {
font-size: $fs-sm;
}
.search-input {
flex: 1;
font-size: $fs-base;
color: $text-primary;
}
.search-placeholder {
color: $text-tertiary;
}
.search-clear {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.search-clear-icon {
font-size: $fs-lg;
color: $text-tertiary;
}
.friends-scroll {
flex: 1;
height: 0;
}
.friends-body {
padding: 0 $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + #{$sp-2xl});
}
.section-label {
display: block;
font-size: $fs-sm;
color: $text-tertiary;
font-weight: $fw-medium;
margin-bottom: $sp-md;
margin-top: $sp-lg;
}
/* 好友请求 */
.requests-section {
margin-bottom: $sp-lg;
}
.request-item {
display: flex;
align-items: center;
gap: $sp-md;
padding: $sp-lg;
background: rgba(232,168,56,0.05);
border: 1rpx solid rgba(232,168,56,0.12);
border-radius: $radius-lg;
margin-bottom: $sp-sm;
}
.request-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: $bg-elevated;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.request-avatar-text {
font-size: $fs-base;
font-weight: $fw-bold;
color: $amber;
}
.request-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.request-name {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.request-msg {
font-size: $fs-xs;
color: $text-tertiary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-accept {
padding: 8rpx 24rpx;
background: linear-gradient(135deg, $amber, $amber-deep);
border-radius: $radius-full;
flex-shrink: 0;
&:active { transform: scale(0.95); }
}
.request-accept-text {
font-size: $fs-sm;
font-weight: $fw-bold;
color: $text-on-amber;
}
/* 删除按钮 */
.friend-del {
padding: 8rpx 20rpx;
border-radius: $radius-full;
background: rgba(255,107,107,0.1);
&:active { background: rgba(255,107,107,0.2); }
}
.friend-del-text {
font-size: $fs-xs;
color: $coral;
}
</style>
+61 -15
View File
@@ -5,6 +5,7 @@
<view class="hero-bg">
<view class="hero-orb hero-orb-1"></view>
<view class="hero-orb hero-orb-2"></view>
<text class="hero-deco">🥃</text>
</view>
<!-- 右上角分享按钮避让胶囊 -->
<button class="hero-share-btn" open-type="share" :style="shareBtnStyle">
@@ -17,11 +18,16 @@
<text class="hero-avatar-icon">👤</text>
</view>
</view>
<view class="hero-info">
<text class="hero-greeting">{{ greeting }}</text>
<view class="hero-name-row">
<text class="hero-name">{{ user.nickname || '酒友' }}</text>
<view class="hero-level-tag">
<text class="hero-level-text">{{ drinkLevel }}</text>
</view>
<text class="hero-joined">{{ joinedDate }} 加入</text>
</view>
<text class="hero-joined">{{ joinedDate }} 加入 · 干杯日记</text>
</view>
</view>
</view>
@@ -219,6 +225,14 @@ export default {
const map = { baijiu: '白酒之魂', beer: '啤酒达人', wine: '红酒雅士', whisky: '威士忌行家', huangjiu: '黄酒知音', sake: '清酒爱好者', fruit: '果酒小清新', cocktail: '调酒师' }
return map[fav] || '多元品鉴师'
},
greeting() {
const h = new Date().getHours()
if (h < 6) return '🌙 夜深了,微醺正好'
if (h < 11) return '☀️ 早上好,新的一天'
if (h < 14) return '🌤 中午好,小酌怡情'
if (h < 18) return '🌇 下午好,来一杯?'
return '🌙 晚上好,今晚喝点?'
},
personaDesc() {
const days = this.stats.totalDays || 0
if (days === 0) return '开始记录你的第一杯吧'
@@ -335,7 +349,7 @@ export default {
return ACHIEVEMENTS[index % ACHIEVEMENTS.length].icon
},
goCircle() {
uni.showToast({ title: '酒友圈即将上线,敬请期待 🍻', icon: 'none', duration: 2000 })
uni.switchTab({ url: '/pages/circle/circle' })
},
goHome() {
uni.switchTab({ url: '/pages/index/index' })
@@ -390,7 +404,7 @@ export default {
/* === 沉浸式头部 === */
.hero-header {
position: relative;
padding: 0 $sp-lg 80rpx;
padding: 0 $sp-lg 64rpx;
overflow: hidden;
}
@@ -447,22 +461,32 @@ export default {
background: rgba(139,133,184,0.06);
}
.hero-deco {
position: absolute;
right: -20rpx;
bottom: -30rpx;
font-size: 200rpx;
opacity: 0.06;
transform: rotate(-15deg);
z-index: 1;
}
.hero-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
padding-top: $sp-lg;
padding-top: $sp-xl;
}
.avatar-ring {
width: 168rpx; height: 168rpx;
width: 128rpx; height: 128rpx;
border-radius: 50%;
padding: 4rpx;
flex-shrink: 0;
background: linear-gradient(160deg, rgba(232,168,56,0.7), rgba(232,168,56,0.2), rgba(139,133,184,0.3));
margin-bottom: $sp-xl;
box-shadow: 0 0 48rpx rgba(232,168,56,0.15);
box-shadow: 0 0 40rpx rgba(232,168,56,0.12);
}
.hero-avatar {
@@ -478,28 +502,49 @@ export default {
}
.hero-avatar-icon {
font-size: 72rpx;
font-size: 56rpx;
opacity: 0.5;
}
.hero-info {
flex: 1;
margin-left: $sp-xl;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.hero-greeting {
font-size: $fs-xs;
color: $text-tertiary;
margin-bottom: 14rpx;
letter-spacing: 1rpx;
}
.hero-name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14rpx;
margin-bottom: 16rpx;
}
.hero-name {
font-size: $fs-2xl;
font-weight: $fw-black;
color: $text-primary;
margin-bottom: $sp-md;
letter-spacing: -1rpx;
}
.hero-level-tag {
padding: 8rpx 24rpx;
background: rgba(232,168,56,0.08);
border: 1rpx solid rgba(232,168,56,0.18);
padding: 6rpx 18rpx;
background: rgba(232,168,56,0.10);
border: 1rpx solid rgba(232,168,56,0.20);
border-radius: $radius-full;
margin-bottom: $sp-md;
}
.hero-level-text {
font-size: $fs-xs;
font-size: 20rpx;
color: $amber;
font-weight: $fw-medium;
}
@@ -508,6 +553,7 @@ export default {
font-size: $fs-xs;
color: $text-tertiary;
opacity: 0.7;
line-height: $lh-normal;
}
/* === 悬浮数据卡片 === */
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB