build / build (push) Successful in 4m11s
- SpaceSide:点击「积分」可查看积分来源明细(发布/评论逐条带分值,分页), 4 个空间页(ModuleList/RecordDetail/RecordEdit/Timeline)传入 :points-userid - api:新增 pointsSourceList(/PointsSourceList);ProfessionDetail 改注为纯读 (阅读数由 VisitCreate 记足迹时 +1) - layout/App:管理员判定改用 BoaUser.type(1 超管 / 2 学校管理员)或区局账号, 后台管理入口落地栏目管理 #/module;App.vue 透传 user.type - ModuleBar/profession-ui:栏目条 auto 外边距只对「更多」项生效(上级栏目组项 的下拉包装层留在原位排队),项间距 6px→5px - 页脚备案信息更新为 ©2024-2026 豫ICP
483 lines
18 KiB
Vue
483 lines
18 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 个人空间侧栏(方案D原型 page-space 左列):
|
||
* panel 1 = space-head 蓝渐变头(字章头像/姓名/积分)+ space-statrow(累计记录/获赞/被访问)
|
||
* + points-wrap 积分进度条 + space-menu 栏目菜单(sm-item + 篇数徽章)
|
||
* panel 2 = 本月概览(ov-grid 四色,仅 full 模式)
|
||
* panel 3 = 最近访客(visitors 圆字章:默认一排,超过一排显示「查看全部访客」就地展开全部)
|
||
* mode:full=栏目列表·个人空间/我的首页完整版;mini=发布编辑、记录详情精简版(无统计行/进度条/本月概览)。
|
||
* space-points 可点击 → 弹「积分来源」弹窗(发布/评论逐条带分值,合计口径同 MyPoints)。
|
||
*/
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
/** 我的 / 他的 */
|
||
himWord?: string;
|
||
teacherName?: string;
|
||
points?: number | string;
|
||
/** 头像 URL(仅本人登录态可能有;他人显示姓名首字圆形) */
|
||
avatar?: string;
|
||
/** 该教师各栏目篇数:{ id, name, recordCount } */
|
||
modules?: any[];
|
||
/** 最近访客:{ visitorId, visitorName } */
|
||
visitors?: any[];
|
||
/** 侧栏访客一排展示个数(面板内宽 262px / 章 26px + 间距 8px = 7 个),超出折叠 */
|
||
visitorRowSize?: number;
|
||
/** 当前高亮栏目(个人首页无高亮传 null) */
|
||
activeMouid?: number | null;
|
||
/** 当前已处于「我的首页」时,首页链接呈当前态 */
|
||
homeCurrent?: boolean;
|
||
/** 空间聚合(SpaceOverview:recordTotal/likeTotal/visitTotal/month)——传入则显示统计行与本月概览 */
|
||
overview?: Record<string, any> | null;
|
||
/** full=完整版侧栏;mini=精简版(发布/详情页) */
|
||
mode?: "full" | "mini";
|
||
/** 积分归属教师 id(他人空间传其 userid;不传 = 当前登录人) */
|
||
pointsUserid?: number | null;
|
||
}>(),
|
||
{ himWord: "我的", teacherName: "", points: 0, avatar: "", modules: () => [], visitors: () => [], visitorRowSize: 7, activeMouid: null, homeCurrent: false, overview: null, mode: "full", pointsUserid: null }
|
||
);
|
||
|
||
import { ref, computed, watch } from "vue";
|
||
import { useRouter } from "vue-router";
|
||
import { Message } from "view-ui-plus";
|
||
import { professionApi } from "@/api/module/profession";
|
||
|
||
const router = useRouter();
|
||
|
||
const emit = defineEmits<{
|
||
(e: "home"): void;
|
||
(e: "module", m: any): void;
|
||
(e: "visitor", v: any): void;
|
||
}>();
|
||
|
||
/** 访客展开态:折叠=只显示一排;展开=全部 */
|
||
const visitorsOpen = ref(false);
|
||
watch(
|
||
() => props.visitors,
|
||
() => {
|
||
visitorsOpen.value = false;
|
||
}
|
||
);
|
||
|
||
/** 积分进度刻度(满刻度 1000,超出按 100%) */
|
||
function pointsWidth(): number {
|
||
const p = Number(props.points || 0);
|
||
return Math.max(4, Math.min(100, Math.round((p / 1000) * 100)));
|
||
}
|
||
function firstChar(name?: string): string {
|
||
return (name || "?").replace(/\s/g, "").slice(0, 1) || "?";
|
||
}
|
||
|
||
/* ============ 积分来源弹窗(点击 space-points 打开,分页) ============ */
|
||
const ptsShow = ref(false);
|
||
const ptsLoading = ref(false);
|
||
const ptsData = ref<Record<string, any> | null>(null);
|
||
const ptsPage = ref(1);
|
||
const ptsPageSize = 8;
|
||
|
||
async function loadPoints() {
|
||
ptsLoading.value = true;
|
||
try {
|
||
ptsData.value =
|
||
(await professionApi.pointsSourceList(props.pointsUserid ?? undefined, ptsPage.value, ptsPageSize).send()) ||
|
||
{};
|
||
} catch (e: any) {
|
||
Message.error(e?.message || "获取积分来源失败");
|
||
ptsShow.value = false;
|
||
} finally {
|
||
ptsLoading.value = false;
|
||
}
|
||
}
|
||
/** 每次打开都重置到第一页重拉(积分随发布/评论实时变) */
|
||
watch(ptsShow, v => {
|
||
if (!v) return;
|
||
ptsPage.value = 1;
|
||
loadPoints();
|
||
});
|
||
|
||
const ptsRows = computed<any[]>(() => ptsData.value?.rows || []);
|
||
const ptsTotal = computed(() => Number(ptsData.value?.total ?? 0));
|
||
/** 与 ModuleList 同款页码序列:前 5 页 + 尾页(…) */
|
||
const ptsPages = computed(() => {
|
||
const tp = Math.max(1, Math.ceil(ptsTotal.value / ptsPageSize));
|
||
const arr: number[] = [];
|
||
for (let i = 1; i <= Math.min(tp, 5); i++) arr.push(i);
|
||
return { arr, tp, hasMore: tp > 5 };
|
||
});
|
||
function ptsGo(p: number) {
|
||
if (p < 1 || p > ptsPages.value.tp || p === ptsPage.value) return;
|
||
ptsPage.value = p;
|
||
loadPoints();
|
||
}
|
||
|
||
/** 评论内容摘要(去换行,截 40 字) */
|
||
function ptsExcerpt(s?: string): string {
|
||
const text = String(s || "").replace(/\s+/g, " ").trim();
|
||
return text.length > 40 ? text.slice(0, 40) + "…" : text;
|
||
}
|
||
function goRecord(recordId?: number) {
|
||
if (!recordId) return;
|
||
ptsShow.value = false;
|
||
router.push({ path: "/record/detail", query: { serid: recordId } });
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<aside class="col-side space-side">
|
||
<!-- ============ 个人空间卡 ============ -->
|
||
<section class="panel">
|
||
<div class="space-head">
|
||
<img v-if="avatar" class="space-avatar" :src="avatar" alt="" />
|
||
<div v-else class="space-avatar">{{ firstChar(teacherName) }}</div>
|
||
<div class="space-name">{{ teacherName || "—" }}</div>
|
||
<a class="space-points" href="javascript:void(0)" title="查看积分来源" @click.prevent="ptsShow = true">积分 <b>{{ points || 0 }}</b> 分</a>
|
||
<a class="space-home-link" :class="{ current: homeCurrent }" href="javascript:void(0)" @click.prevent="emit('home')">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.5L12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/><path d="M10 21v-6h4v6"/></svg>
|
||
{{ himWord }}首页
|
||
</a>
|
||
</div>
|
||
<div v-if="mode === 'full' && overview" class="space-statrow">
|
||
<div class="ss"><b>{{ overview.recordTotal ?? 0 }}</b><span>累计记录</span></div>
|
||
<div class="ss"><b>{{ overview.likeTotal ?? 0 }}</b><span>获赞</span></div>
|
||
<div class="ss"><b>{{ overview.visitTotal ?? 0 }}</b><span>被访问</span></div>
|
||
</div>
|
||
<div v-if="mode === 'full'" class="points-wrap">
|
||
<div class="points-lab"><span>成长积分</span><b>{{ points || 0 }} / 1000</b></div>
|
||
<div class="points-bar"><i :style="{ width: pointsWidth() + '%' }"></i></div>
|
||
<div class="points-hint">积分随发布与互动累积</div>
|
||
</div>
|
||
<div class="space-menu">
|
||
<div
|
||
v-for="m in modules"
|
||
:key="m.id"
|
||
class="sm-item"
|
||
:class="{ on: m.id === activeMouid }"
|
||
@click="emit('module', m)"
|
||
>
|
||
{{ m.name }}<span class="ct">{{ m.recordCount ?? 0 }}</span>
|
||
</div>
|
||
<div v-if="!modules.length" class="sm-empty">暂无栏目数据</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ============ 本月概览(仅完整版) ============ -->
|
||
<section v-if="mode === 'full' && overview && overview.month" class="panel">
|
||
<div class="panel-hd"><h3>本月概览</h3><span class="ext">{{ new Date().getMonth() + 1 }}月</span></div>
|
||
<div class="ov-grid">
|
||
<div class="ov-item"><b style="color:#2b82ec"><i style="background:#2b82ec"></i>{{ overview.month.publish ?? 0 }}</b><span>本月发布</span></div>
|
||
<div class="ov-item"><b style="color:#21b8a6"><i style="background:#21b8a6"></i>{{ overview.month.comments ?? 0 }}</b><span>新获评论</span></div>
|
||
<div class="ov-item"><b style="color:#8b6ff0"><i style="background:#8b6ff0"></i>{{ overview.month.reads ?? 0 }}</b><span>新增阅读</span></div>
|
||
<div class="ov-item"><b style="color:#f0a13a"><i style="background:#f0a13a"></i>{{ overview.month.visitors ?? 0 }}</b><span>新访客</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ============ 最近访客(默认一排;超过一排显示「查看全部访客」就地展开) ============ -->
|
||
<section class="panel">
|
||
<div class="panel-hd"><h3>最近访客</h3></div>
|
||
<div class="visitors" :class="{ open: visitorsOpen }">
|
||
<a
|
||
v-for="(v, i) in visitorsOpen ? visitors : visitors.slice(0, visitorRowSize)"
|
||
:key="i"
|
||
class="v"
|
||
href="javascript:void(0)"
|
||
:title="v.visitorName"
|
||
@click.prevent="v.visitorId && emit('visitor', v)"
|
||
>{{ firstChar(v.visitorName) }}</a>
|
||
<span v-if="!visitors.length" class="sm-empty" style="padding:4px 0 12px">暂无访客</span>
|
||
</div>
|
||
<a
|
||
v-if="visitors.length > visitorRowSize"
|
||
class="v-more"
|
||
href="javascript:void(0)"
|
||
@click.prevent="visitorsOpen = !visitorsOpen"
|
||
>{{ visitorsOpen ? "收起访客 ▴" : `查看全部访客(${visitors.length})▾` }}</a>
|
||
</section>
|
||
|
||
<!-- ============ 积分来源弹窗(点击 space-points 打开;Teleport 挂 body 避侧栏 overflow 裁剪) ============ -->
|
||
<Teleport to="body">
|
||
<div v-if="ptsShow" class="pts-overlay" @click.self="ptsShow = false">
|
||
<div class="pts-modal">
|
||
<div class="pts-hd">
|
||
<h3>积分来源</h3>
|
||
<span class="pts-total"><b>{{ ptsData?.pointsnum ?? (points || 0) }}</b> 分</span>
|
||
<a class="pts-close" href="javascript:void(0)" @click.prevent="ptsShow = false">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||
</a>
|
||
</div>
|
||
<div class="pts-sub">
|
||
<span class="p-item"><i class="dot pub"></i>发布积分 <b>{{ ptsData?.sendPoints ?? 0 }}</b></span>
|
||
<span class="p-item"><i class="dot com"></i>评论积分 <b>{{ ptsData?.commPoints ?? 0 }}</b></span>
|
||
<span class="p-item hint">积分随发布与互动累积</span>
|
||
</div>
|
||
<div class="pts-body">
|
||
<div v-if="ptsLoading" class="pts-empty">加载中…</div>
|
||
<div v-else-if="!ptsRows.length" class="pts-empty">暂无积分来源,发布记录或参与互动即可累积积分</div>
|
||
<template v-else>
|
||
<div
|
||
v-for="r in ptsRows"
|
||
:key="r.type + '-' + r.id"
|
||
class="pts-row"
|
||
:title="'查看记录:' + r.title"
|
||
@click="goRecord(r.recordId)"
|
||
>
|
||
<span class="pts-tag" :class="r.type">{{ r.type === "publish" ? "发布" : "评论" }}</span>
|
||
<div class="pts-main">
|
||
<div class="pts-title">
|
||
{{ r.type === "publish" ? r.title : `评《${r.title}》:${ptsExcerpt(r.contents)}` }}
|
||
</div>
|
||
<div class="pts-meta">
|
||
<span v-if="r.type === 'publish' && r.moduleName" class="mod">{{ r.moduleName }}</span>
|
||
<span>{{ r.createTime }}</span>
|
||
</div>
|
||
</div>
|
||
<b class="pts-val">+{{ r.points ?? 0 }}</b>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<!-- 分页(复用全局 .pagination/.pbtn;仅超一页显示) -->
|
||
<div v-if="ptsPages.tp > 1" class="pagination pts-pager">
|
||
<span>共 {{ ptsTotal }} 条</span>
|
||
<span class="pbtn" @click="ptsGo(ptsPage - 1)">‹</span>
|
||
<span
|
||
v-for="p in ptsPages.arr"
|
||
:key="p"
|
||
class="pbtn"
|
||
:class="{ on: p === ptsPage }"
|
||
@click="ptsGo(p)"
|
||
>{{ p }}</span>
|
||
<span v-if="ptsPages.hasMore" class="pbtn">…</span>
|
||
<span v-if="ptsPages.hasMore" class="pbtn" :class="{ on: ptsPage === ptsPages.tp }" @click="ptsGo(ptsPages.tp)">{{ ptsPages.tp }}</span>
|
||
<span class="pbtn" @click="ptsGo(ptsPage + 1)">›</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</aside>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* col-side / panel / space-head / sm-item / visitors 等主样式由全局 profession-ui.css 提供 */
|
||
.space-side {
|
||
width: 296px;
|
||
flex: none;
|
||
}
|
||
.space-head img.space-avatar {
|
||
object-fit: cover;
|
||
}
|
||
/* 空间头内的首页入口(挂在积分下方):全页面统一玻璃半透明胶囊(用户指定第二种样式),不再区分 current 态 */
|
||
.space-home-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
margin-top: 9px;
|
||
font-size: 12px;
|
||
color: #fff;
|
||
background: rgba(255, 255, 255, 0.26);
|
||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||
border-radius: 999px;
|
||
padding: 3px 12px;
|
||
transition: all 0.2s;
|
||
}
|
||
.space-home-link:hover {
|
||
background: rgba(255, 255, 255, 0.38);
|
||
}
|
||
.sm-empty {
|
||
padding: 10px 12px 12px;
|
||
font-size: 12px;
|
||
color: var(--muted, #8fa9c4);
|
||
}
|
||
/* 积分行可点击(查看积分来源):a 元素继承原 div 版式,hover 提亮 + 数字下划线示意可点 */
|
||
a.space-points {
|
||
display: block;
|
||
cursor: pointer;
|
||
}
|
||
a.space-points:hover {
|
||
color: #fff;
|
||
}
|
||
a.space-points:hover b {
|
||
text-decoration: underline;
|
||
text-underline-offset: 3px;
|
||
}
|
||
|
||
/* ============ 积分来源弹窗(方案D 规范:极细边线 + 极轻阴影 + 衬线数字) ============ */
|
||
.pts-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1200;
|
||
background: rgba(28, 40, 60, 0.42);
|
||
display: grid;
|
||
place-items: center;
|
||
animation: pts-fade 0.18s var(--ease);
|
||
}
|
||
@keyframes pts-fade {
|
||
from { opacity: 0; }
|
||
}
|
||
.pts-modal {
|
||
width: 520px;
|
||
max-width: calc(100vw - 48px);
|
||
max-height: 74vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--card);
|
||
border-radius: 14px;
|
||
box-shadow: 0 18px 50px rgba(16, 40, 80, 0.28);
|
||
overflow: hidden;
|
||
animation: pts-pop 0.22s var(--ease);
|
||
}
|
||
@keyframes pts-pop {
|
||
from { opacity: 0; transform: translateY(10px) scale(0.98); }
|
||
}
|
||
.pts-hd {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 16px 18px 12px;
|
||
border-bottom: 1px solid var(--line-2);
|
||
}
|
||
.pts-hd h3 {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: var(--ink);
|
||
padding-left: 9px;
|
||
border-left: 3px solid var(--navy);
|
||
line-height: 1.2;
|
||
}
|
||
.pts-total {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: var(--ink-2);
|
||
}
|
||
.pts-total b {
|
||
font-family: var(--font-d);
|
||
font-size: 19px;
|
||
color: var(--gold);
|
||
margin-right: 2px;
|
||
}
|
||
.pts-close {
|
||
color: var(--muted);
|
||
display: grid;
|
||
place-items: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 7px;
|
||
transition: all 0.15s;
|
||
}
|
||
.pts-close:hover {
|
||
color: var(--navy);
|
||
background: var(--sky);
|
||
}
|
||
.pts-sub {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
padding: 10px 18px;
|
||
background: var(--bg);
|
||
border-bottom: 1px solid var(--line-2);
|
||
font-size: 12px;
|
||
color: var(--ink-2);
|
||
}
|
||
.pts-sub .p-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
}
|
||
.pts-sub .p-item b {
|
||
font-family: var(--font-d);
|
||
font-size: 14px;
|
||
color: var(--navy);
|
||
}
|
||
.pts-sub .p-item.hint {
|
||
margin-left: auto;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
}
|
||
.pts-sub .dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
}
|
||
.pts-sub .dot.pub {
|
||
background: var(--navy);
|
||
}
|
||
.pts-sub .dot.com {
|
||
background: #21b8a6;
|
||
}
|
||
.pts-body {
|
||
overflow-y: auto;
|
||
padding: 6px 10px 12px;
|
||
}
|
||
/* 弹窗内分页条(全局 .pagination 的内嵌变体:贴底、细上边线) */
|
||
.pts-pager {
|
||
border-top: 1px solid var(--line-2);
|
||
padding: 10px 16px;
|
||
flex: none;
|
||
}
|
||
.pts-empty {
|
||
padding: 34px 16px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
.pts-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 10px;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
transition: background 0.15s var(--ease);
|
||
}
|
||
.pts-row:hover {
|
||
background: var(--sky);
|
||
}
|
||
.pts-row + .pts-row {
|
||
margin-top: 2px;
|
||
}
|
||
.pts-tag {
|
||
flex: none;
|
||
width: 38px;
|
||
text-align: center;
|
||
font-size: 11px;
|
||
line-height: 20px;
|
||
border-radius: 6px;
|
||
background: var(--sky);
|
||
color: var(--navy-2);
|
||
}
|
||
.pts-tag.comment {
|
||
background: #e6f7f4;
|
||
color: #159a89;
|
||
}
|
||
.pts-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.pts-title {
|
||
font-size: 13px;
|
||
color: var(--ink);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.pts-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 2px;
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
}
|
||
.pts-meta .mod {
|
||
color: var(--navy-2);
|
||
background: var(--sky);
|
||
border-radius: 4px;
|
||
padding: 0 6px;
|
||
font-size: 10.5px;
|
||
line-height: 17px;
|
||
}
|
||
.pts-val {
|
||
flex: none;
|
||
font-family: var(--font-d);
|
||
font-size: 15px;
|
||
color: var(--gold);
|
||
}
|
||
</style>
|