功能调整

This commit is contained in:
hebo 2025-12-02 20:25:02 +08:00
parent 85f74332c0
commit 65a5992e57
18 changed files with 6149 additions and 3 deletions

26
src/api/base/srApi.ts Normal file
View File

@ -0,0 +1,26 @@
// 生日贺卡相关API接口
import { get, post } from "@/utils/request";
/**
*
* @param params { id: 推送记录ID }
*/
export const srGetCardDetailApi = async (params: { id: string }) => {
return await get("/api/srTsRecord/getCardDetail", params);
};
/**
*
* @param params { xxtsId: 消息推送ID }
*/
export const srMarkAsViewedApi = async (params: { xxtsId: string }) => {
return await post("/api/srTsRecord/markAsViewed", params);
};
/**
*
*/
export const srFindPageApi = async (params: any) => {
return await get("/api/srTsRecord/findPage", params);
};

48
src/api/base/tzApi.ts Normal file
View File

@ -0,0 +1,48 @@
// 通知公告相关API接口
import { get, post } from "@/utils/request";
/**
*
* useLayout { rows: [...] }
*/
export const mobileTzListApi = async (params: any) => {
const res = await get("/api/notice/findPage", params);
// 返回原始响应useLayout 会自动从 rows 字段取数据
return res;
};
/**
* ID获取通知详情
*/
export const tzFindByIdApi = async (params: { id: string }) => {
return await get("/api/notice/findById", params);
};
/**
* /
*/
export const tzSaveApi = async (params: any) => {
return await post("/api/notice/save", params);
};
/**
*
*/
export const tzDeleteApi = async (params: { ids: string }) => {
return await post("/api/notice/logicDelete", params);
};
/**
* ID查询推送名单
*/
export const tzRecipientFindByNoticeIdApi = async (params: { noticeId: string }) => {
return await get("/api/noticeRecipient/findByNoticeId", params);
};
/**
*
*/
export const xxtsSaveByNoticeParamsApi = async (params: { noticeId: string }) => {
return await post("/api/xxts/saveByNoticeParams", params);
};

View File

@ -37,6 +37,15 @@ export const findAllZw = () => {

View File

@ -0,0 +1,13 @@
import BasicTeacherSelect from './index.vue';
export interface TeacherInfo {
id: string;
jsxm: string;
jsId?: string;
dzzw?: string;
qtzw?: string;
}
export { BasicTeacherSelect };
export default BasicTeacherSelect;

View File

@ -0,0 +1,204 @@
<template>
<view class="teacher-select-wrapper">
<!-- 触发区域 -->
<view class="select-trigger" @click="openSelectPage">
<view class="trigger-content">
<text class="trigger-label" v-if="label">{{ label }}</text>
<view class="trigger-value">
<text :class="{ placeholder: !hasSelected }">
{{ displayText }}
</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</view>
<!-- 已选教师标签展示 -->
<view class="selected-tags" v-if="showTags && selectedTeachers.length > 0">
<text
v-for="(teacher, index) in displayTeachers"
:key="teacher.id"
class="teacher-tag"
>{{ teacher.jsxm }}</text>
<view
v-if="selectedTeachers.length > maxDisplayCount"
class="more-btn"
@click="openSelectPage"
>
<text>更多({{ selectedTeachers.length - maxDisplayCount }})</text>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
export interface TeacherInfo {
id: string;
jsxm: string;
jsId?: string;
dzzw?: string;
qtzw?: string;
}
const props = withDefaults(defineProps<{
/** 已选教师列表 */
modelValue?: TeacherInfo[];
/** 标签文字 */
label?: string;
/** 占位文字 */
placeholder?: string;
/** 是否显示已选教师标签 */
showTags?: boolean;
/** 最多显示的教师标签数量 */
maxDisplayCount?: number;
/** 是否禁用 */
disabled?: boolean;
}>(), {
modelValue: () => [],
label: '',
placeholder: '请选择教师',
showTags: true,
maxDisplayCount: 24,
disabled: false
});
const emit = defineEmits<{
(e: 'update:modelValue', value: TeacherInfo[]): void;
(e: 'change', value: TeacherInfo[]): void;
}>();
//
const selectedTeachers = ref<TeacherInfo[]>([]);
//
watch(() => props.modelValue, (newVal) => {
selectedTeachers.value = newVal || [];
}, { immediate: true, deep: true });
//
const hasSelected = computed(() => selectedTeachers.value.length > 0);
//
const displayText = computed(() => {
if (selectedTeachers.value.length === 0) {
return props.placeholder;
}
return `已选择 ${selectedTeachers.value.length}`;
});
//
const displayTeachers = computed(() => {
return selectedTeachers.value.slice(0, props.maxDisplayCount);
});
//
const openSelectPage = () => {
if (props.disabled) return;
// ID
const selectedIds = selectedTeachers.value.map(t => t.id || t.jsId).join(',');
uni.navigateTo({
url: `/pages/common/selectTeachers/index?selectedIds=${selectedIds}`
});
};
//
const handleTeacherSelected = (teachers: TeacherInfo[]) => {
selectedTeachers.value = teachers;
emit('update:modelValue', teachers);
emit('change', teachers);
};
onMounted(() => {
//
uni.$on('teacherSelected', handleTeacherSelected);
});
onUnmounted(() => {
//
uni.$off('teacherSelected', handleTeacherSelected);
});
//
defineExpose({
getSelectedTeachers: () => selectedTeachers.value,
setSelectedTeachers: (teachers: TeacherInfo[]) => {
selectedTeachers.value = teachers;
emit('update:modelValue', teachers);
},
clear: () => {
selectedTeachers.value = [];
emit('update:modelValue', []);
}
});
</script>
<style lang="scss" scoped>
.teacher-select-wrapper {
width: 100%;
}
.select-trigger {
background: #fff;
border-radius: 8px;
padding: 12px 16px;
cursor: pointer;
.trigger-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.trigger-label {
font-size: 15px;
color: #333;
font-weight: 500;
}
.trigger-value {
display: flex;
align-items: center;
text {
font-size: 14px;
color: #666;
margin-right: 4px;
&.placeholder {
color: #999;
}
}
}
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
background: #fff;
border-radius: 0 0 8px 8px;
border-top: 1px solid #f0f0f0;
.teacher-tag {
font-size: 13px;
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
padding: 4px 10px;
border-radius: 16px;
}
.more-btn {
font-size: 13px;
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
padding: 4px 10px;
border-radius: 16px;
cursor: pointer;
}
}
</style>

View File

@ -1043,6 +1043,66 @@
"enablePullDownRefresh": false
}
},
{
"path": "pages/view/routine/tz/index",
"style": {
"navigationBarTitleText": "通知发布",
"enablePullDownRefresh": false
}
},
{
"path": "pages/view/routine/tz/publish",
"style": {
"navigationBarTitleText": "发布通知",
"enablePullDownRefresh": false
}
},
{
"path": "pages/view/routine/tz/detail",
"style": {
"navigationBarTitleText": "通知详情",
"enablePullDownRefresh": false
}
},
{
"path": "pages/view/routine/tz/push-list",
"style": {
"navigationBarTitleText": "推送名单",
"enablePullDownRefresh": false
}
},
{
"path": "pages/view/routine/tz/detailwb",
"style": {
"navigationBarTitleText": "通知详情",
"enablePullDownRefresh": false
}
},
{
"path": "pages/view/routine/sr/envelope",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "生日祝福",
"enablePullDownRefresh": false,
"backgroundColor": "#ff9a9e"
}
},
{
"path": "pages/view/routine/sr/card",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "生日祝福",
"enablePullDownRefresh": false,
"backgroundColor": "#ff9a9e"
}
},
{
"path": "pages/common/selectTeachers/index",
"style": {
"navigationBarTitleText": "选择教师",
"enablePullDownRefresh": false
}
},
{
"path": "pages/view/analysis/jl/statistics",
"style": {

View File

@ -609,6 +609,40 @@ const sections = reactive<Section[]>([
},
],
},
{
id: "gnyy-xxfb",
icon: "jlfb",
text: "信息发布",
show: true,
permissionKey: "gnyycommon-xxfb", //
isFolder: true, //
folderItems: [
{
id: "xxfb-qdfb",
icon: "qdfb",
text: "签到发布",
show: true,
permissionKey: "routine-qdfb",
path: "/pages/view/routine/qd/index",
},
{
id: "xxfb-jlfb",
icon: "jlfb",
text: "发布接龙",
show: true,
permissionKey: "routine-bjjl",
path: "/pages/view/notice/index",
},
{
id: "xxfb-tzfb",
icon: "gw",
text: "通知发布",
show: true,
permissionKey: "routine-tzfb",
path: "/pages/view/routine/tz/index",
},
],
},
],
},
{

File diff suppressed because it is too large Load Diff

View File

@ -556,7 +556,7 @@ const handleBjConfirm = (e: any) => {
//
const showTeacherTree = () => {
uni.navigateTo({
url: '/pages/view/routine/qd/selectTeachers'
url: '/pages/common/selectTeachers/index'
});
};

View File

@ -591,7 +591,7 @@ const openCategoryPicker = () => {
//
const showTeacherTree = () => {
uni.navigateTo({
url: '/pages/view/routine/qd/selectTeachers'
url: '/pages/common/selectTeachers/index'
});
};

View File

@ -612,7 +612,7 @@ const handleSignatureChange = (e: any) => {
const showTeacherTree = () => {
uni.navigateTo({
url: '/pages/view/routine/qd/selectTeachers'
url: '/pages/common/selectTeachers/index'
});
};

View File

@ -0,0 +1,695 @@
<!-- src/pages/view/routine/sr/card.vue -->
<!-- 生日贺卡展示页面教师端 - 信纸样式 -->
<template>
<view class="birthday-card-page" :class="bgColorClass">
<!-- 加载遮罩层 -->
<view v-if="isLoading" class="loading-overlay">
<view class="loading-content">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
</view>
<!-- 贺卡内容 -->
<view v-else-if="cardData" class="card-container">
<!-- 信纸主体 -->
<view class="letter-paper">
<!-- 信纸顶部装饰 -->
<view class="paper-header">
<view class="header-line"></view>
<view class="header-title">
<text class="title-icon">🎂</text>
<text class="title-text">生日祝福</text>
<text class="title-icon">🎂</text>
</view>
<view class="header-date">{{ formatBirthdayDate }}</view>
</view>
<!-- 称呼 -->
<view class="letter-greeting" v-if="greetingFromContent">
<view class="greeting-line">
<text class="greeting-text">{{ greetingFromContent }}</text>
</view>
</view>
<!-- 信件正文带虚线 -->
<view class="letter-content">
<view
v-for="(line, index) in contentLines"
:key="index"
class="content-line"
>
<text class="line-text">{{ line || '\u00A0' }}</text>
<view class="line-border"></view>
</view>
</view>
<!-- 签名区域 -->
<view class="letter-signature" v-if="cardData.signerName || cardData.signLabel">
<view class="signature-wrapper">
<!-- 签名文字 -->
<view class="signature-info">
<text class="signer-name">
<text v-if="cardData.signLabel" class="signer-label">{{ cardData.signLabel }}</text>
<text v-if="cardData.signerName">{{ cardData.signerName }}</text>
</text>
</view>
<!-- 日期 -->
<view class="signature-date">
<text>{{ currentDate }}</text>
</view>
</view>
</view>
<!-- 信纸底部装饰 -->
<view class="paper-footer">
<view class="footer-decoration">
<text class="footer-icon">🎉</text>
<text class="footer-icon">🎁</text>
<text class="footer-icon">🎈</text>
</view>
</view>
</view>
<!-- 右上角音乐图标 -->
<view
class="music-icon-wrapper"
:class="{ 'playing': isMusicPlaying }"
@click="toggleMusic"
>
<text class="music-icon-emoji">🎵</text>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-state">
<text class="empty-icon">🎂</text>
<text class="empty-text">贺卡不存在或已过期</text>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, computed, onUnmounted } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { srGetCardDetailApi, srMarkAsViewedApi } from "@/api/base/srApi";
import { imagUrl } from "@/utils";
const xxtsId = ref<string>("");
const cardData = ref<any>(null);
const isLoading = ref(false);
const isMusicPlaying = ref(false);
const audioContext = ref<any>(null);
//
const formatBirthdayDate = computed(() => {
if (!cardData.value?.birthdayDate) return '';
const date = cardData.value.birthdayDate;
if (date.includes('月')) return date;
try {
const d = new Date(date);
return `${d.getMonth() + 1}${d.getDate()}`;
} catch {
return date;
}
});
//
const currentDate = computed(() => {
const now = new Date();
return `${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}`;
});
// 寿
const personTypeText = computed(() => {
const type = cardData.value?.srPersonType;
if (type === 'JS') return '老师';
if (type === 'XS') return '同学';
return '';
});
//
const bgColorClass = computed(() => {
const colorType = cardData.value?.hkMb?.animationType || 'blue';
return `bg-${colorType}`;
});
// HTML
const stripHtmlTags = (html: string): string => {
if (!html) return '';
// 使HTML
return html.replace(/<[^>]+>/g, '').trim();
};
//
const greetingFromContent = computed(() => {
if (!cardData.value?.zfContent) return '';
const content = cardData.value.zfContent;
// </p>
const lines = content.split(/<\/p>|<\/div>|\n/).filter((line: string) => line.trim());
if (lines.length > 0) {
// HTML
let firstLine = lines[0];
// <p><div>
firstLine = firstLine.replace(/^<[^>]+>/, '');
// HTML
firstLine = stripHtmlTags(firstLine).trim();
return firstLine || '';
}
return '';
});
// HTML线
const contentLines = computed(() => {
if (!cardData.value?.zfContent) return [''];
const content = cardData.value.zfContent;
// </p>
let lines = content.split(/<\/p>|<\/div>|\n/).filter((line: string) => line.trim());
//
if (lines.length > 0) {
lines = lines.slice(1);
}
// HTML
lines = lines.map((line: string) => {
// <p><div>
line = line.replace(/^<[^>]+>/, '');
// HTML
return stripHtmlTags(line).trim();
}).filter((line: string) => line); //
// 5
const minLines = 5;
while (lines.length < minLines) {
lines.push('');
}
return lines;
});
//
const loadCardDetail = async () => {
if (!xxtsId.value) return;
isLoading.value = true;
try {
const res = await srGetCardDetailApi({ id: xxtsId.value });
if (res?.result) {
cardData.value = res.result;
const returnedXxtsId = res.result.xxtsId || xxtsId.value;
if (returnedXxtsId) {
await markAsViewed(returnedXxtsId);
}
// 使使
initAudio();
}
} catch (e) {
console.error("加载贺卡详情失败:", e);
uni.showToast({ title: "加载失败", icon: "none" });
} finally {
isLoading.value = false;
}
};
//
const markAsViewed = async (id: string) => {
if (!id) return;
try {
await srMarkAsViewedApi({ xxtsId: id });
} catch (e) {
console.error("标记已查阅失败:", e);
}
};
//
const initAudio = () => {
// 使使
const musicUrl = cardData.value?.hkMb?.musicUrl
? imagUrl(cardData.value.hkMb.musicUrl)
: '/static/base/music/srkl.mp3';
try {
// #ifdef H5
audioContext.value = new Audio(musicUrl);
audioContext.value.loop = true;
audioContext.value.play().catch((e: any) => {
console.error("自动播放失败,可能需要用户交互:", e);
});
// #endif
// #ifdef MP-WEIXIN
audioContext.value = uni.createInnerAudioContext();
audioContext.value.src = musicUrl;
audioContext.value.loop = true;
audioContext.value.play();
// #endif
//
isMusicPlaying.value = true;
} catch (e) {
console.error("初始化音频失败:", e);
}
};
//
const toggleMusic = () => {
if (!audioContext.value) return;
try {
if (isMusicPlaying.value) {
audioContext.value.pause();
} else {
audioContext.value.play();
}
isMusicPlaying.value = !isMusicPlaying.value;
} catch (e) {
console.error("音乐播放控制失败:", e);
}
};
//
onUnmounted(() => {
if (audioContext.value) {
try {
audioContext.value.pause();
// #ifdef MP-WEIXIN
audioContext.value.destroy?.();
// #endif
} catch (e) {
console.error("清理音频失败:", e);
}
}
});
onLoad(async (options) => {
xxtsId.value = options?.id || '';
if (xxtsId.value) {
await loadCardDetail();
uni.setNavigationBarTitle({ title: "生日祝福" });
} else {
uni.showToast({ title: "缺少贺卡ID", icon: "none" });
}
});
</script>
<style scoped lang="scss">
.birthday-card-page {
min-height: 100vh;
display: flex;
flex-direction: column;
position: relative;
//
&.bg-blue {
background: linear-gradient(180deg, #e6f3ff 0%, #cce5ff 50%, #b3d9ff 100%);
.letter-paper {
background: linear-gradient(180deg, #f5faff 0%, #eef6ff 100%);
border-color: rgba(59, 130, 246, 0.2);
}
.header-line {
background: linear-gradient(90deg, transparent 0%, #3b82f6 50%, transparent 100%);
}
.title-text {
color: #1e40af;
}
.paper-footer {
border-top-color: #93c5fd;
}
.loading-overlay {
background-color: rgba(230, 243, 255, 0.95);
}
.loading-spinner {
border-color: #93c5fd;
border-top-color: #3b82f6;
}
}
//
&.bg-yellow {
background: linear-gradient(180deg, #fdf6e3 0%, #f5e6c8 50%, #efe0b9 100%);
.letter-paper {
background: linear-gradient(180deg, #fffef5 0%, #fff9e6 100%);
border-color: rgba(201, 162, 39, 0.2);
}
.header-line {
background: linear-gradient(90deg, transparent 0%, #c9a227 50%, transparent 100%);
}
.title-text {
color: #8b4513;
}
.paper-footer {
border-top-color: #e8d5a3;
}
.loading-overlay {
background-color: rgba(253, 246, 227, 0.95);
}
.loading-spinner {
border-color: #e8d5a3;
border-top-color: #c9a227;
}
}
//
&.bg-purple {
background: linear-gradient(180deg, #f3e8ff 0%, #e9d5ff 50%, #ddd6fe 100%);
.letter-paper {
background: linear-gradient(180deg, #faf5ff 0%, #f5f0ff 100%);
border-color: rgba(139, 92, 246, 0.2);
}
.header-line {
background: linear-gradient(90deg, transparent 0%, #8b5cf6 50%, transparent 100%);
}
.title-text {
color: #6b21a8;
}
.paper-footer {
border-top-color: #c4b5fd;
}
.loading-overlay {
background-color: rgba(243, 232, 255, 0.95);
}
.loading-spinner {
border-color: #c4b5fd;
border-top-color: #8b5cf6;
}
}
}
/* 加载遮罩 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
}
.loading-spinner {
width: 50px;
height: 50px;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
font-size: 16px;
color: #666;
}
/* 贺卡容器 */
.card-container {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
padding-top: calc(20px + env(safe-area-inset-top));
padding-bottom: calc(20px + env(safe-area-inset-bottom));
}
/* 信纸主体 */
.letter-paper {
flex: 1;
border-radius: 8px;
padding: 30px 25px;
box-shadow:
0 4px 20px rgba(0, 0, 0, 0.1),
0 1px 3px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
border: 1px solid;
position: relative;
display: flex;
flex-direction: column;
}
/* 信纸左边红色竖线(模拟信纸) */
.letter-paper::before {
content: '';
position: absolute;
left: 20px;
top: 80px;
bottom: 100px;
width: 2px;
background: linear-gradient(180deg, #e74c3c 0%, #c0392b 100%);
opacity: 0.3;
}
/* 信纸顶部装饰 */
.paper-header {
text-align: center;
margin-bottom: 25px;
padding-bottom: 15px;
}
.header-line {
height: 2px;
margin-bottom: 15px;
}
.header-title {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 8px;
}
.title-icon {
font-size: 24px;
}
.title-text {
font-size: 26px;
font-weight: bold;
font-family: "KaiTi", "STKaiti", "楷体", serif;
}
.header-date {
font-size: 14px;
color: #a08060;
}
/* 称呼 */
.letter-greeting {
margin-bottom: 20px;
padding-left: 15px;
}
.greeting-line {
padding-bottom: 8px;
border-bottom: 1px dashed #d4c4a8;
}
.greeting-text {
font-size: 18px;
color: #5a4a3a;
font-family: "KaiTi", "STKaiti", "楷体", serif;
}
/* 信件正文 */
.letter-content {
flex: 1;
padding-left: 15px;
margin-bottom: 30px;
}
.content-line {
position: relative;
min-height: 40px;
display: flex;
align-items: flex-end;
padding-bottom: 8px;
}
.line-text {
font-size: 16px;
line-height: 1.8;
color: #4a3a2a;
font-family: "KaiTi", "STKaiti", "楷体", serif;
text-indent: 2em;
display: block;
width: 100%;
}
.line-border {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
border-bottom: 1px dashed #d4c4a8;
}
/* 签名区域 */
.letter-signature {
margin-top: auto;
padding-top: 20px;
}
.signature-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
padding-right: 20px;
}
.signature-image {
width: 100px;
height: 45px;
margin-bottom: 5px;
}
.signature-info {
text-align: right;
margin-bottom: 8px;
}
.signer-label {
font-size: 14px;
color: #8b7355;
display: inline;
margin-right: 8px;
}
.signer-name {
font-size: 18px;
color: #5a4a3a;
font-weight: 500;
font-family: "KaiTi", "STKaiti", "楷体", serif;
display: inline;
}
.signature-date {
font-size: 14px;
color: #a08060;
border-bottom: 1px dashed #d4c4a8;
padding-bottom: 5px;
}
/* 信纸底部装饰 */
.paper-footer {
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid;
}
.footer-decoration {
display: flex;
justify-content: center;
gap: 20px;
padding-top: 15px;
}
.footer-icon {
font-size: 24px;
animation: float-icon 3s ease-in-out infinite;
}
.footer-icon:nth-child(2) {
animation-delay: 0.5s;
}
.footer-icon:nth-child(3) {
animation-delay: 1s;
}
@keyframes float-icon {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
/* 右上角音乐图标 */
.music-icon-wrapper {
position: fixed;
top: calc(20px + env(safe-area-inset-top));
right: 20px;
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
z-index: 100;
cursor: pointer;
transition: all 0.3s ease;
}
.music-icon-wrapper:active {
transform: scale(0.95);
}
.music-icon-wrapper.playing .music-icon-emoji {
animation: music-rotate 2s linear infinite;
}
.music-icon-emoji {
font-size: 28px;
display: block;
transition: transform 0.3s ease;
}
@keyframes music-rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 空状态 */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
opacity: 0.5;
}
.empty-text {
font-size: 16px;
color: #666;
margin-bottom: 30px;
}
</style>

View File

@ -0,0 +1,658 @@
<!-- src/pages/view/routine/sr/envelope.vue -->
<!-- 生日贺卡信封页面教师端 -->
<template>
<view class="envelope-page" :style="pageStyle">
<!-- 装饰背景 -->
<view class="decoration-bg">
<view class="sparkle sparkle-1"></view>
<view class="sparkle sparkle-2"></view>
<view class="sparkle sparkle-3"></view>
<view class="sparkle sparkle-4">🎉</view>
<view class="sparkle sparkle-5"></view>
<view class="sparkle sparkle-6"></view>
</view>
<!-- 提示文字 -->
<view class="hint-text" :class="{ 'fade-out': isOpening }">
<text>您收到一封生日祝福</text>
<text class="hint-sub">点击信封查看</text>
</view>
<!-- 信封容器 -->
<view
class="envelope-wrapper"
:class="{ 'stop-swing': isOpening, 'opened': isOpened }"
@click="openEnvelope"
>
<!-- 信封主体 -->
<view class="envelope">
<!-- 信封背面内衬 -->
<view class="envelope-back"></view>
<!-- 信纸 -->
<view class="letter" :class="{ 'slide-out': isOpening }">
<view class="letter-content">
<text class="letter-emoji">🎂</text>
<text class="letter-title">生日快乐</text>
<text class="letter-hint">点击查看完整贺卡</text>
</view>
</view>
<!-- 信封盖子三角形翻盖 -->
<view class="envelope-flap" :class="{ 'flap-open': isOpening }"></view>
<!-- 信封正面 -->
<view class="envelope-front">
<!-- 封印 -->
<view class="seal">
<text class="seal-icon">🎂</text>
</view>
</view>
</view>
<!-- 手指提示 -->
<view class="finger-hint" :class="{ 'fade-out': isOpening }">
<text class="finger-icon">👆</text>
</view>
</view>
<!-- 底部装饰 -->
<view class="bottom-decoration" :class="{ 'fade-out': isOpening }">
<text class="deco-icon">🎈</text>
<text class="deco-icon">🎁</text>
<text class="deco-icon">🎈</text>
</view>
<!-- 右上角音乐图标 -->
<view
class="music-icon-wrapper"
:class="{ 'playing': isMusicPlaying, 'fade-out': isOpening }"
@click="toggleMusic"
>
<text class="music-icon-emoji">🎵</text>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, computed, onUnmounted } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { useUserStore } from "@/store/modules/user";
const userStore = useUserStore();
const xxtsId = ref<string>("");
const openId = ref<string>("");
const receiverName = ref<string>("");
const isOpening = ref(false);
const isOpened = ref(false);
const audioContext = ref<any>(null);
const isMusicPlaying = ref(false);
//
const pageStyle = computed(() => {
return {
background: 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 50%, #fad0c4 100%)',
};
});
//
const openEnvelope = () => {
if (isOpening.value || isOpened.value) return;
//
if (!isMusicPlaying.value && audioContext.value) {
playMusic();
}
isOpening.value = true;
//
setTimeout(() => {
isOpened.value = true;
// openId
setTimeout(() => {
uni.navigateTo({
url: `/pages/view/routine/sr/card?id=${xxtsId.value}`
});
}, 300);
}, 1500);
};
//
const initAudio = () => {
try {
// #ifdef H5
audioContext.value = new Audio('/static/base/music/srkl.mp3');
audioContext.value.loop = true;
//
audioContext.value.addEventListener('play', () => {
isMusicPlaying.value = true;
});
audioContext.value.addEventListener('pause', () => {
isMusicPlaying.value = false;
});
audioContext.value.addEventListener('ended', () => {
isMusicPlaying.value = false;
});
//
let hasTriedPlay = false;
//
const attemptPlay = () => {
if (!audioContext.value || hasTriedPlay || isMusicPlaying.value) {
return;
}
hasTriedPlay = true;
audioContext.value.play().then(() => {
isMusicPlaying.value = true;
}).catch(() => {
// 便
hasTriedPlay = false;
});
};
//
let hasInteracted = false;
const playOnInteraction = () => {
if (!hasInteracted && !isMusicPlaying.value) {
hasInteracted = true;
attemptPlay();
}
};
//
const events = ['click', 'touchstart'];
events.forEach(eventType => {
document.addEventListener(eventType, playOnInteraction, { once: true, passive: true });
});
//
attemptPlay();
// #endif
// #ifdef MP-WEIXIN
audioContext.value = uni.createInnerAudioContext();
audioContext.value.src = '/static/base/music/srkl.mp3';
audioContext.value.loop = true;
//
audioContext.value.onPlay(() => {
isMusicPlaying.value = true;
});
audioContext.value.onPause(() => {
isMusicPlaying.value = false;
});
audioContext.value.onStop(() => {
isMusicPlaying.value = false;
});
//
audioContext.value.play();
isMusicPlaying.value = true;
// #endif
} catch (e) {
console.error("初始化音频失败:", e);
}
};
//
const playMusic = () => {
if (!audioContext.value) return;
try {
audioContext.value.play();
isMusicPlaying.value = true;
} catch (e) {
console.error("播放音乐失败:", e);
}
};
//
const toggleMusic = () => {
if (!audioContext.value) return;
try {
if (isMusicPlaying.value) {
audioContext.value.pause();
isMusicPlaying.value = false;
} else {
audioContext.value.play();
isMusicPlaying.value = true;
}
} catch (e) {
console.error("音乐播放控制失败:", e);
}
};
//
onUnmounted(() => {
if (audioContext.value) {
try {
audioContext.value.pause();
// #ifdef MP-WEIXIN
audioContext.value.destroy?.();
// #endif
} catch (e) {
console.error("清理音频失败:", e);
}
}
});
//
const resetEnvelopeState = () => {
isOpening.value = false;
isOpened.value = false;
};
onLoad(async (options) => {
// URL: ?id=xxx&openId=yyy
xxtsId.value = options?.id || '';
openId.value = options?.openId || '';
receiverName.value = options?.name || '';
// openId
if (openId.value) {
try {
await userStore.loginByOpenId(openId.value);
} catch (e) {
console.error("openId认证失败:", e);
}
}
if (!xxtsId.value) {
uni.showToast({ title: "缺少贺卡ID", icon: "none" });
}
//
initAudio();
});
//
onShow(() => {
resetEnvelopeState();
});
</script>
<style scoped lang="scss">
.envelope-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* 装饰背景 */
.decoration-bg {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1;
}
.sparkle {
position: absolute;
font-size: 28px;
animation: sparkle-float 4s ease-in-out infinite;
}
.sparkle-1 { top: 8%; left: 8%; animation-delay: 0s; }
.sparkle-2 { top: 12%; right: 12%; animation-delay: 0.5s; }
.sparkle-3 { top: 28%; left: 5%; animation-delay: 1s; }
.sparkle-4 { top: 22%; right: 8%; animation-delay: 1.5s; }
.sparkle-5 { bottom: 22%; left: 12%; animation-delay: 2s; }
.sparkle-6 { bottom: 18%; right: 15%; animation-delay: 2.5s; }
@keyframes sparkle-float {
0%, 100% {
transform: translateY(0) scale(1);
opacity: 0.6;
}
50% {
transform: translateY(-12px) scale(1.15);
opacity: 1;
}
}
/* 提示文字 */
.hint-text {
position: absolute;
top: 12%;
text-align: center;
z-index: 10;
transition: all 0.5s ease;
}
.hint-text text {
display: block;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.hint-text text:first-child {
font-size: 24px;
font-weight: bold;
margin-bottom: 12px;
color: #973cfe;
text-shadow: 0 2px 15px rgba(151, 60, 254, 0.5), 0 0 20px rgba(151, 60, 254, 0.3);
}
.hint-sub {
font-size: 14px;
color: #973cfe;
text-shadow: 0 2px 10px rgba(151, 60, 254, 0.4);
animation: hint-pulse 2s ease-in-out infinite;
}
@keyframes hint-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.hint-text.fade-out {
opacity: 0;
transform: translateY(-30px);
}
/* 信封容器 - 摇摆动画 */
.envelope-wrapper {
position: relative;
z-index: 5;
cursor: pointer;
animation: envelope-swing 2.5s ease-in-out infinite;
transform-origin: top center;
}
@keyframes envelope-swing {
0%, 100% { transform: rotate(-3deg); }
50% { transform: rotate(3deg); }
}
.envelope-wrapper.stop-swing {
animation: none;
transform: rotate(0deg);
}
.envelope-wrapper:active {
transform: scale(0.98);
}
/* 信封主体 */
.envelope {
position: relative;
width: 300px;
height: 200px;
}
/* 信封背面(内衬) - 信封打开时可见的内部 */
.envelope-back {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 200px;
background: linear-gradient(180deg, #d63031 0%, #c0392b 100%);
border-radius: 8px;
z-index: 1;
}
/* 信纸 */
.letter {
position: absolute;
top: 30px;
left: 25px;
right: 25px;
height: 140px;
background: linear-gradient(180deg, #ffffff 0%, #fff8f0 100%);
border-radius: 6px;
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.15);
z-index: 2;
transform: translateY(0);
transition: transform 1s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.letter.slide-out {
transform: translateY(-160px);
}
.letter-content {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.letter-emoji {
font-size: 36px;
margin-bottom: 8px;
}
.letter-title {
font-size: 22px;
font-weight: bold;
color: #e74c3c;
margin-bottom: 8px;
}
.letter-hint {
font-size: 12px;
color: #999;
}
/* 信封盖子 - 三角形翻盖 */
.envelope-flap {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
border-left: 150px solid transparent;
border-right: 150px solid transparent;
border-top: 100px solid #e74c3c;
z-index: 4;
transform-origin: top center;
transform: rotateX(0deg);
transition: transform 0.8s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.15));
}
.envelope-flap.flap-open {
transform: rotateX(180deg);
}
/* 信封正面 */
.envelope-front {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 130px;
background: linear-gradient(180deg, #e74c3c 0%, #c0392b 100%);
border-radius: 0 0 8px 8px;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
}
/* 信封左右折角装饰 */
.envelope-front::before,
.envelope-front::after {
content: '';
position: absolute;
top: 0;
width: 0;
height: 0;
}
.envelope-front::before {
left: 0;
border-left: 150px solid #c0392b;
border-top: 65px solid transparent;
border-bottom: 65px solid transparent;
opacity: 0.3;
}
.envelope-front::after {
right: 0;
border-right: 150px solid #c0392b;
border-top: 65px solid transparent;
border-bottom: 65px solid transparent;
opacity: 0.3;
}
/* 封印 */
.seal {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(145deg, #f39c12 0%, #e67e22 50%, #d35400 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 4px 15px rgba(0, 0, 0, 0.3),
inset 0 2px 4px rgba(255, 255, 255, 0.3),
inset 0 -2px 4px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 5;
animation: seal-glow 2s ease-in-out infinite;
}
@keyframes seal-glow {
0%, 100% {
box-shadow:
0 4px 15px rgba(243, 156, 18, 0.4),
inset 0 2px 4px rgba(255, 255, 255, 0.3),
inset 0 -2px 4px rgba(0, 0, 0, 0.2);
}
50% {
box-shadow:
0 4px 30px rgba(243, 156, 18, 0.8),
0 0 20px rgba(243, 156, 18, 0.5),
inset 0 2px 4px rgba(255, 255, 255, 0.3),
inset 0 -2px 4px rgba(0, 0, 0, 0.2);
}
}
.seal-icon {
font-size: 32px;
}
/* 手指提示 */
.finger-hint {
position: absolute;
bottom: -60px;
left: 50%;
transform: translateX(-50%);
animation: finger-bounce 1s ease-in-out infinite;
transition: opacity 0.3s ease;
}
@keyframes finger-bounce {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-15px); }
}
.finger-icon {
font-size: 40px;
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.2));
}
.finger-hint.fade-out {
opacity: 0;
}
/* 打开后的状态 */
.envelope-wrapper.opened {
animation: envelope-fadeUp 0.5s ease forwards;
}
@keyframes envelope-fadeUp {
to {
opacity: 0;
transform: translateY(-60px) scale(0.9);
}
}
/* 底部装饰 */
.bottom-decoration {
position: absolute;
bottom: 12%;
display: flex;
gap: 25px;
z-index: 10;
transition: all 0.5s ease;
}
.deco-icon {
font-size: 36px;
animation: deco-bounce 2s ease-in-out infinite;
}
.deco-icon:nth-child(2) {
animation-delay: 0.3s;
}
.deco-icon:nth-child(3) {
animation-delay: 0.6s;
}
@keyframes deco-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
.bottom-decoration.fade-out {
opacity: 0;
transform: translateY(30px);
}
/* 右上角音乐图标 */
.music-icon-wrapper {
position: fixed;
top: calc(20px + env(safe-area-inset-top));
right: 20px;
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
z-index: 100;
cursor: pointer;
transition: all 0.3s ease;
}
.music-icon-wrapper:active {
transform: scale(0.95);
}
.music-icon-wrapper.playing .music-icon-emoji {
animation: music-rotate 2s linear infinite;
}
.music-icon-wrapper.fade-out {
opacity: 0;
transform: scale(0.8);
}
.music-icon-emoji {
font-size: 28px;
display: block;
transition: transform 0.3s ease;
}
@keyframes music-rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

View File

@ -0,0 +1,664 @@
<template>
<BasicLayout>
<scroll-view scroll-y class="detail-scroll-view">
<view class="detail-container">
<!-- 封面 -->
<image
v-if="noticeData.coverImg"
:src="imagUrl(noticeData.coverImg)"
mode="aspectFill"
class="cover-image"
></image>
<!-- 基本信息 -->
<view class="info-card">
<view class="notice-header">
<text class="notice-title">{{ noticeData.title }}</text>
<text class="notice-status" :class="getStatusClass(noticeData.noticeStatus)">
{{ getStatusText(noticeData.noticeStatus) }}
</text>
</view>
<view class="notice-meta">
<view class="meta-item">
<text class="meta-label">类型</text>
<text class="meta-value type-tag">{{ noticeData.noticeTypeName || '通知' }}</text>
</view>
<view class="meta-item">
<text class="meta-label">发布人</text>
<text class="meta-value">{{ noticeData.publishUserName || '未知' }}</text>
</view>
<view class="meta-item">
<text class="meta-label">发布时间</text>
<text class="meta-value">{{ formatTime(noticeData.publishTime) }}</text>
</view>
</view>
</view>
<!-- 通知内容 -->
<view class="info-card">
<text class="section-title">通知内容</text>
<view id="notice-content-area" class="notice-content content-rich-text" v-html="processedContent"></view>
</view>
<!-- 附件 -->
<view class="info-card" v-if="attachmentList.length > 0">
<text class="section-title">附件</text>
<view class="attachment-list">
<view
v-for="(att, index) in attachmentList"
:key="index"
class="attachment-item"
@click="previewAttachment(att)"
>
<uni-icons
:type="getAttachmentIcon(att.type)"
size="20"
color="#1890ff"
></uni-icons>
<text class="attachment-name">{{ att.name }}</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</view>
<!-- 推送统计 -->
<view class="info-card">
<text class="section-title">推送统计</text>
<view class="stats-row">
<view class="stat-item">
<text class="stat-value">{{ noticeData.totalCount || 0 }}</text>
<text class="stat-label">总推送</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value success">{{ noticeData.readCount || 0 }}</text>
<text class="stat-label">已读</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value warning">{{ (noticeData.totalCount || 0) - (noticeData.readCount || 0) }}</text>
<text class="stat-label">未读</text>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部操作 -->
<template #bottom>
<view class="bottom-actions">
<button class="action-btn edit-btn" @click="goToEdit" v-if="noticeData.noticeStatus !== 'A'">
编辑
</button>
<button class="action-btn push-btn" @click="goToPush" v-if="noticeData.noticeStatus !== 'A' && noticeData.pushType !== '2'">
推送
</button>
<button class="action-btn list-btn" @click="goToPushList">
查看推送名单
</button>
</view>
</template>
</BasicLayout>
<!-- 图片预览弹窗用于 base64 图片 -->
<view v-if="showImagePreview" class="image-preview-modal" @click="showImagePreview = false">
<view class="preview-close-tip">点击任意处关闭</view>
<image
:src="previewImageSrc"
mode="aspectFit"
class="preview-image"
@click.stop
/>
</view>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { imagUrl } from "@/utils";
import { tzFindByIdApi, xxtsSaveByNoticeParamsApi } from "@/api/base/tzApi";
import { previewFile as previewFileUtil } from "@/utils/filePreview";
interface Attachment {
name: string;
type: string;
url: string;
}
interface NoticeData {
id: string;
title: string;
content: string;
noticeType: string;
noticeTypeName: string;
coverImg: string;
fileUrl: string;
fileName: string;
fileFormat: string;
publishUserId: string;
publishUserName: string;
publishTime: string;
readCount: number;
totalCount: number;
noticeStatus: string;
pushType?: string; // 1-2-
}
const noticeId = ref<string>("");
const noticeData = ref<NoticeData>({
id: "",
title: "",
content: "",
noticeType: "",
noticeTypeName: "",
coverImg: "",
fileUrl: "",
fileName: "",
fileFormat: "",
publishUserId: "",
publishUserName: "",
publishTime: "",
readCount: 0,
totalCount: 0,
noticeStatus: "B",
});
const attachmentList = ref<Attachment[]>([]);
const showImagePreview = ref(false); //
const previewImageSrc = ref(''); //
//
const processedContent = computed(() => {
if (!noticeData.value?.content) return '';
let content = noticeData.value.content;
//
content = content.replace(/<img([^>]*)>/gi, (match: string, attrs: string) => {
const imgStyle = 'max-width:100%;height:auto;display:block;cursor:pointer;';
if (/style\s*=/i.test(attrs)) {
return match.replace(/style\s*=\s*["']([^"']*)["']/i,
`style="$1;${imgStyle}"`);
}
return `<img${attrs} style="${imgStyle}">`;
});
return content;
});
// HTML &amp;
const decodeHtmlEntities = (str: string): string => {
if (!str) return str;
const textarea = document.createElement('textarea');
textarea.innerHTML = str;
return textarea.value;
};
// URL
const contentImageUrls = computed(() => {
if (!noticeData.value?.content) return [];
const imgRegex = /<img[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi;
const urls: string[] = [];
let match;
while ((match = imgRegex.exec(noticeData.value.content)) !== null) {
// HTML
urls.push(decodeHtmlEntities(match[1]));
}
return urls;
});
// 使 DOM
const handleImageClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target && target.tagName === 'IMG') {
let imgSrc = (target as HTMLImageElement).src;
// HTML &amp;
imgSrc = decodeHtmlEntities(imgSrc);
if (imgSrc) {
// URL
const urls = contentImageUrls.value.length > 0
? contentImageUrls.value
: [imgSrc];
// base64 使
if (imgSrc.startsWith('data:image/')) {
previewImageSrc.value = imgSrc;
showImagePreview.value = true;
return;
}
// 使 uni.previewImage
uni.previewImage({ urls: urls, current: imgSrc });
}
}
};
//
const bindImageClickEvent = () => {
nextTick(() => {
setTimeout(() => {
// 使 id
const dom = document.getElementById('notice-content-area');
if (dom) {
dom.addEventListener('click', handleImageClick as EventListener);
}
}, 500); // DOM
});
};
const unbindImageClickEvent = () => {
const dom = document.getElementById('notice-content-area');
if (dom) {
dom.removeEventListener('click', handleImageClick as EventListener);
}
};
//
watch(() => noticeData.value, () => {
if (noticeData.value) {
bindImageClickEvent();
}
}, { immediate: true });
onUnmounted(() => {
unbindImageClickEvent();
});
onLoad((options: any) => {
if (options?.id) {
noticeId.value = options.id;
loadNoticeDetail(options.id);
}
});
const loadNoticeDetail = async (id: string) => {
try {
uni.showLoading({ title: "加载中..." });
const res = await tzFindByIdApi({ id });
if (res?.result) {
noticeData.value = res.result;
//
if (res.result.fileUrl) {
const urls = res.result.fileUrl.split(",");
const names = (res.result.fileName || "").split(",");
const formats = (res.result.fileFormat || "").split(",");
attachmentList.value = urls.map((url: string, index: number) => ({
url,
name: names[index] || getFileName(url), // fileName
type: formats[index] || "file",
}));
}
}
} catch (error) {
console.error("加载通知详情失败:", error);
uni.showToast({ title: "加载失败", icon: "none" });
} finally {
uni.hideLoading();
}
};
const getStatusClass = (status: string) => {
return status === "A" ? "status-published" : "status-draft";
};
const getStatusText = (status: string) => {
return status === "A" ? "已推送" : "未推送";
};
const formatTime = (timeStr: string) => {
if (!timeStr) return "";
return timeStr.substring(0, 19);
};
const getAttachmentIcon = (type: string) => {
const iconMap: Record<string, string> = {
image: "image",
video: "videocam",
pdf: "paperclip",
doc: "paperclip",
file: "paperclip",
jpg: "image",
png: "image",
jpeg: "image",
};
return iconMap[type?.toLowerCase()] || "paperclip";
};
//
const getFileName = (filePath: string) => {
if (!filePath) return '';
const parts = filePath.split('/');
return parts[parts.length - 1] || filePath;
};
// detailwb.vue
const previewAttachment = (att: Attachment) => {
const fileUrl = att.url.startsWith('http') ? att.url : imagUrl(att.url);
let fileName = att.name || '未知文件';
const fileFormat = att.type || '';
if (!fileUrl) {
uni.showToast({ title: "附件地址无效", icon: "none" });
return;
}
//
let fileSuffix = fileFormat;
if (!fileSuffix && fileName) {
//
const lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex > 0) {
fileSuffix = fileName.substring(lastDotIndex + 1);
}
}
if (!fileSuffix && att.url) {
// URL
const lastDotIndex = att.url.lastIndexOf('.');
if (lastDotIndex > 0) {
fileSuffix = att.url.substring(lastDotIndex + 1);
}
}
const ext = fileSuffix?.toLowerCase();
//
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'image'].includes(ext)) {
uni.previewImage({
urls: [fileUrl],
current: fileUrl
});
return;
}
// kkview
const fullFileName = (fileSuffix && !fileName.toLowerCase().endsWith(`.${ext}`))
? `${fileName}.${fileSuffix}`
: fileName;
// 使 kkview
previewFileUtil(fileUrl, fullFileName, fileSuffix)
.catch((error: any) => {
console.error('预览失败:', error);
uni.showToast({ title: "预览失败", icon: "none" });
});
};
const goToEdit = () => {
uni.navigateTo({
url: `/pages/view/routine/tz/publish?id=${noticeId.value}`,
});
};
const goToPush = async () => {
try {
uni.showLoading({ title: "推送中..." });
await xxtsSaveByNoticeParamsApi({ noticeId: noticeId.value });
uni.showToast({ title: "推送成功", icon: "success" });
loadNoticeDetail(noticeId.value);
} catch (error) {
console.error("推送失败:", error);
uni.showToast({ title: "推送失败", icon: "none" });
} finally {
uni.hideLoading();
}
};
const goToPushList = () => {
uni.navigateTo({
url: `/pages/view/routine/tz/push-list?noticeId=${noticeId.value}`,
});
};
</script>
<style scoped lang="scss">
.detail-scroll-view {
height: calc(100vh - 100px);
}
.detail-container {
padding: 12px;
}
.cover-image {
width: 100%;
height: 200px;
border-radius: 12px;
margin-bottom: 12px;
object-fit: cover;
}
.info-card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.notice-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
.notice-title {
flex: 1;
font-size: 18px;
font-weight: 600;
color: #333;
line-height: 1.4;
margin-right: 12px;
}
.notice-status {
font-size: 12px;
padding: 4px 10px;
border-radius: 12px;
color: #fff;
white-space: nowrap;
&.status-published {
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
}
&.status-draft {
background: linear-gradient(135deg, #faad14 0%, #d48806 100%);
}
}
}
.notice-meta {
.meta-item {
display: flex;
align-items: center;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.meta-label {
font-size: 14px;
color: #999;
width: 70px;
}
.meta-value {
font-size: 14px;
color: #333;
&.type-tag {
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
padding: 2px 8px;
border-radius: 4px;
}
}
}
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
display: block;
}
.notice-content {
font-size: 15px;
color: #666;
line-height: 1.8;
// +
:deep(img) {
max-width: 100% !important;
height: auto !important;
display: block;
margin: 10px auto;
border-radius: 4px;
cursor: pointer;
pointer-events: auto;
}
}
.attachment-list {
.attachment-item {
display: flex;
align-items: center;
padding: 12px;
background: #f5f7fa;
border-radius: 8px;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.attachment-name {
flex: 1;
font-size: 14px;
color: #333;
margin: 0 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.stats-row {
display: flex;
align-items: center;
justify-content: space-around;
padding: 12px 0;
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
.stat-value {
font-size: 24px;
font-weight: 600;
color: #333;
&.success {
color: #52c41a;
}
&.warning {
color: #faad14;
}
}
.stat-label {
font-size: 13px;
color: #999;
margin-top: 4px;
}
}
.stat-divider {
width: 1px;
height: 40px;
background: #f0f0f0;
}
}
.bottom-actions {
display: flex;
padding: 12px 16px;
background: #fff;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
gap: 12px;
.action-btn {
flex: 1;
height: 44px;
border-radius: 22px;
font-size: 15px;
font-weight: 500;
border: none;
&.edit-btn {
background: #f5f7fa;
color: #666;
}
&.push-btn {
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
color: #fff;
}
&.list-btn {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: #fff;
}
}
}
/* 图片预览弹窗 */
.image-preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
.preview-close-tip {
position: absolute;
top: 40px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
}
.preview-image {
max-width: 95%;
max-height: 85vh;
object-fit: contain;
}
}
</style>
<!-- 全局样式处理 rich-text 中的图片自适应 -->
<style lang="scss">
.notice-content .content-rich-text img {
max-width: 100% !important;
height: auto !important;
display: block;
margin: 10px auto;
border-radius: 4px;
}
</style>

View File

@ -0,0 +1,722 @@
<!-- src/pages/view/routine/tz/detailwb.vue -->
<!-- 通知详情页面 - 接收者查看 -->
<template>
<BasicLayout>
<view class="notice-detail-page">
<!-- 加载遮罩层 -->
<view v-if="isLoading" class="loading-overlay">
<view class="loading-content">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
</view>
<view v-else-if="noticeDetail" class="detail-container">
<!-- 1. 主要内容卡片 -->
<view class="info-card main-content-card">
<view v-if="noticeDetail.coverImg" class="cover-image-container">
<image
:src="imagUrl(noticeDetail.coverImg)"
mode="aspectFill"
class="cover-image"
></image>
</view>
<!-- 标题和状态 -->
<view class="notice-header">
<text class="notice-title">{{ noticeDetail.title }}</text>
<text class="notice-type-tag">{{ noticeDetail.noticeTypeName || '通知' }}</text>
</view>
<!-- 发布人和时间 -->
<view class="notice-meta">
<text class="meta-item">发布人: {{ noticeDetail.publishUserName || '未知' }}</text>
<text class="meta-item">发布时间: {{ formatTime(noticeDetail.publishTime) }}</text>
</view>
<!-- 通知内容 -->
<view class="notice-content">
<view v-if="!contentExpanded && contentPreview.length >= 100">
<text class="content-preview">{{ contentPreview }}</text>
<u-button type="text" size="mini" class="more-btn" @click="contentExpanded = true">展开</u-button>
</view>
<view v-else>
<view id="notice-content-area" class="content-rich-text" v-html="processedContent"></view>
<u-button v-if="contentPreview.length >= 100" type="text" size="mini" class="more-btn" @click="contentExpanded = false">收起</u-button>
</view>
</view>
</view>
<!-- 2. 附件卡片 -->
<view v-if="attachmentList.length > 0" class="info-card attachment-card">
<text class="section-title">📎 附件</text>
<view class="attachment-list">
<view
v-for="(att, index) in attachmentList"
:key="index"
class="attachment-item"
@click="previewAttachment(att)"
>
<uni-icons :type="getAttachmentIcon(att.type)" size="20" color="#409eff"></uni-icons>
<text class="attachment-name">{{ att.name }}</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</view>
</view>
<view v-else class="empty-state">
<uni-icons type="info" size="60" color="#ccc"></uni-icons>
<text>通知详情未找到</text>
</view>
</view>
<template #bottom>
<view class="bottom-actions">
<button
class="action-btn return-btn"
@click="handleReturn"
>
返回
</button>
</view>
</template>
</BasicLayout>
<!-- 图片预览弹窗用于 base64 图片 -->
<view v-if="showImagePreview" class="image-preview-modal" @click="showImagePreview = false">
<view class="preview-close-tip">点击任意处关闭</view>
<image
:src="previewImageSrc"
mode="aspectFit"
class="preview-image"
@click.stop
/>
</view>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { tzFindByIdApi } from "@/api/base/tzApi";
import { imagUrl } from "@/utils";
import { post } from "@/utils/request";
import { previewFile as previewFileUtil } from "@/utils/filePreview";
import { useUserStore } from "@/store/modules/user";
const userStore = useUserStore();
const noticeId = ref<string>("");
const xxtsId = ref<string>(""); // yfzc_xxts ID
const openId = ref<string>(""); // openId
const noticeDetail = ref<any>(null);
const isLoading = ref(false);
const contentExpanded = ref(true); //
const attachmentList = ref<any[]>([]);
const showImagePreview = ref(false); //
const previewImageSrc = ref(''); //
//
const contentPreview = computed(() => {
if (!noticeDetail.value?.content) return '';
const text = noticeDetail.value.content
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/[\r\n]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return text.slice(0, 100) + (text.length > 100 ? '...' : '');
});
//
const processedContent = computed(() => {
if (!noticeDetail.value?.content) return '';
let content = noticeDetail.value.content;
//
content = content.replace(/<img([^>]*)>/gi, (match: string, attrs: string) => {
const imgStyle = 'max-width:100%;height:auto;display:block;cursor:pointer;';
if (/style\s*=/i.test(attrs)) {
return match.replace(/style\s*=\s*["']([^"']*)["']/i,
`style="$1;${imgStyle}"`);
}
return `<img${attrs} style="${imgStyle}">`;
});
return content;
});
// HTML &amp;
const decodeHtmlEntities = (str: string): string => {
if (!str) return str;
const textarea = document.createElement('textarea');
textarea.innerHTML = str;
return textarea.value;
};
// URL
const contentImageUrls = computed(() => {
if (!noticeDetail.value?.content) return [];
const imgRegex = /<img[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi;
const urls: string[] = [];
let match;
while ((match = imgRegex.exec(noticeDetail.value.content)) !== null) {
// HTML
urls.push(decodeHtmlEntities(match[1]));
}
return urls;
});
// 使 DOM
const handleImageClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target && target.tagName === 'IMG') {
let imgSrc = (target as HTMLImageElement).src;
// HTML &amp;
imgSrc = decodeHtmlEntities(imgSrc);
console.log('点击图片,解码后 src:', imgSrc ? imgSrc.substring(0, 100) + '...' : 'empty');
if (imgSrc) {
// URL
const urls = contentImageUrls.value.length > 0
? contentImageUrls.value
: [imgSrc];
// base64 使
if (imgSrc.startsWith('data:image/')) {
previewImageSrc.value = imgSrc;
showImagePreview.value = true;
return;
}
// 使 uni.previewImage
uni.previewImage({ urls: urls, current: imgSrc });
}
}
};
//
const bindImageClickEvent = () => {
nextTick(() => {
setTimeout(() => {
// 使 id
const dom = document.getElementById('notice-content-area');
console.log('绑定事件DOM元素:', dom);
if (dom) {
dom.addEventListener('click', handleImageClick as EventListener);
console.log('事件绑定成功');
//
const imgs = dom.querySelectorAll('img');
console.log('内容中图片数量:', imgs.length);
} else {
console.log('未找到 #notice-content-area 元素');
}
}, 500); // DOM
});
};
const unbindImageClickEvent = () => {
const dom = document.getElementById('notice-content-area');
if (dom) {
dom.removeEventListener('click', handleImageClick as EventListener);
}
};
//
watch([() => noticeDetail.value, () => contentExpanded.value], () => {
if (noticeDetail.value && contentExpanded.value) {
bindImageClickEvent();
}
}, { immediate: true });
onUnmounted(() => {
unbindImageClickEvent();
});
//
const formatTime = (timeStr: string) => {
if (!timeStr) return "";
return timeStr.substring(0, 19);
};
//
const getAttachmentIcon = (type: string) => {
const iconMap: Record<string, string> = {
image: "image",
video: "videocam",
pdf: "paperclip",
doc: "paperclip",
file: "paperclip",
jpg: "image",
png: "image",
jpeg: "image",
};
return iconMap[type?.toLowerCase()] || "paperclip";
};
//
const getFileName = (filePath: string) => {
if (!filePath) return '';
const parts = filePath.split('/');
return parts[parts.length - 1] || filePath;
};
//
const previewAttachment = (att: any) => {
const fileUrl = att.url.startsWith('http') ? att.url : imagUrl(att.url);
let fileName = att.name || '未知文件';
const fileFormat = att.type || '';
if (!fileUrl) {
uni.showToast({ title: "附件地址无效", icon: "none" });
return;
}
//
let fileSuffix = fileFormat;
if (!fileSuffix && fileName) {
//
const lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex > 0) {
fileSuffix = fileName.substring(lastDotIndex + 1);
}
}
if (!fileSuffix && att.url) {
// URL
const lastDotIndex = att.url.lastIndexOf('.');
if (lastDotIndex > 0) {
fileSuffix = att.url.substring(lastDotIndex + 1);
}
}
const ext = fileSuffix?.toLowerCase();
console.log('预览附件:', { fileUrl, fileName, fileSuffix, ext });
//
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'image'].includes(ext)) {
uni.previewImage({
urls: [fileUrl],
current: fileUrl
});
return;
}
// kkview
const fullFileName = (fileSuffix && !fileName.toLowerCase().endsWith(`.${ext}`))
? `${fileName}.${fileSuffix}`
: fileName;
console.log('调用预览:', { fileUrl, fullFileName, fileSuffix });
// 使 kkview
previewFileUtil(fileUrl, fullFileName, fileSuffix)
.catch((error: any) => {
console.error('预览失败:', error);
uni.showToast({ title: "预览失败", icon: "none" });
});
};
//
const loadNoticeDetail = async () => {
if (!noticeId.value) return;
isLoading.value = true;
try {
// 1.
const detailRes = await tzFindByIdApi({ id: noticeId.value });
if (detailRes?.result) {
noticeDetail.value = detailRes.result;
//
if (detailRes.result.fileUrl) {
const urls = detailRes.result.fileUrl.split(",");
const names = (detailRes.result.fileName || "").split(",");
const formats = (detailRes.result.fileFormat || "").split(",");
attachmentList.value = urls.map((url: string, index: number) => ({
url,
name: names[index] || getFileName(url), // fileName
type: formats[index] || "file",
}));
}
}
// 2.
await markAsRead();
} catch (e) {
console.error("加载通知详情失败:", e);
uni.showToast({ title: "加载通知详情失败", icon: "none" });
} finally {
isLoading.value = false;
}
};
//
const markAsRead = async () => {
if (!xxtsId.value) return;
try {
// xxtsId
await post("/api/noticeRecipient/markAsRead", {
xxtsId: xxtsId.value
});
} catch (e) {
console.error("标记已读失败:", e);
}
};
//
const handleReturn = () => {
const pages = getCurrentPages();
if (pages.length === 1) {
// #ifdef H5
if (window.opener) {
window.close();
} else {
uni.showModal({
title: '提示',
content: '请点击左上角关闭按钮退出',
showCancel: false,
confirmText: '知道了'
});
}
// #endif
// #ifdef MP-WEIXIN
// @ts-ignore
wx.closeWindow();
// #endif
// #ifndef H5 || MP-WEIXIN
uni.navigateBack({
delta: 1,
fail: () => {
uni.reLaunch({ url: '/pages/base/service/index' });
}
});
// #endif
} else {
uni.navigateBack({ delta: 1 });
}
};
onLoad(async (options) => {
// URL: ?noticeId=xxx&id=yyy&openId=zzz&from=db
// noticeId = ID, id = yfzc_xxts ID, openId = openId
noticeId.value = options?.noticeId || '';
xxtsId.value = options?.id || '';
openId.value = options?.openId || '';
// openId
if (openId.value) {
try {
const loginSuccess = await userStore.loginByOpenId(openId.value);
if (!loginSuccess) {
uni.showToast({ title: "认证失败,请重新登录", icon: "none" });
return;
}
} catch (e) {
console.error("openId认证失败:", e);
uni.showToast({ title: "认证失败", icon: "none" });
return;
}
}
if (noticeId.value) {
await loadNoticeDetail();
uni.setNavigationBarTitle({ title: "通知详情" });
} else {
uni.showToast({ title: "缺少通知ID", icon: "none" });
}
});
</script>
<!-- 全局样式处理 rich-text 中的图片自适应 -->
<style lang="scss">
.notice-content .content-rich-text img {
max-width: 100% !important;
height: auto !important;
display: block;
margin: 10px auto;
border-radius: 4px;
}
</style>
<style scoped lang="scss">
.notice-detail-page {
background-color: #f4f5f7;
min-height: 100vh;
padding: 15px;
padding-bottom: 80px;
box-sizing: border-box;
}
/* 加载遮罩层样式 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 12px;
padding: 30px 40px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
font-size: 15px;
color: #333;
font-weight: 500;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: #999;
text {
margin-top: 15px;
font-size: 15px;
}
}
/* 通用卡片样式 */
.info-card {
background-color: #ffffff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
/* 主要内容卡片 */
.main-content-card {
.cover-image-container {
margin: -16px -16px 16px -16px;
.cover-image {
width: 100%;
height: 180px;
border-radius: 12px 12px 0 0;
display: block;
}
}
.notice-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
gap: 12px;
.notice-title {
font-size: 18px;
font-weight: bold;
color: #333;
flex: 1;
line-height: 1.4;
}
.notice-type-tag {
font-size: 12px;
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
padding: 4px 10px;
border-radius: 12px;
white-space: nowrap;
}
}
.notice-meta {
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
.meta-item {
display: block;
font-size: 14px;
color: #666;
margin-bottom: 6px;
&:last-child {
margin-bottom: 0;
}
}
}
.notice-content {
.content-preview {
display: block;
font-size: 15px;
color: #666;
line-height: 1.6;
margin-bottom: 8px;
}
.content-rich-text {
font-size: 15px;
color: #666;
line-height: 1.8;
// +
:deep(img) {
max-width: 100% !important;
height: auto !important;
display: block;
margin: 10px auto;
border-radius: 4px;
cursor: pointer;
pointer-events: auto;
}
}
}
}
/* 附件卡片 */
.attachment-card {
.section-title {
display: block;
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.attachment-list {
.attachment-item {
display: flex;
align-items: center;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.attachment-name {
font-size: 14px;
color: #333;
margin: 0 10px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
/* 更多按钮样式 */
.more-btn {
font-size: 14px !important;
color: #409eff !important;
font-weight: 500;
padding: 0 !important;
margin-top: 8px;
}
/* 底部操作 */
.bottom-actions {
display: flex;
justify-content: space-around;
align-items: center;
padding: 12px 15px;
background-color: #ffffff;
border-top: 1px solid #e5e5e5;
gap: 12px;
.action-btn {
flex: 1;
min-width: 100px;
font-size: 15px;
height: 44px;
line-height: 44px;
border-radius: 22px;
border: none;
&::after {
border: none;
}
&.return-btn {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: #ffffff;
}
&.return-btn:active {
background: linear-gradient(135deg, #096dd9 0%, #0050b3 100%);
}
}
}
/* 图片预览弹窗 */
.image-preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
.preview-close-tip {
position: absolute;
top: 40px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
}
.preview-image {
max-width: 95%;
max-height: 85vh;
object-fit: contain;
}
}
</style>

View File

@ -0,0 +1,447 @@
<!-- src/pages/view/routine/tz/index.vue -->
<template>
<view class="tz-list-page">
<!-- 列表内容 -->
<BasicListLayout @register="register" v-model="dataList">
<template v-slot="{ data }">
<view class="tz-card">
<view class="card-header">
<text class="tz-title">{{ data.title }}</text>
<view class="status-wrapper">
<text class="tz-status" :class="getStatusClass(data.noticeStatus)">
{{ getStatusText(data.noticeStatus) }}
</text>
<text
v-if="data.pushType === '2' && data.noticeStatus === 'B'"
class="countdown-text"
>
{{ getCountdown(data.remindTime, data.id) || '已到推送时间' }}
</text>
</view>
</view>
<view class="card-body">
<image
v-if="data.coverImg"
:src="imagUrl(data.coverImg)"
mode="aspectFill"
class="cover-thumbnail"
></image>
<view class="tz-info">
<text class="tz-type">{{ data.noticeTypeName || '通知' }}</text>
<text class="tz-stats">
已读: {{ data.readCount || 0 }} / {{ data.totalCount || 0 }}
</text>
</view>
</view>
<view class="card-footer">
<text class="footer-item">发布者: {{ data.publishUserName || '未知' }}</text>
<text class="footer-item">{{ formatTime(data.publishTime) }}</text>
<view class="footer-actions">
<image
src="@/static/base/details.png"
mode="aspectFit"
class="footer-action-icon"
style="width:22px;height:22px;"
@click="goToDetail(data.id)"
/>
<image
v-if="data.noticeStatus === 'B' && data.pushType !== '2'"
src="@/static/base/push.png"
mode="aspectFit"
class="footer-action-icon"
style="width:22px;height:22px;"
@click="goToPush(data.id)"
/>
</view>
</view>
</view>
</template>
<template #bottom>
<view class="flex-row items-center pb-10 pt-5">
<u-button
text="新增通知"
class="mx-15"
type="primary"
@click="goToPublish"
/>
</view>
</template>
</BasicListLayout>
</view>
</template>
<script lang="ts" setup>
import { useLayout } from "@/components/BasicListLayout/hooks/useLayout";
import { mobileTzListApi } from "@/api/base/tzApi";
import { imagUrl } from "@/utils";
import { ref, onUnmounted } from "vue";
import { onShow } from "@dcloudio/uni-app";
interface TzItem {
id: string;
title: string;
content: string;
noticeType: string;
noticeTypeName: string;
noticeStatus: string; // AB
coverImg: string;
publishUserId: string;
publishUserName: string;
publishTime: string;
readCount: number;
totalCount: number;
pushType?: string; // 1-2-
remindTime?: string; //
}
// 使 BasicListLayout
const [register, { reload }] = useLayout({
api: mobileTzListApi,
componentProps: {},
});
//
const dataList = ref<TzItem[]>([]);
//
const goToDetail = (id: string) => {
uni.navigateTo({
url: `/pages/view/routine/tz/detail?id=${id}`,
});
};
//
const goToPublish = () => {
uni.navigateTo({
url: "/pages/view/routine/tz/publish",
});
};
//
const goToPush = (id: string) => {
uni.navigateTo({
url: `/pages/view/routine/tz/push-list?noticeId=${id}`,
});
};
// CSS
const getStatusClass = (status: string) => {
if (status === "A") return "status-published";
return "status-draft";
};
//
const getStatusText = (status: string) => {
if (status === "A") return "已推送";
return "未推送";
};
//
const formatTime = (timeStr: string) => {
if (!timeStr) return "";
const date = new Date(timeStr);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
2,
"0"
)}-${String(date.getDate()).padStart(2, "0")}`;
};
//
let countdownTimer: ReturnType<typeof setInterval> | null = null;
const countdownRefresh = ref(0); //
const refreshedNoticeIds = ref<Set<string>>(new Set()); // ID
//
const getCountdown = (remindTime: string | undefined, noticeId: string) => {
if (!remindTime) return "";
// 使 countdownRefresh
const _ = countdownRefresh.value;
const now = new Date().getTime();
const targetTime = new Date(remindTime).getTime();
const diff = targetTime - now;
if (diff <= 0) {
//
if (!refreshedNoticeIds.value.has(noticeId)) {
refreshedNoticeIds.value.add(noticeId);
//
setTimeout(() => {
reload();
}, 1000);
}
return "已到推送时间";
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
if (days > 0) {
return `${days}${hours}`;
} else if (hours > 0) {
return `${hours}${minutes}`;
} else if (minutes > 0) {
return `${minutes}${seconds}`;
} else {
return `${seconds}`;
}
};
//
const startCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer);
}
//
countdownTimer = setInterval(() => {
//
countdownRefresh.value++;
}, 1000);
};
onShow(() => {
reload();
startCountdown();
// ID
refreshedNoticeIds.value.clear();
});
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
});
</script>
<style scoped lang="scss">
.tz-list-page {
position: relative;
min-height: 100vh;
background-color: #f5f7fa;
padding: 12px;
box-sizing: border-box;
}
.tz-card {
background-color: #ffffff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border: 1px solid #f0f0f0;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:active {
transform: translateY(1px);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.12);
}
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-radius: 2px;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
gap: 12px;
.tz-title {
font-size: 16px;
font-weight: 600;
color: #2c3e50;
flex: 1;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
}
.status-wrapper {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.tz-status {
font-size: 11px;
padding: 4px 8px;
border-radius: 12px;
color: #fff;
white-space: nowrap;
font-weight: 500;
letter-spacing: 0.5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
&.status-published {
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
}
&.status-draft {
background: linear-gradient(135deg, #faad14 0%, #d48806 100%);
}
}
.countdown-text {
font-size: 10px;
color: #ff4d4f;
white-space: nowrap;
font-weight: 500;
padding: 2px 6px;
background: rgba(255, 77, 79, 0.1);
border-radius: 8px;
border: 1px solid rgba(255, 77, 79, 0.3);
}
}
.card-body {
margin-bottom: 12px;
display: flex;
align-items: flex-start;
.cover-thumbnail {
width: 80px;
height: 60px;
border-radius: 8px;
margin-right: 12px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
object-fit: cover;
}
.tz-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
.tz-type {
font-size: 14px;
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
width: fit-content;
}
.tz-stats {
font-size: 13px;
color: #666;
}
}
}
.card-footer {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
align-items: center;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
.footer-item {
white-space: nowrap;
font-size: 13px;
color: #7f8c8d;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
display: flex;
align-items: center;
&::before {
content: '';
width: 4px;
height: 4px;
background-color: #bdc3c7;
border-radius: 50%;
margin-right: 6px;
flex-shrink: 0;
}
}
.footer-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 18px;
width: 100%;
flex-basis: 100%;
margin-top: 8px;
padding: 0;
background: none;
border: none;
}
.footer-action-icon {
cursor: pointer;
transition: transform 0.1s;
background: none !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
.footer-action-icon:active {
transform: scale(0.92);
}
}
//
.tz-card {
animation: fadeInUp 0.3s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
//
@media (max-width: 375px) {
.tz-list-page {
padding: 8px;
}
.tz-card {
padding: 12px;
margin-bottom: 8px;
}
.card-header .tz-title {
font-size: 15px;
}
.card-footer .footer-item {
max-width: 150px;
font-size: 12px;
}
}
</style>

View File

@ -0,0 +1,955 @@
<template>
<BasicLayout>
<scroll-view scroll-y class="form-scroll-view">
<view class="form-container">
<!-- 基本信息卡片 -->
<view class="info-card main-content-card">
<view class="form-item">
<text class="form-label title-label">通知标题 <text class="required">*</text></text>
<uni-easyinput
type="textarea"
autoHeight
v-model="formData.title"
placeholder="请输入通知标题"
:inputBorder="true"
placeholder-style="font-weight:bold; font-size: 15px; color: #999;"
class="title-input"
></uni-easyinput>
</view>
<view class="form-item">
<text class="form-label content-label">通知内容 <text class="required">*</text></text>
<BasicEditor
v-model="formData.content"
placeholder="请输入通知内容,支持插入图片"
/>
</view>
<view class="form-item attachments-section">
<text class="form-label">附件</text>
<view class="attachment-list">
<view
v-for="(att, index) in attachmentList"
:key="index"
class="attachment-item"
>
<uni-icons
:type="getAttachmentIcon(att.type)"
size="20"
color="#666"
class="attachment-icon"
></uni-icons>
<text class="attachment-name" @click="previewAttachment(att)">{{ att.name }}</text>
<uni-icons
type="closeempty"
size="18"
color="#999"
class="remove-icon"
@click="removeAttachment(index)"
></uni-icons>
</view>
</view>
<view class="add-attachment-placeholder" @click="addAttachment">
<view class="add-icon">
<uni-icons type="plusempty" size="20" color="#ccc"></uni-icons>
</view>
<text class="placeholder-text">添加图文/视频/文件</text>
</view>
</view>
</view>
<!-- 通知类型 -->
<view class="info-card list-item-card">
<picker
mode="selector"
:range="noticeTypeOptions"
range-key="label"
@change="handleNoticeTypeChange"
>
<view class="list-item-row">
<text class="list-label">通知类型</text>
<view class="list-value">
<text>{{ noticeTypeText }}</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</picker>
</view>
<!-- 推送教师 -->
<view class="info-card teacher-select-card">
<BasicTeacherSelect
v-model="selectedTeachers"
label="推送教师"
placeholder="请选择教师"
:showTags="true"
:maxDisplayCount="maxDisplayCount"
@change="onTeachersChange"
/>
</view>
<!-- 推送类型 -->
<view class="info-card list-item-card">
<view class="list-item-row no-border">
<text class="list-label">推送类型</text>
<view class="push-type-options">
<view
class="push-type-option"
:class="{ active: formData.pushType === '1' }"
@click="formData.pushType = '1'"
>
<view class="radio-circle" :class="{ checked: formData.pushType === '1' }"></view>
<text>立即推送</text>
</view>
<view
class="push-type-option"
:class="{ active: formData.pushType === '2' }"
@click="formData.pushType = '2'"
>
<view class="radio-circle" :class="{ checked: formData.pushType === '2' }"></view>
<text>定时推送</text>
</view>
</view>
</view>
</view>
<!-- 提醒时间定时推送时显示 -->
<view class="info-card list-item-card" v-if="formData.pushType === '2'">
<view class="list-item-row" @click="remindTimePicker?.open()">
<text class="list-label">提醒时间</text>
<view class="list-value">
<text :class="{ placeholder: !formData.remindTime }">
{{ formData.remindTime || "请选择定时推送时间" }}
</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</view>
<!-- 发布时间 -->
<view class="info-card list-item-card">
<view class="list-item-row" @click="publishTimePicker?.open()">
<text class="list-label">发布时间</text>
<view class="list-value">
<text :class="{ placeholder: !formData.publishTime }">
{{ formData.publishTime || "请选择" }}
</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- Bottom slot -->
<template #bottom>
<view class="bottom-actions">
<button class="action-btn draft-btn" @click="saveDraft">
保存草稿
</button>
<button class="action-btn publish-btn" @click="publishNotice" :disabled="isPublishing">
{{ isPublishing ? '发布中...' : '立即发布' }}
</button>
</view>
</template>
<!-- 发布时间选择器 -->
<DatetimePicker
ref="publishTimePicker"
:value="formData.publishTime || new Date().getTime()"
mode="datetime"
title="选择发布时间"
@confirm="handlePublishTimeConfirm"
/>
<!-- 提醒时间选择器定时推送 -->
<DatetimePicker
ref="remindTimePicker"
:value="formData.remindTime || new Date().getTime()"
mode="datetime"
title="选择定时推送时间"
@confirm="handleRemindTimeConfirm"
/>
</BasicLayout>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import BasicTeacherSelect from "@/components/BasicTeacherSelect/index.vue";
import BasicEditor from "@/components/BasicForm/components/BasicEditor.vue";
import { attachmentUpload } from "@/api/system/upload";
import { imagUrl } from "@/utils";
import { tzSaveApi, tzFindByIdApi } from "@/api/base/tzApi";
import { dicApi } from "@/api/system/dic";
import { useUserStore } from "@/store/modules/user";
import DatetimePicker from "@/components/BasicPicker/TimePicker/DatetimePicker.vue";
interface Teacher {
id: string;
jsxm: string;
jsId?: string;
dzzw?: string;
qtzw?: string;
}
interface Attachment {
name: string;
type: string;
url: string;
}
interface FormData {
id?: string;
title: string;
content: string;
coverImg: string;
fileUrl: string;
fileName: string;
fileFormat: string;
noticeType: string;
noticeTypeName: string;
jsId: string;
publishUserId: string;
publishUserName: string;
publishTime: string;
pushType: string; // 1-2-
remindTime: string; // 使
status: string;
noticeStatus: string;
}
const userStore = useUserStore();
const noticeId = ref<string | null>(null);
const isPublishing = ref(false);
//
const publishTimePicker = ref<any>(null);
const remindTimePicker = ref<any>(null);
//
const formData = reactive<FormData>({
title: "",
content: "",
coverImg: "",
fileUrl: "",
fileName: "",
fileFormat: "",
noticeType: "",
noticeTypeName: "",
jsId: "",
publishUserId: "",
publishUserName: "",
publishTime: "",
pushType: "1", //
remindTime: "", //
status: "A",
noticeStatus: "B", //
});
//
const attachmentList = ref<Attachment[]>([]);
// pid: 2146632830
const noticeTypeOptions = ref<{ label: string; value: string }[]>([]);
const noticeTypeText = computed(() => {
const selected = noticeTypeOptions.value.find(
(item) => item.value === formData.noticeType
);
return selected?.label || "请选择";
});
//
const loadNoticeTypeDict = async () => {
try {
const res = await dicApi({ pid: 2146632830 });
if (res?.result && Array.isArray(res.result)) {
noticeTypeOptions.value = res.result.map((item: any) => ({
label: item.dictionaryValue || item.label || item.name,
value: item.dictionaryCode || item.value || item.id,
}));
}
} catch (error) {
console.error("加载通知类型字典失败:", error);
// 使
noticeTypeOptions.value = [
{ label: "会议通知", value: "MEETING" },
{ label: "工作安排", value: "WORK" },
{ label: "公告", value: "ANNOUNCE" },
{ label: "其他", value: "OTHER" },
];
}
};
//
const selectedTeachers = ref<Teacher[]>([]);
const maxDisplayCount = 24;
//
const onTeachersChange = (teachers: Teacher[]) => {
selectedTeachers.value = teachers;
};
//
onLoad(async (options: any) => {
//
await loadNoticeTypeDict();
//
if (options?.id) {
noticeId.value = options.id;
await loadNoticeDetail(options.id);
}
initPublisher();
});
//
const initPublisher = () => {
const js = userStore.getJs;
if (js) {
formData.publishUserId = js.id;
formData.publishUserName = js.jsxm;
}
//
formData.publishTime = formatDateTime(new Date());
};
//
const loadNoticeDetail = async (id: string) => {
try {
uni.showLoading({ title: "加载中..." });
const res = await tzFindByIdApi({ id });
if (res?.result) {
const data = res.result;
Object.assign(formData, {
id: data.id,
title: data.title,
content: data.content,
coverImg: data.coverImg,
fileUrl: data.fileUrl,
fileName: data.fileName,
fileFormat: data.fileFormat,
noticeType: data.noticeType,
noticeTypeName: data.noticeTypeName,
jsId: data.jsId,
publishUserId: data.publishUserId,
publishUserName: data.publishUserName,
publishTime: data.publishTime,
pushType: data.pushType || '1', //
remindTime: data.remindTime || '', //
status: data.status,
noticeStatus: data.noticeStatus,
});
//
if (data.fileUrl) {
const urls = data.fileUrl.split(",");
const names = (data.fileName || "").split(",");
const formats = (data.fileFormat || "").split(",");
attachmentList.value = urls.map((url: string, index: number) => ({
url,
name: names[index] || `文件${index + 1}`,
type: formats[index] || "file",
}));
}
//
if (data.jsId) {
const jsIds = data.jsId.split(",");
// localStorage
try {
const storageData = uni.getStorageSync('app-common');
if (storageData) {
let parsedData = typeof storageData === 'string' ? JSON.parse(storageData) : storageData;
let allJsData = parsedData?.data?.allJsBasicInfoVo?.result || parsedData?.allJsBasicInfoVo?.result || [];
selectedTeachers.value = allJsData
.filter((t: any) => jsIds.includes(t.id || t.jsId))
.map((t: any) => ({
id: t.id || t.jsId,
jsxm: t.jsxm || t.name,
jsId: t.id || t.jsId,
dzzw: t.dzzw || '',
qtzw: t.qtzw || ''
}));
}
} catch (e) {
console.error('解析教师数据失败:', e);
}
}
}
} catch (error) {
console.error("加载通知详情失败:", error);
uni.showToast({ title: "加载失败", icon: "none" });
} finally {
uni.hideLoading();
}
};
//
const addAttachment = () => {
uni.chooseFile({
count: 5,
type: "all",
success: (res) => {
const files = Array.isArray(res.tempFiles) ? res.tempFiles : [res.tempFiles];
files.forEach((file: any) => {
uploadAttachment(file);
});
},
fail: (error) => {
console.error("选择文件失败:", error);
uni.showToast({
title: "选择文件失败",
icon: "none"
});
},
});
};
//
const uploadAttachment = async (file: any) => {
try {
uni.showLoading({ title: "上传中..." });
const uploadRes: any = await attachmentUpload(file.path as unknown as Blob);
if (uploadRes?.result?.[0]?.filePath) {
attachmentList.value.push({
name: file.name,
type: getFileType(file.name),
url: uploadRes.result[0].filePath,
});
uni.showToast({
title: "上传成功",
icon: "success"
});
} else {
uni.showToast({
title: uploadRes?.message || "上传失败",
icon: "none"
});
}
} catch (error) {
console.error("附件上传失败:", error);
uni.showToast({
title: "上传失败",
icon: "none"
});
} finally {
uni.hideLoading();
}
};
const removeAttachment = (index: number) => {
attachmentList.value.splice(index, 1);
};
const previewAttachment = (att: Attachment) => {
uni.previewImage({
urls: [imagUrl(att.url)],
fail: () => {
uni.showToast({ title: "预览失败", icon: "none" });
},
});
};
const getAttachmentIcon = (type: string) => {
const iconMap: Record<string, string> = {
image: "image",
video: "videocam",
pdf: "paperclip",
doc: "paperclip",
file: "paperclip",
};
return iconMap[type] || "paperclip";
};
const getFileType = (fileName: string) => {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) return "image";
if (["mp4", "avi", "mov"].includes(ext)) return "video";
if (ext === "pdf") return "pdf";
if (["doc", "docx"].includes(ext)) return "doc";
return "file";
};
//
const handleNoticeTypeChange = (e: any) => {
const index = e.detail.value;
formData.noticeType = noticeTypeOptions.value[index].value;
formData.noticeTypeName = noticeTypeOptions.value[index].label;
};
// - DatetimePicker
const handlePublishTimeConfirm = (e: any) => {
// e.value
if (typeof e.value === 'number') {
formData.publishTime = formatDateTime(new Date(e.value));
} else {
formData.publishTime = e.value;
}
};
const handleRemindTimeConfirm = (e: any) => {
// e.value
if (typeof e.value === 'number') {
formData.remindTime = formatDateTime(new Date(e.value));
} else {
formData.remindTime = e.value;
}
};
const formatDateTime = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
// 稿
const saveDraft = async () => {
if (!validateForm()) return;
try {
isPublishing.value = true;
uni.showLoading({ title: "保存中..." });
const submitData = buildSubmitData();
submitData.noticeStatus = "B"; // 稿
await tzSaveApi(submitData);
uni.showToast({ title: "保存成功", icon: "success" });
setTimeout(() => {
uni.navigateBack();
}, 1500);
} catch (error) {
console.error("保存失败:", error);
uni.showToast({ title: "保存失败", icon: "none" });
} finally {
isPublishing.value = false;
uni.hideLoading();
}
};
//
const publishNotice = async () => {
if (!validateForm()) return;
try {
isPublishing.value = true;
uni.showLoading({ title: "发布中..." });
const submitData = buildSubmitData();
submitData.noticeStatus = "B"; //
const result: any = await tzSaveApi(submitData);
if (result.resultCode === 1) {
uni.showToast({ title: "发布成功", icon: "success" });
setTimeout(() => {
//
if (formData.pushType === '2') {
//
uni.redirectTo({
url: '/pages/view/routine/tz/index'
});
} else {
//
uni.redirectTo({
url: `/pages/view/routine/tz/push-list?noticeId=${result.result}`
});
}
}, 1500);
} else {
uni.showToast({ title: result.message || "发布失败", icon: "none" });
}
} catch (error) {
console.error("发布失败:", error);
uni.showToast({ title: "发布失败", icon: "none" });
} finally {
isPublishing.value = false;
uni.hideLoading();
}
};
//
const validateForm = () => {
if (!formData.title.trim()) {
uni.showToast({ title: "请输入通知标题", icon: "none" });
return false;
}
if (!formData.content.trim()) {
uni.showToast({ title: "请输入通知内容", icon: "none" });
return false;
}
if (!formData.noticeType) {
uni.showToast({ title: "请选择通知类型", icon: "none" });
return false;
}
if (selectedTeachers.value.length === 0) {
uni.showToast({ title: "请选择推送教师", icon: "none" });
return false;
}
//
if (formData.pushType === '2' && !formData.remindTime) {
uni.showToast({ title: "请选择定时推送时间", icon: "none" });
return false;
}
return true;
};
//
const buildSubmitData = () => {
//
const fileUrls = attachmentList.value.map((a) => a.url);
const fileNames = attachmentList.value.map((a) => a.name.replace(/\.[^/.]+$/, ""));
const fileFormats = attachmentList.value.map((a) => {
const ext = a.name.split(".").pop() || "";
return ext;
});
return {
id: formData.id || undefined,
title: formData.title,
content: formData.content,
coverImg: formData.coverImg,
fileUrl: fileUrls.join(","),
fileName: fileNames.join(","),
fileFormat: fileFormats.join(","),
noticeType: formData.noticeType,
noticeTypeName: formData.noticeTypeName,
jsId: selectedTeachers.value.map((t) => t.id).join(","),
publishUserId: formData.publishUserId,
publishUserName: formData.publishUserName,
publishTime: formData.publishTime,
pushType: formData.pushType,
remindTime: formData.pushType === '2' ? formData.remindTime : '', //
status: "A",
noticeStatus: formData.noticeStatus,
};
};
</script>
<style scoped lang="scss">
.form-scroll-view {
height: calc(100vh - 130px);
}
.form-container {
padding: 12px;
padding-bottom: 24px;
}
.info-card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.main-content-card {
.form-item {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
}
.title-label {
display: block;
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
.required {
color: #ff4d4f;
margin-left: 4px;
}
}
.title-input {
:deep(.uni-easyinput__content-textarea) {
font-size: 18px;
font-weight: 600;
min-height: 60px;
}
:deep(.uni-easyinput__content) {
border: 1px solid #e0e0e0;
border-radius: 8px;
}
}
.content-label {
display: block;
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
.required {
color: #ff4d4f;
margin-left: 4px;
}
}
.attachments-section {
.form-label {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
display: block;
}
.attachment-list {
margin-bottom: 12px;
}
.attachment-item {
display: flex;
align-items: center;
padding: 10px 12px;
background: #f5f7fa;
border-radius: 8px;
margin-bottom: 8px;
.attachment-icon {
margin-right: 10px;
}
.attachment-name {
flex: 1;
font-size: 14px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-icon {
padding: 4px;
}
}
.add-attachment-placeholder {
display: flex;
align-items: center;
padding: 12px;
border: 1px dashed #ddd;
border-radius: 8px;
background: #fafafa;
.add-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
}
.placeholder-text {
font-size: 14px;
color: #999;
}
}
}
.list-item-card {
padding: 0;
min-height: auto;
.list-item-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
min-height: 52px;
&.no-border {
border-bottom: none;
}
.list-label {
font-size: 15px;
color: #333;
flex-shrink: 0;
}
.list-value {
display: flex;
align-items: center;
flex: 1;
justify-content: flex-end;
text {
font-size: 14px;
color: #666;
margin-right: 4px;
&.placeholder {
color: #999;
}
}
}
//
.push-type-options {
display: flex;
flex-direction: row;
align-items: center;
.push-type-option {
display: flex;
flex-direction: row;
align-items: center;
padding: 6px 12px;
margin-left: 12px;
border-radius: 16px;
background: #f5f7fa;
&.active {
background: rgba(24, 144, 255, 0.1);
}
.radio-circle {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #d9d9d9;
margin-right: 6px;
box-sizing: border-box;
&.checked {
border-color: #1890ff;
background: #1890ff;
position: relative;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: #fff;
}
}
}
text {
font-size: 14px;
color: #333;
}
}
}
}
}
.card-header {
&.picker-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.section-title {
font-size: 15px;
font-weight: 600;
color: #333;
}
.target-class {
display: flex;
align-items: center;
text {
font-size: 14px;
color: #666;
margin-right: 4px;
&.placeholder {
color: #999;
}
}
}
}
}
.name-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
.name-tag {
font-size: 13px;
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
padding: 4px 10px;
border-radius: 16px;
}
}
.more-btn-container {
margin-top: 12px;
text-align: center;
.more-btn-full {
font-size: 13px;
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
border: none;
}
}
.bottom-actions {
display: flex;
padding: 12px 16px;
background: #fff;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
.action-btn {
flex: 1;
height: 44px;
border-radius: 22px;
font-size: 16px;
font-weight: 500;
border: none;
&.draft-btn {
background: #f5f7fa;
color: #666;
margin-right: 12px;
}
&.publish-btn {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: #fff;
&:disabled {
opacity: 0.6;
}
}
}
}
//
.teacher-select-card {
padding: 0;
}
</style>

View File

@ -0,0 +1,384 @@
<template>
<BasicLayout>
<view class="push-list-page">
<!-- 统计信息 -->
<view class="stats-card">
<view class="stat-item">
<text class="stat-value">{{ recipientList.length }}</text>
<text class="stat-label">总人数</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value success">{{ readCount }}</text>
<text class="stat-label">已读</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value warning">{{ unreadCount }}</text>
<text class="stat-label">未读</text>
</view>
</view>
<!-- 筛选 -->
<view class="filter-bar">
<view
class="filter-item"
:class="{ active: filterType === 'all' }"
@click="filterType = 'all'"
>
全部
</view>
<view
class="filter-item"
:class="{ active: filterType === 'read' }"
@click="filterType = 'read'"
>
已读
</view>
<view
class="filter-item"
:class="{ active: filterType === 'unread' }"
@click="filterType = 'unread'"
>
未读
</view>
</view>
<!-- 名单列表 -->
<scroll-view scroll-y class="list-scroll">
<view class="recipient-list">
<view
v-for="item in filteredList"
:key="item.id"
class="recipient-item"
>
<view class="recipient-info">
<text class="recipient-name">{{ item.jsxm }}</text>
<text class="recipient-dept" v-if="item.deptName">{{ item.deptName }}</text>
</view>
<view class="recipient-status">
<text
class="status-tag"
:class="item.isRead === '1' ? 'read' : 'unread'"
>
{{ item.isRead === '1' ? '已读' : '未读' }}
</text>
<text class="read-time" v-if="item.isRead === '1' && item.readTime">
{{ formatTime(item.readTime) }}
</text>
</view>
</view>
<view v-if="filteredList.length === 0" class="empty-tip">
<uni-icons type="info" size="40" color="#ccc"></uni-icons>
<text>暂无数据</text>
</view>
</view>
</scroll-view>
</view>
<!-- 底部操作 -->
<template #bottom>
<view class="bottom-actions" v-if="noticeStatus !== 'A' && pushType !== '2'">
<button class="action-btn push-btn" @click="handlePush" :disabled="isPushing">
{{ isPushing ? '推送中...' : '立即推送' }}
</button>
</view>
</template>
</BasicLayout>
</template>
<script lang="ts" setup>
import { ref, computed } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { tzRecipientFindByNoticeIdApi, xxtsSaveByNoticeParamsApi, tzFindByIdApi } from "@/api/base/tzApi";
interface Recipient {
id: string;
noticeId: string;
jsId: string;
jsxm: string;
deptId: string;
deptName: string;
isPushed: string;
isRead: string;
readTime: string;
readStatus: string;
}
const noticeId = ref<string>("");
const noticeStatus = ref<string>("B");
const pushType = ref<string>("1"); // 1-2-
const recipientList = ref<Recipient[]>([]);
const filterType = ref<string>("all");
const isPushing = ref(false);
//
const readCount = computed(() => {
return recipientList.value.filter((item) => item.isRead === "1").length;
});
const unreadCount = computed(() => {
return recipientList.value.filter((item) => item.isRead !== "1").length;
});
//
const filteredList = computed(() => {
if (filterType.value === "read") {
return recipientList.value.filter((item) => item.isRead === "1");
}
if (filterType.value === "unread") {
return recipientList.value.filter((item) => item.isRead !== "1");
}
return recipientList.value;
});
onLoad((options: any) => {
if (options?.noticeId) {
noticeId.value = options.noticeId;
}
});
onShow(() => {
if (noticeId.value) {
loadRecipientList();
loadNoticeStatus();
}
});
const loadNoticeStatus = async () => {
try {
const res = await tzFindByIdApi({ id: noticeId.value });
if (res?.result) {
noticeStatus.value = res.result.noticeStatus;
pushType.value = res.result.pushType || "1";
}
} catch (error) {
console.error("加载通知状态失败:", error);
}
};
const loadRecipientList = async () => {
try {
uni.showLoading({ title: "加载中..." });
const res = await tzRecipientFindByNoticeIdApi({ noticeId: noticeId.value });
if (res?.result) {
recipientList.value = res.result;
}
} catch (error) {
console.error("加载推送名单失败:", error);
uni.showToast({ title: "加载失败", icon: "none" });
} finally {
uni.hideLoading();
}
};
const formatTime = (timeStr: string) => {
if (!timeStr) return "";
return timeStr.substring(0, 16);
};
const handlePush = async () => {
try {
isPushing.value = true;
uni.showLoading({ title: "推送中..." });
const res = await xxtsSaveByNoticeParamsApi({ noticeId: noticeId.value });
if (res?.resultCode === 1) {
uni.showToast({ title: res.message || "推送成功", icon: "success" });
//
setTimeout(() => {
uni.redirectTo({
url: '/pages/view/routine/tz/index'
});
}, 1500);
} else {
uni.showToast({ title: res?.message || "推送失败", icon: "none" });
}
} catch (error) {
console.error("推送失败:", error);
uni.showToast({ title: "推送失败", icon: "none" });
} finally {
isPushing.value = false;
uni.hideLoading();
}
};
</script>
<style scoped lang="scss">
.push-list-page {
min-height: 100vh;
background: #f5f7fa;
display: flex;
flex-direction: column;
}
.stats-card {
display: flex;
align-items: center;
justify-content: space-around;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
padding: 20px;
margin: 12px;
border-radius: 12px;
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
.stat-value {
font-size: 28px;
font-weight: 600;
color: #fff;
&.success {
color: #b7eb8f;
}
&.warning {
color: #ffe58f;
}
}
.stat-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
margin-top: 4px;
}
}
.stat-divider {
width: 1px;
height: 40px;
background: rgba(255, 255, 255, 0.3);
}
}
.filter-bar {
display: flex;
background: #fff;
margin: 0 12px 12px;
border-radius: 8px;
padding: 4px;
.filter-item {
flex: 1;
text-align: center;
padding: 10px 0;
font-size: 14px;
color: #666;
border-radius: 6px;
transition: all 0.3s;
&.active {
background: #1890ff;
color: #fff;
}
}
}
.list-scroll {
flex: 1;
height: calc(100vh - 280px);
}
.recipient-list {
padding: 0 12px;
}
.recipient-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
padding: 14px 16px;
border-radius: 10px;
margin-bottom: 10px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
.recipient-info {
flex: 1;
.recipient-name {
font-size: 15px;
font-weight: 500;
color: #333;
display: block;
}
.recipient-dept {
font-size: 13px;
color: #999;
margin-top: 4px;
display: block;
}
}
.recipient-status {
display: flex;
flex-direction: column;
align-items: flex-end;
.status-tag {
font-size: 12px;
padding: 3px 10px;
border-radius: 12px;
&.read {
background: rgba(82, 196, 26, 0.1);
color: #52c41a;
}
&.unread {
background: rgba(250, 173, 20, 0.1);
color: #faad14;
}
}
.read-time {
font-size: 11px;
color: #999;
margin-top: 4px;
}
}
}
.empty-tip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
text {
font-size: 14px;
color: #999;
margin-top: 12px;
}
}
.bottom-actions {
padding: 12px 16px;
background: #fff;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
.action-btn {
width: 100%;
height: 44px;
border-radius: 22px;
font-size: 16px;
font-weight: 500;
border: none;
&.push-btn {
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
color: #fff;
&:disabled {
opacity: 0.6;
}
}
}
}
</style>