feat(studio): 用户向前端——消息中心 + 个人中心(闭合评审 L-1 消息无入口 / Profile 缺失)
- 消息中心(views/message):消费 community.yaml 玩家侧端点(messages 列表/mark-read/unread-count),类型筛选+未读红点+标记已读+游标翻页+诚实空态;TabBar 底部 3→4 tab 加"消息"一级入口 - 个人中心(views/profile):账号信息(passport /me)+创作成长(community /level)+功能入口+退出登录(复用既有 logout() 不重实现);"我的"tab 指向 /profile - 支撑:api/community.ts + store/message.ts + mock/community.ts(沿用每模块带 mock 约定)+ 契约 VO 入 types.ts;router 加 /message /profile(均 requiresAuth) verify-as-you-go:vite build --mode staging ✓ + vue-tsc EXIT=0;消费端点逐条核验在 community.yaml(:41/:69/:90/:114)+passport.yaml(:100)契约内;真机走查留合并点 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2650347840
commit
3147b11d60
92
game-studio/src/api/community.ts
Normal file
92
game-studio/src/api/community.ts
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* community 模块 API(严格贴 contracts/api-schemas/community.yaml)。
|
||||
* 端:/app-api/community/*(产品端 game-studio 用到的 4 个 app 读端点;
|
||||
* 用户 Token,Mapper 强制 getLoginUserId() 归属谓词——用户只见自己数据,归属隔离在后端可信边界 enforce)。
|
||||
*
|
||||
* 设计要点(与 feed/passport 等既有 api 文件同范式):
|
||||
* - 复用 ./request 的 huijing CommonResult 信封解包:拦截器已把 {code,data,msg} 解包为 data,
|
||||
* 故各函数返回 Promise<解包后的 data 类型>。
|
||||
* - 仅封装产品端「我的消息中心 / 未读角标 / 标记已读 / 我的创作者等级 / 我的奖励」读端点;
|
||||
* /rpc-api(CommunityNotifyApi,上游同进程投递)与 /admin-api(staging mock-trigger)非产品端,前端不封装。
|
||||
* - 未登录访问返回 401,由 request.ts 401 拦截统一清态跳登录(玩家区不主动拉消息,登录态页才调)。
|
||||
*/
|
||||
import { request } from './request'
|
||||
import type {
|
||||
CommunityMessagePage,
|
||||
CommunityUnreadCount,
|
||||
CommunityLevel,
|
||||
CommunityReward,
|
||||
CommunityMsgType,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* 站内信分页列表(P-NTF-01)。
|
||||
* GET /app-api/community/messages?type&readStatus&cursor&size
|
||||
* 按已读态 + 创建时间倒序;归属隔离由后端 Mapper 强制 user_id=getLoginUserId()。
|
||||
* @param params 可选筛选与分页:type(1系统 2公告 3审核 4收益)/ readStatus(0未读 1已读)/ cursor(上页末条 id)/ size(默认 20)
|
||||
*/
|
||||
export function getMessages(params?: {
|
||||
type?: CommunityMsgType
|
||||
readStatus?: 0 | 1
|
||||
cursor?: number
|
||||
size?: number
|
||||
}): Promise<CommunityMessagePage> {
|
||||
return request<CommunityMessagePage>({
|
||||
url: '/app-api/community/messages',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记单条已读(P-NTF-01)。
|
||||
* POST /app-api/community/messages/{id}/read
|
||||
* 后端落 read_status=1 + read_time;非本人消息拒绝(1-107-001-***,由拦截器 Toast)。
|
||||
* @param id 消息 ID
|
||||
* @returns 是否标记成功
|
||||
*/
|
||||
export function markRead(id: number): Promise<boolean> {
|
||||
return request<boolean>({
|
||||
url: `/app-api/community/messages/${id}/read`,
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 未读数角标(P-NTF-01)。
|
||||
* GET /app-api/community/messages/unread-count
|
||||
* 返回总未读数 + 按四类分组未读数;供底部 Tab 红点与消息中心顶部分类角标用。
|
||||
* @returns { total, byType }
|
||||
*/
|
||||
export function getUnreadCount(): Promise<CommunityUnreadCount> {
|
||||
return request<CommunityUnreadCount>({
|
||||
url: '/app-api/community/messages/unread-count',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前创作者等级 + published_count + 下一里程碑进度(P-INC-01)。
|
||||
* GET /app-api/community/level
|
||||
* 归属隔离:creator_user_id=getLoginUserId();无记录返回初始化默认(level=1/count=0)。
|
||||
* 供个人中心「创作成长」卡展示。
|
||||
*/
|
||||
export function getLevel(): Promise<CommunityLevel> {
|
||||
return request<CommunityLevel>({
|
||||
url: '/app-api/community/level',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的奖励触发记录列表(P-INC-01)。
|
||||
* GET /app-api/community/rewards
|
||||
* 触发态(0待发放)/发放态(1已发放,M4)均可见;归属隔离同上。
|
||||
* (本波个人中心暂不主展奖励列表,留作后续二级页/follow-up,封装就位避免孤儿端点。)
|
||||
*/
|
||||
export function getRewards(): Promise<CommunityReward[]> {
|
||||
return request<CommunityReward[]>({
|
||||
url: '/app-api/community/rewards',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
@ -7,5 +7,7 @@ export * as aigcApi from './aigc'
|
||||
export * as runtimeApi from './runtime'
|
||||
export * as projectApi from './project'
|
||||
export * as telemetryApi from './telemetry'
|
||||
export * as communityApi from './community'
|
||||
export * as passportApi from './passport'
|
||||
export { request } from './request'
|
||||
export type * from './types'
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
* - runtime.yaml → RuntimePackage* / SessionStart* / SessionEnd*
|
||||
* - project.yaml → Project* / ProjectCreate* / ProjectDraft* / ProjectPublish*
|
||||
* - telemetry.yaml → Envelope* / EventBatch*
|
||||
* - community.yaml → CommunityMessage* / CommunityUnread* / CommunityLevel* / CommunityReward*
|
||||
* - passport.yaml → 见 src/api/passport.ts(PlayerMeResp 等就近定义在 api 文件内,范式一致)
|
||||
*/
|
||||
|
||||
/* ============================== feed.yaml ============================== */
|
||||
@ -396,3 +398,88 @@ export interface EventBatchResult {
|
||||
/** 本次批量受理回执 traceId */
|
||||
traceId: string
|
||||
}
|
||||
|
||||
/* ============================== community.yaml ============================== */
|
||||
|
||||
/**
|
||||
* 站内消息类型(community.yaml CommunityMessageRespVO.type):
|
||||
* 1 系统 / 2 公告 / 3 审核 / 4 收益。
|
||||
* 与契约 messages 列表的 type 枚举严格对齐,禁止发明契约外类型值。
|
||||
*/
|
||||
export type CommunityMsgType = 1 | 2 | 3 | 4
|
||||
|
||||
/** 已读状态(community.yaml readStatus):0 未读 / 1 已读 */
|
||||
export type CommunityReadStatus = 0 | 1
|
||||
|
||||
/** 站内消息(community.yaml CommunityMessageRespVO,逐字段镜像) */
|
||||
export interface CommunityMessage {
|
||||
/** 消息 ID */
|
||||
id: number
|
||||
/** 消息类型:1系统 2公告 3审核 4收益 */
|
||||
type: CommunityMsgType
|
||||
/** 业务关联键(供前端跳转,本波仅展示) */
|
||||
bizRef?: string
|
||||
/** 消息标题 */
|
||||
title: string
|
||||
/** 消息正文(审核拒绝含原因) */
|
||||
content: string
|
||||
/** 已读状态:0未读 1已读 */
|
||||
readStatus: CommunityReadStatus
|
||||
/** 已读时间(未读时为 null) */
|
||||
readTime?: string | null
|
||||
/** 创建时间 */
|
||||
createTime: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 站内信分页结果(community.yaml /app-api/community/messages 200.data)。
|
||||
* 游标分页:nextCursor=null 表示无更多(与 feed 的空串模型不同,此处忠实贴 community 契约用 null)。
|
||||
*/
|
||||
export interface CommunityMessagePage {
|
||||
list: CommunityMessage[]
|
||||
/** 下一页游标,null=无更多 */
|
||||
nextCursor: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 未读数角标(community.yaml /app-api/community/messages/unread-count 200.data)。
|
||||
* total=总未读;byType=按四类分组(key=type 字符串 '1'/'2'/'3'/'4',value=count)。
|
||||
*/
|
||||
export interface CommunityUnreadCount {
|
||||
/** 总未读数 */
|
||||
total: number
|
||||
/** 按四类分组未读数(additionalProperties: integer) */
|
||||
byType: Record<string, number>
|
||||
}
|
||||
|
||||
/** 创作者等级 + 计数 + 下一里程碑进度(community.yaml CommunityLevelRespVO) */
|
||||
export interface CommunityLevel {
|
||||
/** 创作者等级:1小白(MVP) */
|
||||
level: number
|
||||
/** 已发布作品数(计数权威源) */
|
||||
publishedCount: number
|
||||
/** 末次已触发里程碑键(如 publish_3) */
|
||||
lastMilestone?: string
|
||||
/** 下一里程碑键(null=已达顶) */
|
||||
nextMilestone?: string | null
|
||||
/** 下一里程碑目标作品数(如 3,null=已达顶) */
|
||||
nextMilestoneTarget?: number | null
|
||||
}
|
||||
|
||||
/** 新人奖励触发记录(community.yaml CommunityRewardRespVO) */
|
||||
export interface CommunityReward {
|
||||
/** 奖励触发记录 ID */
|
||||
id: number
|
||||
/** 里程碑键(如 publish_3) */
|
||||
milestone: string
|
||||
/** 奖励类型:1流量包 2现金 */
|
||||
rewardType: number
|
||||
/** 奖励额度(流量包=曝光量;现金=分) */
|
||||
rewardAmount: number
|
||||
/** 发放状态:0已触发待发放(本波终态) 1已发放(M4) */
|
||||
status: number
|
||||
/** M4 发放回执(本波空) */
|
||||
grantedRef?: string
|
||||
/** 触发时间 */
|
||||
createTime: string
|
||||
}
|
||||
|
||||
@ -1,14 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TabBar —— 底部三入口(游戏流 /feed · 创作 /create · 我的 /project)。
|
||||
* 对应 spec §3.1 底部 TabBar。基于 vue-router 当前路由高亮,点击切换路由区。
|
||||
* TabBar —— 底部四入口(游戏流 /feed · 创作 /create · 消息 /message · 我的 /profile)。
|
||||
* 对应 spec §3.1 底部 TabBar;新增「消息」一级入口(闭合评审 L-1「消息无一级入口」)。
|
||||
* 基于 vue-router 当前路由高亮,点击切换路由区。
|
||||
* 在试玩/分享等全屏沉浸页(meta.hideTabBar)由 App.vue 控制隐藏,本组件只负责渲染与跳转。
|
||||
*
|
||||
* 未读角标:「消息」Tab 据 messageStore.unreadTotal 显示红点(>0 时),
|
||||
* 进入即拉一次未读数(onMounted),登录态变化由各页交互再刷新。
|
||||
*/
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMessageStore } from '../store/message'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
// 消息未读角标:与消息中心同源(store 单一数据源)
|
||||
const messageStore = useMessageStore()
|
||||
const { unreadTotal } = storeToRefs(messageStore)
|
||||
// 登录态:仅真实登录才拉未读(匿名/批跑态站内信端点 401,避免无谓 401 跳转)
|
||||
const userStore = useUserStore()
|
||||
|
||||
/** Tab 配置项 */
|
||||
interface TabItem {
|
||||
@ -16,24 +28,36 @@ interface TabItem {
|
||||
path: string
|
||||
/** 该 Tab 归属的路由前缀(用于高亮匹配,覆盖二级页) */
|
||||
match: string
|
||||
/** 额外高亮前缀(如「我的」同时覆盖 /project 二级页),可选 */
|
||||
extraMatch?: string
|
||||
/** 文案 */
|
||||
label: string
|
||||
/** 图标字符 */
|
||||
icon: string
|
||||
/** 是否显示未读红点(仅「消息」Tab) */
|
||||
showBadge?: boolean
|
||||
}
|
||||
|
||||
/** 三入口(顺序:游戏流 / 创作 / 我的) */
|
||||
/** 四入口(顺序:游戏流 / 创作 / 消息 / 我的)。
|
||||
* 「我的」指向个人中心 /profile,并额外高亮 /project(我的项目二级页归属「我的」)。 */
|
||||
const tabs: TabItem[] = [
|
||||
{ path: '/feed', match: '/feed', label: '游戏流', icon: '◎' },
|
||||
{ path: '/create', match: '/create', label: '创作', icon: '✦' },
|
||||
{ path: '/project', match: '/project', label: '我的', icon: '◰' },
|
||||
{ path: '/message', match: '/message', label: '消息', icon: '✉', showBadge: true },
|
||||
{ path: '/profile', match: '/profile', extraMatch: '/project', label: '我的', icon: '◰' },
|
||||
]
|
||||
|
||||
/** 当前激活 tab 的 match 前缀(按 route.path 命中最长前缀) */
|
||||
/** 判断某 tab 是否被当前 path 命中(match 或 extraMatch 的精确/前缀匹配) */
|
||||
function isHit(tab: TabItem, path: string): boolean {
|
||||
const prefixes = tab.extraMatch ? [tab.match, tab.extraMatch] : [tab.match]
|
||||
return prefixes.some((m) => path === m || path.startsWith(m + '/'))
|
||||
}
|
||||
|
||||
/** 当前激活 tab 的 match 前缀(按 route.path 命中) */
|
||||
const activeMatch = computed(() => {
|
||||
const path = route.path
|
||||
// 优先精确/前缀匹配;/feed 与 /play、/share 都归游戏流区由路由 meta.tab 也可控,这里按前缀简单判定
|
||||
const hit = tabs.find((t) => path === t.match || path.startsWith(t.match + '/'))
|
||||
// /feed 与 /play、/share 都归游戏流区(路由 meta.tab 亦可控,这里按前缀判定)
|
||||
const hit = tabs.find((t) => isHit(t, path))
|
||||
return hit?.match ?? ''
|
||||
})
|
||||
|
||||
@ -42,6 +66,16 @@ function go(tab: TabItem) {
|
||||
if (activeMatch.value === tab.match) return
|
||||
void router.push(tab.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入应用即拉一次未读角标(仅真实登录态)。
|
||||
* 匿名/staging env-token 态不拉:站内信端点要登录态,避免触发 401 清态跳登录(零中断)。
|
||||
*/
|
||||
onMounted(() => {
|
||||
if (userStore.isRealLogin()) {
|
||||
void messageStore.loadUnreadCount()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -53,7 +87,13 @@ function go(tab: TabItem) {
|
||||
:class="{ 'is-active': activeMatch === tab.match }"
|
||||
@click="go(tab)"
|
||||
>
|
||||
<span class="tabbar__icon">{{ tab.icon }}</span>
|
||||
<span class="tabbar__icon">
|
||||
{{ tab.icon }}
|
||||
<!-- 「消息」Tab 未读红点(unreadTotal>0 才显示;>99 显示 99+) -->
|
||||
<span v-if="tab.showBadge && unreadTotal > 0" class="tabbar__badge">
|
||||
{{ unreadTotal > 99 ? '99+' : unreadTotal }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="tabbar__label">{{ tab.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
@ -80,9 +120,29 @@ function go(tab: TabItem) {
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.tabbar__icon {
|
||||
position: relative;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
/* 未读红点角标(叠在图标右上角,pink 点缀,与消息中心同色) */
|
||||
.tabbar__badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 100%;
|
||||
margin-left: -6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #2a0610;
|
||||
background: var(--pink);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.tabbar__label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
158
game-studio/src/mock/community.ts
Normal file
158
game-studio/src/mock/community.ts
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* community 模块 mock handler(严格贴 contracts/api-schemas/community.yaml 响应结构)。
|
||||
* 覆盖产品端读端点(默认 dev/preview 离线可跑,与 feed/project 等同范式):
|
||||
* GET /app-api/community/messages → { list: CommunityMessageRespVO[], nextCursor }
|
||||
* POST /app-api/community/messages/:id/read → Boolean
|
||||
* GET /app-api/community/messages/unread-count → { total, byType }
|
||||
* GET /app-api/community/level → CommunityLevelRespVO
|
||||
* GET /app-api/community/rewards → CommunityRewardRespVO[]
|
||||
*
|
||||
* 假数据:固定 24 条站内信(四类均匀分布 + 部分已读),cursor 用「末条 id」编码(与契约游标语义一致)。
|
||||
* 标记已读会就地改本地状态,unread-count 据此实时派生(保证消息中心交互闭环可在 mock 下走通)。
|
||||
*/
|
||||
import type { MockRoute, MockRequest } from './shared'
|
||||
import { MockError } from './shared'
|
||||
import type {
|
||||
CommunityMessage,
|
||||
CommunityMessagePage,
|
||||
CommunityUnreadCount,
|
||||
CommunityLevel,
|
||||
CommunityReward,
|
||||
CommunityMsgType,
|
||||
} from '../api/types'
|
||||
|
||||
/** 四类消息标题/正文词库(拼出有辨识度的 demo 文案) */
|
||||
const TYPE_SAMPLES: Record<CommunityMsgType, { title: string; content: string }[]> = {
|
||||
1: [
|
||||
{ title: '欢迎来到绘境AI', content: '完善资料、发布你的第一个作品,开启创作之旅。' },
|
||||
{ title: '账号安全提醒', content: '检测到你在新设备登录,如非本人操作请及时修改密码。' },
|
||||
],
|
||||
2: [
|
||||
{ title: '平台公告', content: '本周末将进行系统维护,预计影响时长 30 分钟,敬请留意。' },
|
||||
{ title: '创作激励升级', content: '新人里程碑奖励规则已更新,发布作品最高可得流量包扶持。' },
|
||||
],
|
||||
3: [
|
||||
{ title: '作品审核通过', content: '你的作品《霓虹躲避》已通过审核,现已在游戏流上架。' },
|
||||
{ title: '作品审核未通过', content: '你的作品因封面含敏感元素未通过,请修改后重新提交。' },
|
||||
],
|
||||
4: [
|
||||
{ title: '收益到账提醒', content: '昨日广告分成 ¥3.20 已计入你的账户,可在收益页查看明细。' },
|
||||
{ title: '里程碑奖励触发', content: '恭喜达成「发布 3 款作品」里程碑,流量包奖励已触发待发放。' },
|
||||
],
|
||||
}
|
||||
|
||||
/** 生成第 idx 条消息(id 从 1 开始,倒序展示时大 id 在前)。 */
|
||||
function makeMessage(idx: number): CommunityMessage {
|
||||
// 四类轮转:1系统 2公告 3审核 4收益
|
||||
const type = (((idx - 1) % 4) + 1) as CommunityMsgType
|
||||
const samples = TYPE_SAMPLES[type]
|
||||
const sample = samples[(idx - 1) % samples.length]
|
||||
// 前 1/3(id 较大=较新)置为未读,其余已读,制造未读角标
|
||||
const isUnread = idx % 3 === 0
|
||||
// 创建时间:按 idx 递减(id 越大越新)
|
||||
const created = new Date(Date.now() - (24 - idx) * 3600_000)
|
||||
return {
|
||||
id: idx,
|
||||
type,
|
||||
bizRef: `mock-${type}-${idx}`,
|
||||
title: sample.title,
|
||||
content: sample.content,
|
||||
readStatus: isUnread ? 0 : 1,
|
||||
readTime: isUnread ? null : new Date(created.getTime() + 600_000).toISOString(),
|
||||
createTime: created.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/** 站内信全量(本地态,标记已读会就地改 readStatus)。倒序:id 大在前。 */
|
||||
const ALL_MESSAGES: CommunityMessage[] = Array.from({ length: 24 }, (_, i) => makeMessage(i + 1)).sort(
|
||||
(a, b) => b.id - a.id,
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /app-api/community/messages?type&readStatus&cursor&size
|
||||
* 游标分页:cursor=上页末条 id,取「id < cursor」的下一批;首页不传。
|
||||
*/
|
||||
function messagesHandler(req: MockRequest): CommunityMessagePage {
|
||||
const typeQ = req.query.type ? Number(req.query.type) : undefined
|
||||
const readQ = req.query.readStatus !== undefined && req.query.readStatus !== '' ? Number(req.query.readStatus) : undefined
|
||||
const size = req.query.size ? parseInt(req.query.size, 10) || 20 : 20
|
||||
const cursor = req.query.cursor ? parseInt(req.query.cursor, 10) : undefined
|
||||
|
||||
// 先按筛选条件过滤(贴契约:type/readStatus 服务端过滤)
|
||||
let filtered = ALL_MESSAGES
|
||||
if (typeQ !== undefined) filtered = filtered.filter((m) => m.type === typeQ)
|
||||
if (readQ !== undefined) filtered = filtered.filter((m) => m.readStatus === readQ)
|
||||
|
||||
// 游标:取 id < cursor 的项(列表已按 id 倒序)
|
||||
const after = cursor !== undefined ? filtered.filter((m) => m.id < cursor) : filtered
|
||||
const page = after.slice(0, size)
|
||||
const hasMore = after.length > size
|
||||
return {
|
||||
list: page,
|
||||
// 下一页游标 = 本页末条 id;无更多时 null(贴契约 nextCursor: nullable)
|
||||
nextCursor: hasMore && page.length > 0 ? page[page.length - 1].id : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /app-api/community/messages/:id/read —— 就地置已读(已读幂等返回 true) */
|
||||
function markReadHandler(req: MockRequest): boolean {
|
||||
const id = Number(req.params.id)
|
||||
const msg = ALL_MESSAGES.find((m) => m.id === id)
|
||||
if (!msg) {
|
||||
// 贴契约:非本人/不存在消息拒绝(community 站内信错误码段 1-107-001-***)
|
||||
throw new MockError(1107001001, '消息不存在或非本人消息')
|
||||
}
|
||||
if (msg.readStatus === 0) {
|
||||
msg.readStatus = 1
|
||||
msg.readTime = new Date().toISOString()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** GET /app-api/community/messages/unread-count —— 据本地态实时派生 */
|
||||
function unreadCountHandler(): CommunityUnreadCount {
|
||||
const byType: Record<string, number> = {}
|
||||
let total = 0
|
||||
for (const m of ALL_MESSAGES) {
|
||||
if (m.readStatus === 0) {
|
||||
total += 1
|
||||
byType[String(m.type)] = (byType[String(m.type)] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
return { total, byType }
|
||||
}
|
||||
|
||||
/** GET /app-api/community/level —— 创作者等级(mock 固定一组进度中数据) */
|
||||
function levelHandler(): CommunityLevel {
|
||||
return {
|
||||
level: 1,
|
||||
publishedCount: 2,
|
||||
lastMilestone: 'publish_1',
|
||||
nextMilestone: 'publish_3',
|
||||
nextMilestoneTarget: 3,
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /app-api/community/rewards —— 我的奖励触发记录(mock 一条已触发待发放) */
|
||||
function rewardsHandler(): CommunityReward[] {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
milestone: 'publish_1',
|
||||
rewardType: 1,
|
||||
rewardAmount: 10000,
|
||||
status: 0,
|
||||
grantedRef: '',
|
||||
createTime: new Date(Date.now() - 86400_000).toISOString(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** 导出本模块路由表(供 mock/index 汇总;具体 path 在 :param 之前,保证匹配优先级) */
|
||||
export const communityRoutes: MockRoute[] = [
|
||||
{ method: 'GET', pattern: '/app-api/community/messages/unread-count', handler: unreadCountHandler },
|
||||
{ method: 'GET', pattern: '/app-api/community/messages', handler: messagesHandler },
|
||||
{ method: 'POST', pattern: '/app-api/community/messages/:id/read', handler: markReadHandler },
|
||||
{ method: 'GET', pattern: '/app-api/community/level', handler: levelHandler },
|
||||
{ method: 'GET', pattern: '/app-api/community/rewards', handler: rewardsHandler },
|
||||
]
|
||||
@ -14,6 +14,7 @@ import { aigcRoutes } from './aigc'
|
||||
import { runtimeRoutes } from './runtime'
|
||||
import { projectRoutes } from './project'
|
||||
import { telemetryRoutes } from './telemetry'
|
||||
import { communityRoutes } from './community'
|
||||
|
||||
/** 全部 mock 路由(按模块拼接;模块内已排好 specific→param 顺序) */
|
||||
const ALL_ROUTES = [
|
||||
@ -22,6 +23,7 @@ const ALL_ROUTES = [
|
||||
...runtimeRoutes,
|
||||
...projectRoutes,
|
||||
...telemetryRoutes,
|
||||
...communityRoutes,
|
||||
]
|
||||
|
||||
/** huijing 统一包络结构 */
|
||||
|
||||
@ -87,7 +87,23 @@ const routes: RouteRecordRaw[] = [
|
||||
props: true,
|
||||
},
|
||||
|
||||
// —— 我的(项目)区 ——
|
||||
// —— 消息区(玩家侧站内信,闭合评审 L-1「消息无一级入口」)——
|
||||
{
|
||||
path: '/message',
|
||||
name: 'message',
|
||||
component: () => import('../views/message/Message.vue'), // L0 消息中心
|
||||
// requiresAuth:站内信为登录态私有数据(Mapper 强制 getLoginUserId 归属),未真实登录跳 /login(§9.3)
|
||||
meta: { title: '消息', tab: 'message', requiresAuth: true },
|
||||
},
|
||||
|
||||
// —— 我的(个人中心 + 项目)区 ——
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('../views/profile/Profile.vue'), // L0 个人中心
|
||||
// requiresAuth:个人中心含账号信息(passport.me),未真实登录跳 /login(§9.3)
|
||||
meta: { title: '我的', tab: 'project', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/project',
|
||||
name: 'project',
|
||||
|
||||
@ -12,3 +12,4 @@ export { useFeedStore } from './feed'
|
||||
export { useProjectStore } from './project'
|
||||
export { useCreateStore } from './create'
|
||||
export { useSessionStore } from './session'
|
||||
export { useMessageStore } from './message'
|
||||
|
||||
114
game-studio/src/store/message.ts
Normal file
114
game-studio/src/store/message.ts
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* useMessageStore —— 我的消息中心(站内信列表 + 未读角标 + 标记已读)。
|
||||
* 对应契约 community.yaml app 端三读端点:messages / messages/{id}/read / messages/unread-count。
|
||||
* 数据经 communityApi(严格贴 community.yaml),与 feed/project store 同范式(cursor 分页 + 乐观更新)。
|
||||
*
|
||||
* 职责边界(本波):
|
||||
* - state:list(消息列表)/ nextCursor(游标,null=到底)/ unreadTotal(总未读,供底部 Tab 红点)/ loading;
|
||||
* - actions:loadMessages(首屏/换筛选)/ loadMore(上拉翻页)/ markRead(单条已读,乐观更新 + 未读数自减)/ loadUnreadCount(拉角标)。
|
||||
* - 不做本地二次过滤:type/readStatus 筛选透传后端(与服务端一致)。
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as communityApi from '../api/community'
|
||||
import type { CommunityMessage, CommunityMsgType } from '../api/types'
|
||||
|
||||
export const useMessageStore = defineStore('message', () => {
|
||||
/** 当前消息列表(按已读态 + 创建时间倒序,后端排好) */
|
||||
const list = ref<CommunityMessage[]>([])
|
||||
/** 下一页游标(null=已到底;首屏不传) */
|
||||
const nextCursor = ref<number | null>(null)
|
||||
/** 总未读数(供底部 Tab 红点与顶部角标;与列表解耦,单独拉取) */
|
||||
const unreadTotal = ref<number>(0)
|
||||
/** 加载中标记(防并发重复拉取) */
|
||||
const loading = ref<boolean>(false)
|
||||
/** 当前类型筛选(undefined=全部;翻页时沿用) */
|
||||
const activeType = ref<CommunityMsgType | undefined>(undefined)
|
||||
|
||||
/** 单页条数(贴契约默认 20) */
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
/**
|
||||
* 内部:拉一页消息(首屏或翻页)。
|
||||
* @param reset true=首屏(清空重置,用当前 activeType);false=追加下一页
|
||||
*/
|
||||
async function fetchPage(reset: boolean) {
|
||||
if (loading.value) return
|
||||
// 翻页时若已到底(nextCursor=null 且非首屏)则不再请求
|
||||
if (!reset && nextCursor.value === null && list.value.length > 0) return
|
||||
loading.value = true
|
||||
try {
|
||||
const cursor = reset ? undefined : (nextCursor.value ?? undefined)
|
||||
const resp = await communityApi.getMessages({
|
||||
type: activeType.value,
|
||||
cursor,
|
||||
size: PAGE_SIZE,
|
||||
})
|
||||
list.value = reset ? resp.list : [...list.value, ...resp.list]
|
||||
nextCursor.value = resp.nextCursor
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载消息首屏(进入页面 / 切换类型筛选时调用)。
|
||||
* @param type 类型筛选(undefined=全部)
|
||||
*/
|
||||
async function loadMessages(type?: CommunityMsgType) {
|
||||
activeType.value = type
|
||||
nextCursor.value = null
|
||||
list.value = []
|
||||
await fetchPage(true)
|
||||
}
|
||||
|
||||
/** 加载下一页(上拉触底调用) */
|
||||
async function loadMore() {
|
||||
await fetchPage(false)
|
||||
}
|
||||
|
||||
/** 拉取未读角标(进入页面与标记后刷新;失败静默,不阻断列表) */
|
||||
async function loadUnreadCount() {
|
||||
try {
|
||||
const resp = await communityApi.getUnreadCount()
|
||||
unreadTotal.value = resp.total
|
||||
} catch {
|
||||
// 角标非关键路径,失败不影响主流程(保留旧值)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记单条已读 + 本地乐观更新(先翻转 UI + 未读数自减,再发请求,失败回滚)。
|
||||
* @param id 消息 ID
|
||||
*/
|
||||
async function markRead(id: number) {
|
||||
const msg = list.value.find((m) => m.id === id)
|
||||
// 已读的不重复操作(避免未读数误减)
|
||||
if (!msg || msg.readStatus === 1) return
|
||||
// 乐观:就地置已读 + 未读总数自减
|
||||
msg.readStatus = 1
|
||||
msg.readTime = new Date().toISOString()
|
||||
if (unreadTotal.value > 0) unreadTotal.value -= 1
|
||||
try {
|
||||
await communityApi.markRead(id)
|
||||
} catch (e) {
|
||||
// 失败回滚乐观更新,避免 UI 与服务端不一致
|
||||
msg.readStatus = 0
|
||||
msg.readTime = null
|
||||
unreadTotal.value += 1
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
nextCursor,
|
||||
unreadTotal,
|
||||
loading,
|
||||
activeType,
|
||||
loadMessages,
|
||||
loadMore,
|
||||
loadUnreadCount,
|
||||
markRead,
|
||||
}
|
||||
})
|
||||
398
game-studio/src/views/message/Message.vue
Normal file
398
game-studio/src/views/message/Message.vue
Normal file
@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Message.vue —— 「消息」中心(玩家侧站内信,底部 Tab “消息”,闭合评审 L-1「消息无一级入口」)。
|
||||
*
|
||||
* 闭环职责(严格贴 community.yaml app 三读端点):
|
||||
* - 进入即 loadMessages(全部) + loadUnreadCount → 列表 + 未读角标。
|
||||
* - 顶部类型筛选(全部/系统/公告/审核/收益),切换即按 type 重新拉取(透传后端,不前端二次过滤)。
|
||||
* - 每条按已读态区分:未读左侧红点高亮 + 标题加重;点击单条 → markRead(乐观置已读 + 未读数自减)。
|
||||
* - 上拉触底 loadMore(cursor 游标分页,nextCursor=null 到底)。
|
||||
*
|
||||
* 边缘失败(与既有视图同口径):
|
||||
* - 列表/角标请求失败 → request.ts 拦截器已统一 Toast,不白屏;此处仅在「非加载中且为空」时显示 EmptyState 诚实空态。
|
||||
* - 未登录 → request.ts 401 拦截统一清态跳登录(本页 requiresAuth,守卫已前置拦未登录访问)。
|
||||
*
|
||||
* 契约纪律:仅消费 store/message(→ api/community,严格贴 community.yaml),不直连后端、不发明端点。
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { showToast } from 'vant'
|
||||
import { useMessageStore } from '../../store/message'
|
||||
import { EmptyState, LoadingBar } from '../../components'
|
||||
import { MSG_TYPE_FILTERS, getMsgTypeMeta } from './msg-type'
|
||||
import type { CommunityMessage, CommunityMsgType } from '../../api/types'
|
||||
|
||||
const messageStore = useMessageStore()
|
||||
// 列表/游标/未读/加载态走 store 响应式引用,保持单一数据源
|
||||
const { list, nextCursor, unreadTotal, loading } = storeToRefs(messageStore)
|
||||
|
||||
/** 当前选中类型筛选(undefined=全部)。默认全部。 */
|
||||
const activeType = ref<CommunityMsgType | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* 拉取消息首屏 + 未读角标。
|
||||
* 失败不抛断页面:拦截器已 Toast,这里吞掉 reject 仅保证收尾。
|
||||
* @param type 类型筛选(undefined=全部)
|
||||
*/
|
||||
async function fetchList(type: CommunityMsgType | undefined) {
|
||||
try {
|
||||
await messageStore.loadMessages(type)
|
||||
await messageStore.loadUnreadCount()
|
||||
} catch {
|
||||
// 网络/业务错误已由 request.ts 统一 Toast,此处静默避免未捕获 rejection
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换类型筛选页签 → 重新拉取首屏 */
|
||||
function onSwitchType(type: CommunityMsgType | undefined) {
|
||||
if (activeType.value === type) return
|
||||
activeType.value = type
|
||||
fetchList(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击单条消息 → 若未读则标记已读(乐观更新)。
|
||||
* 本波仅「展开+标记已读」,bizRef 跳转留 follow-up(契约已带 bizRef,前端暂不路由)。
|
||||
* @param msg 被点击的消息
|
||||
*/
|
||||
async function onClickMessage(msg: CommunityMessage) {
|
||||
if (msg.readStatus === 1) return
|
||||
try {
|
||||
await messageStore.markRead(msg.id)
|
||||
} catch {
|
||||
// 标记失败已由 store 回滚 + 拦截器 Toast,无需额外处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 上拉触底加载下一页(nextCursor=null 即到底,store 内部已守卫不重复请求) */
|
||||
async function onReachBottom() {
|
||||
if (loading.value) return
|
||||
if (nextCursor.value === null && list.value.length > 0) return
|
||||
try {
|
||||
await messageStore.loadMore()
|
||||
} catch {
|
||||
// 翻页失败拦截器已 Toast
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动监听:接近底部时触发翻页(与 feed 上拉一致的轻量实现,避免引第三方滚动组件)。
|
||||
* 阈值 120px:滚动到距底部 120px 内即预加载下一页。
|
||||
*/
|
||||
function onScroll(ev: Event) {
|
||||
const el = ev.target as HTMLElement
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 120) {
|
||||
onReachBottom()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部标记已读(顶部快捷操作):逐条对未读项调 markRead(乐观更新已在 store 内)。
|
||||
* 仅在存在未读时可点;批量端点契约未提供,故前端按单条循环(条数有限,可接受)。
|
||||
*/
|
||||
async function onMarkAllRead() {
|
||||
const unread = list.value.filter((m) => m.readStatus === 0)
|
||||
if (unread.length === 0) return
|
||||
for (const m of unread) {
|
||||
try {
|
||||
await messageStore.markRead(m.id)
|
||||
} catch {
|
||||
// 单条失败 store 已回滚,继续处理其余
|
||||
}
|
||||
}
|
||||
showToast({ message: '已全部标为已读', type: 'success' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 简易时间格式化(仅列表展示用,与 Project.vue 同口径)。
|
||||
* @param iso ISO 时间串(可空)
|
||||
*/
|
||||
function formatTime(iso?: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
/** 首屏加载:默认拉「全部」+ 未读角标 */
|
||||
onMounted(() => {
|
||||
fetchList(activeType.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="msg-list" @scroll="onScroll">
|
||||
<!-- 顶部标题栏 + 未读数 + 全部已读入口 -->
|
||||
<header class="msg-list__head">
|
||||
<h1 class="msg-list__title">
|
||||
消息
|
||||
<!-- 总未读角标(>0 才显示,与底部 Tab 红点同源 unreadTotal) -->
|
||||
<span v-if="unreadTotal > 0" class="msg-list__badge">{{ unreadTotal > 99 ? '99+' : unreadTotal }}</span>
|
||||
</h1>
|
||||
<button v-if="unreadTotal > 0" class="msg-list__allread" @click="onMarkAllRead">全部已读</button>
|
||||
</header>
|
||||
|
||||
<!-- 类型筛选页签(横向滚动,覆盖 4 类 + 全部,与项目页筛选同风格) -->
|
||||
<nav class="msg-list__filters">
|
||||
<button
|
||||
v-for="f in MSG_TYPE_FILTERS"
|
||||
:key="String(f.value ?? 'all')"
|
||||
class="msg-list__filter"
|
||||
:class="{ 'is-active': activeType === f.value }"
|
||||
@click="onSwitchType(f.value)"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 加载条(首屏请求中显示不确定进度) -->
|
||||
<div v-if="loading && list.length === 0" class="msg-list__loading">
|
||||
<LoadingBar indeterminate />
|
||||
</div>
|
||||
|
||||
<!-- 列表主体 -->
|
||||
<main class="msg-list__body">
|
||||
<!-- 诚实空态:非加载中且无数据(玩家侧确无消息时如实展示,不造假数据) -->
|
||||
<EmptyState
|
||||
v-if="!loading && list.length === 0"
|
||||
icon="✉"
|
||||
title="还没有消息"
|
||||
description="系统通知、审核结果、收益提醒都会出现在这里"
|
||||
/>
|
||||
|
||||
<!-- 消息卡片列表 -->
|
||||
<ul v-else class="msg-list__items">
|
||||
<li
|
||||
v-for="m in list"
|
||||
:key="m.id"
|
||||
class="msg-card"
|
||||
:class="{ 'is-unread': m.readStatus === 0 }"
|
||||
@click="onClickMessage(m)"
|
||||
>
|
||||
<!-- 左侧类型图标(按 type 取 D4 色)+ 未读红点 -->
|
||||
<div class="msg-card__icon" :style="{ color: getMsgTypeMeta(m.type).color }">
|
||||
<span class="msg-card__icon-ch">{{ getMsgTypeMeta(m.type).icon }}</span>
|
||||
<span v-if="m.readStatus === 0" class="msg-card__dot" aria-label="未读"></span>
|
||||
</div>
|
||||
|
||||
<!-- 信息区 -->
|
||||
<div class="msg-card__info">
|
||||
<div class="msg-card__row">
|
||||
<h3 class="msg-card__title">{{ m.title }}</h3>
|
||||
<span class="msg-card__type" :style="{ color: getMsgTypeMeta(m.type).color }">
|
||||
{{ getMsgTypeMeta(m.type).label }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="msg-card__content">{{ m.content }}</p>
|
||||
<p class="msg-card__time">{{ formatTime(m.createTime) }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 翻页加载提示(非首屏的 loadMore 中) -->
|
||||
<div v-if="loading && list.length > 0" class="msg-list__more">
|
||||
<LoadingBar indeterminate />
|
||||
</div>
|
||||
<!-- 到底提示 -->
|
||||
<p v-else-if="nextCursor === null && list.length > 0" class="msg-list__end">没有更多了</p>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.msg-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
/* 本页自身滚动(用于上拉翻页监听);高度撑满父容器 */
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* 顶部标题栏 */
|
||||
.msg-list__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 8px;
|
||||
}
|
||||
.msg-list__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--fs-h2);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
/* 标题旁总未读角标(pink 点缀,与点赞类强调一致) */
|
||||
.msg-list__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #2a0610;
|
||||
background: var(--pink);
|
||||
border-radius: 9px;
|
||||
}
|
||||
/* 全部已读:弱化文本按钮 */
|
||||
.msg-list__allread {
|
||||
font-size: 13px;
|
||||
color: var(--cyan);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 类型筛选页签:横向可滚动(与项目页筛选同风格) */
|
||||
.msg-list__filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 6px 16px 12px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.msg-list__filters::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.msg-list__filter {
|
||||
flex: 0 0 auto;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.msg-list__filter.is-active {
|
||||
color: #04222a;
|
||||
background: var(--cyan);
|
||||
border-color: var(--cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.msg-list__loading {
|
||||
padding: 4px 16px 8px;
|
||||
}
|
||||
|
||||
.msg-list__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 卡片列表 */
|
||||
.msg-list__items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 12px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.msg-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.msg-card:active {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
/* 未读:左侧细边强调,背景略亮 */
|
||||
.msg-card.is-unread {
|
||||
border-left: 2px solid var(--cyan);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.msg-card__icon {
|
||||
position: relative;
|
||||
flex: 0 0 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.msg-card__icon-ch {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
/* 未读红点(右上角) */
|
||||
.msg-card__dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--pink);
|
||||
border: 1px solid var(--panel);
|
||||
}
|
||||
.msg-card__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.msg-card__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.msg-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 未读标题加重(视觉提示未读) */
|
||||
.msg-card.is-unread .msg-card__title {
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
.msg-card__type {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.msg-card__content {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
/* 正文最多两行,超出省略 */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg-card__time {
|
||||
margin: 6px 0 0;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 翻页/到底提示 */
|
||||
.msg-list__more {
|
||||
padding: 4px 16px 16px;
|
||||
}
|
||||
.msg-list__end {
|
||||
text-align: center;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
padding: 8px 0 20px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
53
game-studio/src/views/message/msg-type.ts
Normal file
53
game-studio/src/views/message/msg-type.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 站内消息类型展示元数据(消息中心列表/筛选共用)。
|
||||
*
|
||||
* 单一事实源:契约 community.yaml `CommunityMessageRespVO.type`
|
||||
* 1 系统 / 2 公告 / 3 审核 / 4 收益
|
||||
* 与 src/api/types.ts 的 CommunityMsgType 联合类型严格对齐,禁止发明契约外类型值。
|
||||
*
|
||||
* 颜色映射 D4 token(与项目内既有语义就近一致):
|
||||
* 系统 cyan(中性强调)/ 公告 violet(辅助强调)/ 审核 amber(进行/提示)/ 收益 green(正向)。
|
||||
*/
|
||||
import type { CommunityMsgType } from '../../api/types'
|
||||
|
||||
/** 单个消息类型的展示配置 */
|
||||
export interface MsgTypeMeta {
|
||||
/** 类型文案 */
|
||||
label: string
|
||||
/** 图标字符(与 TabBar/EmptyState 同走符号风,离线无依赖) */
|
||||
icon: string
|
||||
/** D4 token 颜色变量(CSS var(),用于图标/标签) */
|
||||
color: string
|
||||
}
|
||||
|
||||
/** 类型 → 展示元数据映射表(覆盖契约 4 类) */
|
||||
export const MSG_TYPE_META: Record<CommunityMsgType, MsgTypeMeta> = {
|
||||
1: { label: '系统', icon: '⚙', color: 'var(--cyan)' },
|
||||
2: { label: '公告', icon: '◈', color: 'var(--violet)' },
|
||||
3: { label: '审核', icon: '✓', color: 'var(--amber)' },
|
||||
4: { label: '收益', icon: '¥', color: 'var(--green)' },
|
||||
}
|
||||
|
||||
/** 默认兜底(遇契约外未知类型值时使用,保证 UI 不崩) */
|
||||
const FALLBACK_META: MsgTypeMeta = { label: '消息', icon: '✦', color: 'var(--muted)' }
|
||||
|
||||
/**
|
||||
* 按类型取展示元数据(含越界兜底)。
|
||||
* @param type 消息类型(可能为越界值,列表数据兜底)
|
||||
*/
|
||||
export function getMsgTypeMeta(type: number | undefined): MsgTypeMeta {
|
||||
if (type === undefined) return FALLBACK_META
|
||||
return MSG_TYPE_META[type as CommunityMsgType] ?? FALLBACK_META
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息中心顶部类型筛选项(含「全部」)。
|
||||
* value=undefined 表示不筛选(取全部);其余对齐契约 4 类。
|
||||
*/
|
||||
export const MSG_TYPE_FILTERS: Array<{ label: string; value: CommunityMsgType | undefined }> = [
|
||||
{ label: '全部', value: undefined },
|
||||
{ label: '系统', value: 1 },
|
||||
{ label: '公告', value: 2 },
|
||||
{ label: '审核', value: 3 },
|
||||
{ label: '收益', value: 4 },
|
||||
]
|
||||
362
game-studio/src/views/profile/Profile.vue
Normal file
362
game-studio/src/views/profile/Profile.vue
Normal file
@ -0,0 +1,362 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Profile.vue —— 「我的」个人中心(底部 Tab “我的”,闭合评审「个人中心缺失 Profile missing」)。
|
||||
*
|
||||
* 闭环职责:
|
||||
* - 账号信息:进入即 getMe → passport.me(昵称/脱敏手机号/头像/创作者态);
|
||||
* 失败兜底用 userStore 既有字段(mock 模式/passport 未接通时不白屏,诚实降级)。
|
||||
* - 创作成长:getLevel → community.level(等级 + 已发布数 + 下一里程碑进度),失败则隐藏该卡(非关键)。
|
||||
* - 功能入口:我的项目(/project)/ 消息(/message)二级导航;设置、帮助与反馈为占位项(本波未实现,置灰提示)。
|
||||
* - 退出登录:复用既有 userStore.logout()(与 Project.vue 同一登出能力,严禁重复实现清键逻辑)。
|
||||
*
|
||||
* 契约纪律:仅消费 api/passport(PlayerMeResp)+ api/community(CommunityLevel)+ store/user,不发明端点。
|
||||
* 边缘失败:getMe/getLevel 失败由 request.ts 统一 Toast;本页对账号信息做 store 兜底、对等级卡做隐藏降级。
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showConfirmDialog, showToast } from 'vant'
|
||||
import { getMe, type PlayerMeResp } from '../../api/passport'
|
||||
import * as communityApi from '../../api/community'
|
||||
import { useUserStore } from '../../store/user'
|
||||
import { useMessageStore } from '../../store/message'
|
||||
import { AppButton } from '../../components'
|
||||
import type { CommunityLevel } from '../../api/types'
|
||||
|
||||
const router = useRouter()
|
||||
// 登录态 store:账号信息兜底源 + 登出动作(复用既有 logout(),不重实现)
|
||||
const userStore = useUserStore()
|
||||
// 消息 store:用于「消息」入口的未读角标展示(与底部 Tab 红点同源)
|
||||
const messageStore = useMessageStore()
|
||||
|
||||
/**
|
||||
* 是否真实登录(仅 localStorage 真 token 才算,§9.2)。
|
||||
* 用作「退出登录」入口的唯一显隐判据:匿名态/staging env-token 态恒 false,不渲染登出入口(零中断)。
|
||||
*/
|
||||
const showLogout = userStore.isRealLogin()
|
||||
|
||||
/** 账号信息(优先 getMe 真数据;失败兜底 store)。null 表示尚未就绪(首屏短暂态)。 */
|
||||
const me = ref<PlayerMeResp | null>(null)
|
||||
/** 创作者等级(getLevel 成功才有;失败保持 null,对应卡隐藏) */
|
||||
const level = ref<CommunityLevel | null>(null)
|
||||
/** 账号信息加载中(仅控制头部骨架,失败即转兜底) */
|
||||
const meLoading = ref(true)
|
||||
|
||||
/**
|
||||
* 用 userStore 现有字段构造兜底账号信息(getMe 失败/mock 模式时用)。
|
||||
* 手机号 store 无持久化,兜底留空(passport.me 才回脱敏手机号)。
|
||||
*/
|
||||
function fallbackMe(): PlayerMeResp {
|
||||
return {
|
||||
userId: Number(userStore.userId) || 0,
|
||||
mobile: '',
|
||||
nickname: userStore.nickname,
|
||||
avatar: userStore.avatar,
|
||||
creatorFlag: userStore.creatorFlag,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取账号信息(getMe)。失败兜底 store 字段,保证头部不白屏。
|
||||
* getMe 失败时 request.ts 已 Toast(如 401 会清态跳登录);此处仅做展示兜底。
|
||||
*/
|
||||
async function fetchMe() {
|
||||
meLoading.value = true
|
||||
try {
|
||||
me.value = await getMe()
|
||||
} catch {
|
||||
// passport 未接通(mock 模式)或网络异常:用 store 兜底展示,不白屏
|
||||
me.value = fallbackMe()
|
||||
} finally {
|
||||
meLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取创作者等级(失败静默:等级卡隐藏,不影响个人中心主功能) */
|
||||
async function fetchLevel() {
|
||||
try {
|
||||
level.value = await communityApi.getLevel()
|
||||
} catch {
|
||||
// 非关键:community/level 未接通或异常时不展示等级卡
|
||||
level.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳「我的项目」(保留既有项目列表全功能,作为个人中心二级入口) */
|
||||
function goProjects() {
|
||||
router.push('/project')
|
||||
}
|
||||
|
||||
/** 跳「消息」中心 */
|
||||
function goMessages() {
|
||||
router.push('/message')
|
||||
}
|
||||
|
||||
/** 占位功能项点击(设置/帮助本波未实现,给可感知的提示而非静默) */
|
||||
function onPlaceholder(name: string) {
|
||||
showToast(`${name}功能即将上线`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录:二次确认 → 调既有 userStore.logout() 清本地会话 → replace 到游戏流。
|
||||
* 与 Project.vue onLogout 同口径(复用同一 logout(),不重复实现清键逻辑)。
|
||||
* 用 replace 而非 push:登出后不应能「返回」到受保护的个人中心(避免回退触发守卫再跳登录的抖动)。
|
||||
*/
|
||||
async function onLogout() {
|
||||
try {
|
||||
await showConfirmDialog({
|
||||
title: '退出登录',
|
||||
message: '退出后需重新登录才能查看你的项目与创作,确定退出吗?',
|
||||
confirmButtonText: '退出登录',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
// 用户点取消:showConfirmDialog 以 reject 表示,吞掉即可,不执行登出
|
||||
return
|
||||
}
|
||||
// 复用既有登出能力(清登录态 + localStorage 持久化态)
|
||||
userStore.logout()
|
||||
// 登出后回到零门槛的游戏流(玩家区无需登录)
|
||||
router.replace('/feed')
|
||||
}
|
||||
|
||||
/** 首屏加载:账号信息 + 创作者等级 + 未读角标(消息入口红点) */
|
||||
onMounted(() => {
|
||||
fetchMe()
|
||||
fetchLevel()
|
||||
// 拉未读数供「消息」入口角标(失败 store 内部已静默)
|
||||
void messageStore.loadUnreadCount()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile">
|
||||
<!-- 账号信息头部 -->
|
||||
<header class="profile__head">
|
||||
<!-- 头像(无头像用昵称首字底纹兜底) -->
|
||||
<div
|
||||
class="profile__avatar"
|
||||
:style="{ backgroundImage: me?.avatar ? `url(${me.avatar})` : undefined }"
|
||||
>
|
||||
<span v-if="!me?.avatar" class="profile__avatar-ph">{{ (me?.nickname || '游').slice(0, 1) }}</span>
|
||||
</div>
|
||||
<div class="profile__id">
|
||||
<h1 class="profile__nick">{{ me?.nickname || '游客' }}</h1>
|
||||
<!-- 脱敏手机号(passport.me 才有;兜底态留空不展示该行) -->
|
||||
<p v-if="me?.mobile" class="profile__mobile">{{ me.mobile }}</p>
|
||||
<!-- 创作者白名单标识(creatorFlag=1 才显示) -->
|
||||
<span v-if="me?.creatorFlag === 1" class="profile__tag">创作者</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 创作成长卡(community.level 成功才显示) -->
|
||||
<section v-if="level" class="profile__level">
|
||||
<div class="profile__level-row">
|
||||
<span class="profile__level-label">创作等级</span>
|
||||
<span class="profile__level-val">Lv.{{ level.level }}</span>
|
||||
</div>
|
||||
<div class="profile__level-row">
|
||||
<span class="profile__level-label">已发布作品</span>
|
||||
<span class="profile__level-val">{{ level.publishedCount }}</span>
|
||||
</div>
|
||||
<!-- 下一里程碑进度(有 nextMilestoneTarget 才显示) -->
|
||||
<p
|
||||
v-if="level.nextMilestoneTarget"
|
||||
class="profile__level-next"
|
||||
>
|
||||
距下个里程碑还差 {{ Math.max(0, level.nextMilestoneTarget - level.publishedCount) }} 个作品
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 功能入口列表 -->
|
||||
<nav class="profile__menu">
|
||||
<!-- 我的项目(保留既有项目列表全功能) -->
|
||||
<button class="profile__item" @click="goProjects">
|
||||
<span class="profile__item-icon">◰</span>
|
||||
<span class="profile__item-label">我的项目</span>
|
||||
<span class="profile__item-chevron">›</span>
|
||||
</button>
|
||||
<!-- 消息(与底部 Tab 同入口,带未读角标,方便从个人中心进入) -->
|
||||
<button class="profile__item" @click="goMessages">
|
||||
<span class="profile__item-icon">✉</span>
|
||||
<span class="profile__item-label">消息</span>
|
||||
<span v-if="messageStore.unreadTotal > 0" class="profile__item-badge">
|
||||
{{ messageStore.unreadTotal > 99 ? '99+' : messageStore.unreadTotal }}
|
||||
</span>
|
||||
<span class="profile__item-chevron">›</span>
|
||||
</button>
|
||||
<!-- 设置(占位,本波未实现) -->
|
||||
<button class="profile__item" @click="onPlaceholder('设置')">
|
||||
<span class="profile__item-icon">⚙</span>
|
||||
<span class="profile__item-label">设置</span>
|
||||
<span class="profile__item-chevron">›</span>
|
||||
</button>
|
||||
<!-- 帮助与反馈(占位,本波未实现) -->
|
||||
<button class="profile__item" @click="onPlaceholder('帮助与反馈')">
|
||||
<span class="profile__item-icon">?</span>
|
||||
<span class="profile__item-label">帮助与反馈</span>
|
||||
<span class="profile__item-chevron">›</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 退出登录(仅真实登录态显示;匿名/批跑 env-token 态不渲染) -->
|
||||
<div v-if="showLogout" class="profile__logout">
|
||||
<AppButton type="ghost" block @click="onLogout">退出登录</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* 账号信息头部 */
|
||||
.profile__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 24px 16px 20px;
|
||||
}
|
||||
.profile__avatar {
|
||||
flex: 0 0 60px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--panel-2);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.profile__avatar-ph {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
.profile__id {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.profile__nick {
|
||||
font-size: var(--fs-h2);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.profile__mobile {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
/* 创作者标识小徽标 */
|
||||
.profile__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
margin-top: 6px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
color: var(--cyan);
|
||||
border: 1px solid var(--cyan);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--cyan) 10%, transparent);
|
||||
}
|
||||
|
||||
/* 创作成长卡 */
|
||||
.profile__level {
|
||||
margin: 0 12px 12px;
|
||||
padding: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.profile__level-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.profile__level-label {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.profile__level-val {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
}
|
||||
.profile__level-next {
|
||||
margin: 6px 0 0;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 功能入口列表 */
|
||||
.profile__menu {
|
||||
margin: 0 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.profile__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 50px;
|
||||
padding: 0 14px;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
.profile__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.profile__item:active {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.profile__item-icon {
|
||||
flex: 0 0 22px;
|
||||
font-size: 17px;
|
||||
color: var(--cyan);
|
||||
text-align: center;
|
||||
}
|
||||
.profile__item-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.profile__item-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #2a0610;
|
||||
background: var(--pink);
|
||||
border-radius: 9px;
|
||||
}
|
||||
.profile__item-chevron {
|
||||
flex: 0 0 auto;
|
||||
font-size: 20px;
|
||||
color: var(--muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 退出登录 */
|
||||
.profile__logout {
|
||||
margin-top: auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user