Files
UserhomeFront/src/components/widgets/WeekplanWidget.vue
T
chenxing 651e7e16f3
build / build (push) Successful in 48s
style(一周安排小组件): 列表区固定5条高度+滚动/空态等高、字号对齐旧版、备注与数据区分
- 列表区 .wk-list-area 固定 268px(旧版实测行距≈54px×5):超出滚动、空态同高,切「休/节假日」卡片高度不再跳(实测三态卡片高 554px 完全一致)
- 日期条圆点改绝对定位+常驻占位(visibility):修「整周无安排时日期条矮 6px」;今日蓝底方块不再把圆点盖成同色
- 字号对齐旧版:标题 14→15、星期 12→14、日期 13→15、日常详情 14→16、查看概览 12→13、列表内容/时间 12.5→15、meta 11.5→12.5、备注 12→13、空态 13→14
- 备注与安排数据区分:上分割线 + #f7f8fa 浅底块 + 6px 圆角
2026-10-15 21:25:31 +08:00

505 lines
19 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.
<template>
<div class="wg-weekplan" ref="refRoot">
<Skeleton
v-if="loading"
style="padding: 15px"
:loading="true"
:title="false"
:paragraph="{ rows: 4 }"
animated
/>
<!-- 门控用 hasWeek(有 title 的周对象)而非 hasData:空周(hasData=false)
也必须保留标题行 + 翻页箭头,否则用户翻进空周后没有出口 -->
<template v-else-if="hasWeek">
<div class="wk-title">
<!-- 显示用简称(日期区间 + 第N周);接口全称(如「学校工作安排」)留在 title 里 hover 可见 -->
<span :title="titleFull">{{ titleStr }}</span>
<!-- 翻页:边界由响应 minWeek/maxWeek 驱动(缺省按 [0,0] 禁用两侧);越界 oapi 返回 code:2,前端先拦 -->
<span class="pager">
<i :class="{ disabled: !canPrev }" @click="onPrev">‹</i>
<i :class="{ disabled: !canNext }" @click="onNext">›</i>
</span>
</div>
<div v-if="days.length > 0" class="wk-days">
<div
v-for="(day, index) in days"
:key="day.date"
:class="['d', { 'is-on': index === currentIndex, has: hasWork(index), rest: day.dayType === 1, holiday: day.dayType === 2 }]"
@click="onDayClick(index)"
>
<span>{{ day.weekday }}</span>
<b>{{ dayText(day) }}</b>
</div>
</div>
<div class="wk-tips-row">
<span class="wk-tips">日常详情</span>
<!-- 外跳地址由后端按「子应用 code + 路由」解析下发(应用注册表 pc_url),前端不写死域名 -->
<a
v-if="overviewUrl"
class="wk-overview"
:href="overviewUrl"
target="_blank"
rel="noopener"
>查看概览 ›</a>
</div>
<!-- 列表区固定高度(≈5 条单行):超出滚动;空态(休/节假日/无安排)同高,卡片高度不跳 -->
<div class="wk-list-area">
<ul v-if="currentList.length > 0" class="wk-list">
<li v-for="(item, index) in currentList" :key="index">
<div class="line">
<span class="content" :title="item.contents">{{ item.contents }}</span>
<span class="time">{{ item.times }}</span>
</div>
<div v-if="metaOf(item)" class="meta">{{ metaOf(item) }}</div>
</li>
</ul>
<div v-else class="no-data">{{ currentDayLabel }}</div>
</div>
<!-- 备注:默认 2 行截断,超出才给「展开/收起」;空备注整块不渲染 -->
<div v-if="remarkText" class="wk-remark">
<div ref="refRemark" class="rk-txt" :class="{ expanded: remarkExpanded }">{{ remarkText }}</div>
<span v-if="remarkOverflow" class="rk-more" @click="toggleRemark">{{ remarkExpanded ? "收起" : "展开" }}</span>
</div>
</template>
<!-- 空态:模板内联 SVG,不依赖外部图片 / CSS 背景 -->
<div v-else class="wg-empty">
<svg width="112" height="112" viewBox="0 0 210 242" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect opacity="0.01" x="0.5" y="0.5" width="209" height="209" stroke="#DCDEE0" stroke-dasharray="5" />
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M21 69.5625H47.25V171.938H24.9375H21H2.625V95.8125H21V69.5625Z" fill="#F2F3F5" />
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M105 36.5217C104.368 36.671 103.709 36.75 103.031 36.75C98.3196 36.75 94.5 32.9304 94.5 28.2188C94.5 23.721 97.9807 20.0361 102.395 19.7108C102.382 19.486 102.375 19.2594 102.375 19.0312C102.375 12.8698 107.37 7.875 113.531 7.875C118.555 7.875 122.803 11.1953 124.201 15.7611C124.362 15.7537 124.524 15.75 124.688 15.75C130.486 15.75 135.188 20.451 135.188 26.25C135.188 32.049 130.486 36.75 124.688 36.75C124.243 36.75 123.805 36.7224 123.375 36.6688V36.75H105V36.5217Z" fill="#F2F3F5" />
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M20.8304 43.8601C20.5247 43.9314 20.2058 43.969 19.878 43.969C17.5982 43.969 15.75 42.1461 15.75 39.8973C15.75 37.7506 17.4342 35.9919 19.5704 35.8367C19.5638 35.7293 19.5605 35.6211 19.5605 35.5121C19.5605 32.5714 21.9774 30.1875 24.9587 30.1875C27.3894 30.1875 29.4449 31.7722 30.1212 33.9512C30.1994 33.9477 30.2779 33.946 30.3569 33.946C33.1629 33.946 35.4376 36.1896 35.4376 38.9573C35.4376 41.725 33.1629 43.9687 30.3569 43.9687C30.1417 43.9687 29.9297 43.9555 29.7215 43.9299V43.9688H20.8304V43.8601Z" fill="#F2F3F5" />
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M190.616 19.6875V38.0625H202.125V139.609H161.438V38.0625V19.6875H190.616Z" fill="#F2F3F5" />
<rect y="182.438" width="210" height="27.5625" fill="#F7F8FA" />
<rect opacity="0.6" x="107.625" y="82.6875" width="47.25" height="69.5625" rx="2" fill="#DCDCDC" />
<rect x="57.75" y="65.625" width="84" height="86.625" rx="2" fill="#F2F3F5" />
<rect x="65.625" y="73.5" width="68.25" height="72.1875" rx="1" fill="#fff" />
<rect x="77.4375" y="87.9375" width="44.625" height="7.875" rx="1" fill="#EAEDF1" />
<rect x="77.4375" y="106.312" width="44.625" height="7.875" fill="#EAEDF1" />
<rect x="77.4375" y="124.688" width="44.625" height="7.875" rx="1" fill="#EAEDF1" />
<rect x="47.25" y="145.688" width="115.5" height="36.75" rx="1" fill="#EAEDF1" />
<rect x="85.3125" y="160.125" width="39.375" height="7.875" rx="1" fill="#F7F8FA" />
</svg>
<span class="txt">暂无数据</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
import UserSettingApi from "@/api/UserSettingApi";
defineProps<{ widgetId: number; overviewUrl?: string }>();
const emit = defineEmits(["loaded"]);
const refRoot = ref<HTMLElement | null>(null);
const refRemark = ref<HTMLElement | null>(null);
const loading = ref(true);
/**
* 接口 data:单个周对象(title/year/term/week/monday/days[]/remark/hasData/minWeek/maxWeek),非数组。
* 命名警示:形参 week(偏移量)曾与 ref week(响应体)同名遮蔽,严格模式下 `week.value=` 抛
* TypeError 且 catch 内再抛 ⇒ loading 永驻(v1 骨架卡死根因)⇒ ref 恒用 weekData,禁止再命名 week。
*/
const weekData = ref<any>({});
/** week 偏移:0=本周、-1=上一周、+1=下周(与数据字段 data.week 绝对周次同名不同义) */
const weekOffset = ref(0);
/** 合法偏移范围:每次响应刷新;老版本服务缺省时按 [0,0] 处理(宁可不翻,也不打越界 code:2) */
const minWeek = ref(0);
const maxWeek = ref(0);
const currentIndex = ref(0);
/** 备注展开态;remarkOverflow 只在折叠态测量(溢出才显示按钮) */
const remarkExpanded = ref(false);
const remarkOverflow = ref(false);
let observer: ResizeObserver | null = null;
const days = computed<any[]>(() => (Array.isArray(weekData.value.days) ? weekData.value.days : []));
/** 标题简称:日期区间 + 第N周(接口 title 往往是「××学校工作安排」这类长名,放到 hover) */
const titleStr = computed<string>(() => {
const list = days.value;
const week = Number(weekData.value.week);
const weekPart = Number.isFinite(week) && week > 0 ? `第${week}周` : "";
if (list.length >= 2) {
const range = `${monthDay(list[0])}~${monthDay(list[list.length - 1])}`;
return weekPart ? `${range} ${weekPart}` : range;
}
return weekPart || weekData.value.title || "";
});
/** hover 全称:接口 title,与简称相同则不挂(避免重复提示) */
const titleFull = computed<string>(() => {
const full = weekData.value.title || "";
return full === titleStr.value ? "" : full;
});
/**
* 有周对象 = titleStr 非空(含 hasData=false 的空周:后端仍回 title/days,只是 items 全空)。
* 翻页箭头必须随之保留,避免「翻进空周 → 箭头消失 → 无法返回」的死角;
* 真正无周对象(oapi 降级返回 {})才走大图标空态。
*/
const hasWeek = computed(() => titleStr.value !== "");
const remarkText = computed<string>(() => weekData.value.remark || "");
const canPrev = computed(() => weekOffset.value > minWeek.value);
const canNext = computed(() => weekOffset.value < maxWeek.value);
const currentList = computed<any[]>(() => {
const day = days.value[currentIndex.value];
return day && Array.isArray(day.items) ? day.items : [];
});
/** 当天无安排时的占位文案:优先接口 dayLabel(节日名/无/休),否则兜「暂无工作安排」 */
const currentDayLabel = computed<string>(() => {
const day = days.value[currentIndex.value];
return (day && day.dayLabel) || "暂无工作安排";
});
onMounted(async () => {
if (window.ResizeObserver && refRoot.value) {
observer = new ResizeObserver(reportHeight);
observer.observe(refRoot.value);
}
await loadWeek(0);
});
onUnmounted(() => {
if (observer) observer.disconnect();
});
async function loadWeek(week: number) {
weekOffset.value = week;
loading.value = true;
try {
const data: any = await UserSettingApi.getWidgetData("weekplan", "week=" + week);
weekData.value = data && typeof data === "object" ? data : {};
// 合法偏移范围:老版本服务不返回时按 0 处理(两侧禁用)
minWeek.value = Number.isFinite(Number(weekData.value.minWeek)) ? Number(weekData.value.minWeek) : 0;
maxWeek.value = Number.isFinite(Number(weekData.value.maxWeek)) ? Number(weekData.value.maxWeek) : 0;
if (week === 0) locateToday();
else currentIndex.value = 0; // 非本周默认落周一,不沿用「今天」下标
} catch (e) {
console.warn("[widget] weekplan 取数失败", e);
weekData.value = {};
} finally {
// finally 收尾:即使 catch 内再抛,loading 也必须落 false(v1 骨架卡死的直接机制)
loading.value = false;
// 换周/换数据后重置备注折叠态并重测溢出
remarkExpanded.value = false;
nextTick(measureRemark);
reportHeight();
}
}
/** 今日定位:优先按 date 匹配今日 ISO(防跨周边界偏差),匹配不到退星期推算 */
function locateToday() {
const list = days.value;
if (list.length === 0) {
currentIndex.value = 0;
return;
}
const now = new Date();
const iso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const hit = list.findIndex(d => d && d.date === iso);
currentIndex.value = hit >= 0 ? hit : (now.getDay() + 6) % 7;
if (currentIndex.value >= list.length) currentIndex.value = 0;
}
function pad(n: number) {
return n < 10 ? "0" + n : "" + n;
}
function onPrev() {
if (!canPrev.value) return;
loadWeek(weekOffset.value - 1);
}
function onNext() {
if (!canNext.value) return;
loadWeek(weekOffset.value + 1);
}
function onDayClick(index: number) {
currentIndex.value = index;
}
function hasWork(index: number) {
const day = days.value[index];
return !!day && Array.isArray(day.items) && day.items.length > 0;
}
/** 日期条只显示「日」,不带月份:优先 date(yyyy-MM-dd),退化到 dateText 尾部数字 */
function dayText(day: any) {
if (!day) return "";
const date = typeof day.date === "string" ? day.date : "";
if (date.length >= 10) return date.slice(8, 10);
const text = String(day.dateText || "");
const hit = text.match(/(\d{1,2})\s*$/);
return hit ? hit[1].padStart(2, "0") : text;
}
/** M月D日(标题区间用;月/日去前导零) */
function monthDay(day: any) {
const date = day && typeof day.date === "string" ? day.date : "";
if (date.length >= 10) {
return `${Number(date.slice(5, 7))}月${Number(date.slice(8, 10))}日`;
}
return String((day && day.dateText) || "");
}
/** 次要信息行:地点 / 负责人 / 参与人员,全部为空则不显示 */
function metaOf(item: any) {
if (!item) return "";
const parts: string[] = [];
if (item.place) parts.push(item.place);
if (item.principal) parts.push(item.principal);
if (item.participant) parts.push(item.participant);
return parts.join(" · ");
}
function toggleRemark() {
remarkExpanded.value = !remarkExpanded.value;
if (!remarkExpanded.value) nextTick(measureRemark);
reportHeight();
}
/** 备注溢出判定:折叠态下内容高度超过可视高度才算溢出(展开态保持按钮可见,不重测) */
function measureRemark() {
const el = refRemark.value;
if (!el || remarkExpanded.value) return;
remarkOverflow.value = el.scrollHeight > el.clientHeight + 2;
}
function reportHeight() {
if (refRoot.value) emit("loaded", refRoot.value.offsetHeight);
}
</script>
<style lang="scss" scoped>
.wg-weekplan {
padding: 6px 0 10px;
}
.wk-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 15px;
padding: 6px 0 10px;
font-size: 15px;
font-weight: 700;
color: #344449;
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pager i {
font-style: normal;
font-size: 16px;
font-weight: 400;
color: #6e7c87;
padding: 0 6px;
cursor: pointer;
&.disabled {
color: #d6dbe1;
cursor: default;
}
}
}
.wk-days {
display: flex;
margin: 0 15px 6px;
gap: 4px;
.d {
position: relative;
flex: 1 1 0;
text-align: center;
font-size: 14px;
color: #6e7c87;
border-radius: 4px;
// 底部留出圆点位置:整周无安排时也占位,日期条高度恒定
padding: 4px 0 11px;
cursor: pointer;
b {
display: block;
font-size: 15px;
color: #344449;
margin-top: 2px;
font-weight: 600;
}
// 圆点绝对定位在单元格底部(今日的蓝底方块不会把它盖住)
b::after {
content: "";
position: absolute;
left: 50%;
bottom: 3px;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: #2d8cf0;
visibility: hidden;
}
&.has b::after {
visibility: visible;
}
// 休息日(周六 / 周日)弱化日期数字
&.rest b {
color: #98a2b0;
}
// 法定节假日醒目标记
&.holiday b {
color: #e64545;
}
// 今日:蓝底白字方块(置最后,压过 rest / holiday 的日期字色)
&.is-on {
color: #1d4ed8;
b {
width: 28px;
margin: 2px auto 0;
padding: 1px 0;
background: #2d8cf0;
color: #fff;
border-radius: 4px;
}
}
}
}
.wk-tips-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 8px 15px 0;
}
.wk-tips {
font-size: 16px;
font-weight: 700;
color: #1f2329;
}
.wk-overview {
flex: 0 0 auto;
font-size: 13px;
color: #2d8cf0;
text-decoration: none;
cursor: pointer;
&:hover {
color: #1d6fd0;
}
}
/* 列表区:固定高度 = 5 条(旧版实测行距 ≈54px × 5 ≈ 268px,与「内容+地点」两行条目 5 条相当);
多条滚动、空态占位同高,避免切到「休/节假日」时卡片高度跳变 */
.wk-list-area {
height: 268px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #d3d8de;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
.wk-list {
list-style: none;
margin: 0;
padding: 0;
li {
margin: 0 15px;
padding: 6px 0;
border-bottom: 1px solid #f2f4f7;
font-size: 15px;
color: #344449;
&:last-child {
border-bottom: 0;
}
.line {
display: flex;
align-items: center;
}
}
.content {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
flex: 0 0 auto;
color: #6e7c87;
margin-left: 10px;
}
.meta {
margin-top: 2px;
font-size: 12.5px;
color: #98a2b0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
/* 备注:与「安排数据」区分——上方一条分割线 + 浅底块,跟白色列表拉开层次 */
.wk-remark {
margin: 8px 15px 0;
padding: 8px 10px;
background: #f7f8fa;
border-top: 1px solid #eef1f5;
border-radius: 6px;
font-size: 13px;
color: #5b6672;
.rk-txt {
line-height: 1.6;
// 备注可能是含换行的多行文本:pre-line 保留换行;折叠态截断 2 行
white-space: pre-line;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
&.expanded {
display: block;
overflow: visible;
}
}
.rk-more {
display: inline-block;
margin-top: 2px;
font-size: 13px;
color: #2d8cf0;
cursor: pointer;
user-select: none;
&:hover {
color: #1d6fd0;
}
}
}
.no-data {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #b9c2cc;
font-size: 14px;
}
/* ---------- 空态(模板内联 SVG,不依赖外部图片 / CSS 背景) ---------- */
.wg-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 132px;
padding: 16px 0 20px;
.txt {
font-size: 12px;
color: #98a2b0;
}
}
</style>