Files
profession-front/src/components/SpaceSide.vue
T
lijing afa9cef808
build / build (push) Successful in 4m11s
feat: 前台新增积分来源明细弹窗与管理员后台入口判定
- 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
2026-10-09 09:33:41 +08:00

483 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>