功能调整
This commit is contained in:
parent
85f74332c0
commit
65a5992e57
26
src/api/base/srApi.ts
Normal file
26
src/api/base/srApi.ts
Normal 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
48
src/api/base/tzApi.ts
Normal 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);
|
||||
};
|
||||
|
||||
@ -37,6 +37,15 @@ export const findAllZw = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
13
src/components/BasicTeacherSelect/index.ts
Normal file
13
src/components/BasicTeacherSelect/index.ts
Normal 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;
|
||||
|
||||
204
src/components/BasicTeacherSelect/index.vue
Normal file
204
src/components/BasicTeacherSelect/index.vue
Normal 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>
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
1227
src/pages/common/selectTeachers/index.vue
Normal file
1227
src/pages/common/selectTeachers/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -556,7 +556,7 @@ const handleBjConfirm = (e: any) => {
|
||||
// 跳转到教师选择页面
|
||||
const showTeacherTree = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/view/routine/qd/selectTeachers'
|
||||
url: '/pages/common/selectTeachers/index'
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -591,7 +591,7 @@ const openCategoryPicker = () => {
|
||||
// 跳转到教师选择页面
|
||||
const showTeacherTree = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/view/routine/qd/selectTeachers'
|
||||
url: '/pages/common/selectTeachers/index'
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -612,7 +612,7 @@ const handleSignatureChange = (e: any) => {
|
||||
|
||||
const showTeacherTree = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/view/routine/qd/selectTeachers'
|
||||
url: '/pages/common/selectTeachers/index'
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
695
src/pages/view/routine/sr/card.vue
Normal file
695
src/pages/view/routine/sr/card.vue
Normal 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>
|
||||
658
src/pages/view/routine/sr/envelope.vue
Normal file
658
src/pages/view/routine/sr/envelope.vue
Normal 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>
|
||||
664
src/pages/view/routine/tz/detail.vue
Normal file
664
src/pages/view/routine/tz/detail.vue
Normal 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 实体解码(处理 & 等)
|
||||
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 实体(处理 & 等问题)
|
||||
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>
|
||||
|
||||
722
src/pages/view/routine/tz/detailwb.vue
Normal file
722
src/pages/view/routine/tz/detailwb.vue
Normal 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(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/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 实体解码(处理 & 等)
|
||||
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 实体(处理 & 等问题)
|
||||
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>
|
||||
|
||||
447
src/pages/view/routine/tz/index.vue
Normal file
447
src/pages/view/routine/tz/index.vue
Normal 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; // A已推送,B未推送
|
||||
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>
|
||||
|
||||
955
src/pages/view/routine/tz/publish.vue
Normal file
955
src/pages/view/routine/tz/publish.vue
Normal 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>
|
||||
|
||||
384
src/pages/view/routine/tz/push-list.vue
Normal file
384
src/pages/view/routine/tz/push-list.vue
Normal 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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user