Files
profession-front/src/views/profession/Timeline.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

262 lines
10 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">
import { ref, watch, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { professionApi, mouduleApi, baseApi } from "@/api/module/profession";
import SpaceSide from "@/components/SpaceSide.vue";
import CrumbTags from "@/components/CrumbTags.vue";
/**
* 教师个人空间 ·「我的首页」(专业发展足迹,方案D原型 page-timeline):
* 左侧栏(SpaceSide 完整版:蓝渐变头/统计行/积分进度/栏目菜单/本月概览/最近访客),
* 右主区 crumbs「我的/他的首页」+ panel 时间轴(year-block 年份圆点折叠 +
* tl-item:tdot/t-date/t-module【栏目】/t-title/t-excerpt 摘要)。
* ?userid= 空 = 自己;点条目进详情,点栏目进该教师栏目列表。
*/
const route = useRoute();
const router = useRouter();
const userid = ref<number | null>(null);
const isMine = ref(true);
const mineName = ref("");
const selfAvatar = ref("");
const himWord = ref("我的");
const spaceTeacher = ref<{ name: string; points: number }>({ name: "", points: 0 });
const spaceModules = ref<any[]>([]);
const spaceVisitors = ref<any[]>([]);
const space = ref<any>({ teacherName: "", points: 0, recordTotal: 0, visitTotal: 0, moduleCounts: [], month: {} });
const years = ref<any[]>([]);
const loading = ref(false);
const totalCount = () => years.value.reduce((s: number, y: any) => s + (y.itemlist?.length || 0), 0);
async function load() {
loading.value = true;
try {
years.value = (await professionApi.timeline(userid.value!).send()) || [];
} catch (e: any) {
Message.error(e.message || "获取时间轴失败");
} finally {
loading.value = false;
}
}
/** 个人空间侧栏数据(与栏目列表页同源) */
async function loadSpace(uid: number) {
try {
const p: any = await professionApi.myPoints(uid).send();
spaceTeacher.value = { name: p?.teacherName || "", points: p?.pointsnum ?? 0 };
} catch {
spaceTeacher.value = { name: "", points: 0 };
}
try {
spaceModules.value = (await mouduleApi.myModuleList(uid).send()) || [];
} catch {
spaceModules.value = [];
}
try {
space.value = (await professionApi.spaceOverview(uid).send()) || space.value;
} catch {
/* 概览失败忽略 */
}
try {
spaceVisitors.value = (await professionApi.visitList({ userid: uid, limit: 50 }).send()) || [];
} catch {
spaceVisitors.value = [];
}
}
/* ============ 年份折叠(默认全部展开,对齐老页面) ============ */
const openYears = ref<number[]>([]);
watch(years, v => {
openYears.value = v.map((y: any) => y.year);
});
function toggleYear(y: number) {
const i = openYears.value.indexOf(y);
if (i >= 0) openYears.value.splice(i, 1);
else openYears.value.push(y);
}
function isOpen(y: number) {
return openYears.value.includes(y);
}
/** 正文摘要:去 HTML 标签后截 120 字(原型 t-excerpt 两行截断) */
function excerpt(remark?: string) {
if (!remark) return "";
const text = String(remark).replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").trim();
return text.length > 120 ? text.slice(0, 120) + "…" : text;
}
/** 相对时间标签(时间元素):今天/昨天/N 天前/N 个月前/N 年前 */
function relTime(y: any, it: any): string {
const d = new Date(Number(y.year), (Number(it.month) || 1) - 1, Number(it.day) || 1);
const today = new Date();
today.setHours(0, 0, 0, 0);
const diff = Math.round((today.getTime() - d.getTime()) / 86400000);
if (diff <= 0) return "今天";
if (diff === 1) return "昨天";
if (diff < 30) return diff + " 天前";
if (diff < 365) return Math.round(diff / 30) + " 个月前";
return Math.round(diff / 365) + " 年前";
}
function goDetail(id: number) {
router.push({ path: "/record/detail", query: { serid: id } });
}
function goModuleList(m: any) {
if (!m?.id) return;
router.push({ path: "/module/list", query: { mouid: m.id, ...(userid.value ? { userid: userid.value } : {}) } });
}
function goVisitor(v: any) {
router.push({ path: "/timeline", query: { userid: v.visitorId } });
}
onMounted(async () => {
// 先取当前登录人,再判定空间归属(与栏目列表页一致:query userid = 登录人 → 我的)
let mineId: number | null = null;
try {
const u: any = await baseApi.getUserInfo().send();
mineId = u?.id ?? null;
mineName.value = u?.name || "";
selfAvatar.value = u?.profilePicture || "";
} catch {
mineId = null;
}
mineIdCache = mineId;
await applyRoute(mineId);
});
/** 路由参数应用(onMounted 与 watch 复用) */
let mineIdCache: number | null = null;
async function applyRoute(mineId: number | null) {
const q = Number(route.query.userid || 0);
userid.value = q || mineId;
isMine.value = userid.value != null && userid.value === mineId;
himWord.value = isMine.value ? "我的" : "他的";
if (!userid.value) {
Message.warning("未获取到用户信息");
return;
}
loadSpace(userid.value);
load();
}
/* 同组件路由复用(我的 ↔ 他的)时 onMounted 不重跑,需按 query.userid 重载 */
watch(
() => route.query.userid,
async () => {
if (route.name !== "teacher_timeline") return; // 已离开本页(组件卸载途中)
const q = Number(route.query.userid || 0);
const target = q || mineIdCache;
if (target === userid.value) return;
await applyRoute(mineIdCache);
}
);
</script>
<template>
<div class="main-grid">
<!-- ============ 个人空间侧栏(完整版) ============ -->
<SpaceSide
:him-word="himWord"
:avatar="isMine ? selfAvatar : ''"
:teacher-name="spaceTeacher.name || (isMine ? mineName : '—')"
:points="spaceTeacher.points"
:modules="spaceModules"
:visitors="spaceVisitors"
:active-mouid="null"
:overview="space"
:home-current="true"
:points-userid="userid"
@home="userid && router.push({ path: '/timeline', query: { userid } })"
@module="goModuleList"
@visitor="goVisitor"
/>
<!-- ============ 右主区:我的首页 · 发展足迹时间轴 ============ -->
<div class="col-main">
<CrumbTags :tags="[{ text: himWord + '首页' }]"></CrumbTags>
<section class="panel tl-panel">
<Spin v-if="loading" size="large" fix></Spin>
<!-- 面板头:沿用原型 panel-hd(左竖条 h3 + ext),右侧保留发布入口(仅本人) -->
<div class="panel-hd tl-panel-hd">
<h3>发展足迹<span class="tl-count">共 {{ totalCount() }} 条</span></h3>
</div>
<div class="tl-body">
<div class="timeline">
<div v-for="(y, yi) in years" :key="y.year" class="year-block">
<div class="year-hd" @click="toggleYear(y.year)">
<span class="ydot">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="4.5" width="18" height="16.5" rx="2.5"/><path d="M8 2.5v4M16 2.5v4M3 9.5h18"/></svg>
</span>
<h3>{{ y.year }} <i>年</i></h3>
<span class="ycnt">{{ (y.itemlist || []).length }} 篇</span>
<span class="yarrow" :class="{ closed: !isOpen(y.year) }">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</span>
</div>
<div v-show="isOpen(y.year)">
<div v-for="(it, ii) in y.itemlist" :key="it.id" class="tl-item" :style="{ '--i': ii }">
<span class="tdot"></span>
<div class="t-head">
<span class="t-date">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
{{ String(it.month).padStart(2, "0") }}-{{ String(it.day).padStart(2, "0") }}
<em>· {{ relTime(y, it) }}</em>
</span>
<span class="t-module">{{ it.moduleName || "—" }}</span>
</div>
<a class="t-title" @click="goDetail(it.id)">{{ it.title }}</a>
<div v-if="excerpt(it.remark)" class="t-excerpt">{{ excerpt(it.remark) }}</div>
</div>
</div>
</div>
<!-- 时间轴终点:「现在」脉冲节点 -->
<div v-if="years.length" class="tl-now">
<span class="ndot"></span><span>现在</span>
</div>
<div v-if="!years.length && !loading" class="tl-empty">
<Icon type="ios-time-outline" size="44"></Icon>
<p>暂无专业发展记录</p>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
/* main-grid / col-main / panel / crumbs / timeline / year-block / tl-item 等由全局 profession-ui.css 提供 */
.tl-panel {
position: relative;
}
.tl-panel-hd h3 {
display: flex;
align-items: baseline;
gap: 8px;
}
.tl-count {
font-family: var(--font-b, sans-serif);
font-size: 12px;
font-weight: 400;
color: var(--muted, #8fa9c4);
}
.tl-body {
padding: 14px 28px 22px;
}
.tl-empty {
padding: 56px 0 48px;
text-align: center;
color: #c5c8ce;
}
.tl-empty p {
margin: 10px 0 0;
font-size: 13px;
}
</style>