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:
zizi 2026-06-16 07:10:44 +00:00
parent 2650347840
commit 3147b11d60
12 changed files with 1355 additions and 10 deletions

View 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',
})
}

View File

@ -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'

View File

@ -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
}

View File

@ -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;
}

View 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 },
]

View File

@ -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 统一包络结构 */

View File

@ -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',

View File

@ -12,3 +12,4 @@ export { useFeedStore } from './feed'
export { useProjectStore } from './project'
export { useCreateStore } from './create'
export { useSessionStore } from './session'
export { useMessageStore } from './message'

View 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,
}
})

View 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>

View 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 },
]

View 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>