新增签到管理

This commit is contained in:
hb 2025-07-23 22:32:01 +08:00
parent 48c2f73396
commit bc28a17e01
15 changed files with 3944 additions and 34 deletions

View File

@ -58,6 +58,7 @@
"lodash": "4.17.21",
"pinia": "2.0.23",
"pinia-plugin-persist-uni": "1.2.0",
"qrcode": "^1.5.3",
"uview-plus": "3.1.20",
"vconsole": "3.15.1",
"vue": "3.2.45",
@ -72,6 +73,7 @@
"@types/html5plus": "1.0.2",
"@types/lodash": "4.14.191",
"@types/node": "18.11.15",
"@types/qrcode": "^1.5.5",
"@types/uni-app": "1.4.4",
"sass": "1.56.0",
"sass-loader": "10.1.1",

View File

@ -64,6 +64,26 @@ export const jsdfindJsByPhoneApi = async (params: any) => {
return await get("/api/js/findJsByPhone", params);
};
// 根据年级/班级ID查询教师
export const jsFindByNjIdOrBjIdApi = async (params: { njOrBjId: string }) => {
return await get(`/api/js/findByNjIdOrBjId?njOrBjId=${params.njOrBjId}`);
};
// 根据科目ID查询教师
export const jsFindByKmIdApi = async (params: { kmId: string }) => {
return await get(`/api/js/findByKmId?kmId=${params.kmId}`);
};
// 根据班主任年级ID查询教师
export const jsFindByBzrNjIdApi = async (params: { njId: string }) => {
return await get(`/api/js/findByBzrNjId?njId=${params.njId}`);
};
// 根据职务ID查询教师
export const jsFindByZwIdApi = async (params: { zwId: string; zwType: string }) => {
return await get(`/api/js/findByZwId?zwId=${params.zwId}&zwType=${params.zwType}`);
};
// 选课列表
export const jsdXkListApi = async (params: any) => {
return await get("/mobile/js/xk/list", params);
@ -208,3 +228,64 @@ export const drpkkbApi = async (params: any) => {
export const getRzRjApi = async (params: any) => {
return await get("/mobile/js/getRzRj", params);
};
// 签到发布相关API
// 获取签到发布列表
export const qdzxFindPageApi = async (params: any) => {
return await get("/api/qdzx/findPage", params);
};
// 保存签到发布(新增/编辑)
export const qdzxSaveApi = async (params: any) => {
return await post("/api/qdzx/save", params);
};
// 根据签到ID查询签到执行情况教师列表
export const qdzxFindByQdParamsApi = async (params: { qdId: string }) => {
return await get("/api/qdzx/findByQdParams", params);
};
export const qdzxFindByQdAndJsApi = async (params: { qdId: string; jsId: string }) => {
return await get("/api/qdzx/findByQdAndJs", params);
};
// 保存签到消息推送
export const xxtsSaveByQdzxParamsApi = async (params: { qdId: string }) => {
return await post("/api/xxts/saveByQdzxParams", params);
};
// 签到主表相关API
// 获取签到列表
export const qdFindPageApi = async (params: any) => {
return await get("/api/qd/findPage", params);
};
// 保存签到(新增/编辑)
export const qdSaveApi = async (params: any) => {
return await post("/api/qd/save", params);
};
// 根据ID获取签到详情
export const qdFindByIdApi = async (params: { id: string }) => {
return await get(`/api/qd/findById?id=${params.id}`);
};
// 签到执行相关API
export const qdzxSignInApi = async (params: any) => {
return await post("/api/qdzx/signIn", params);
};
// 生成签到二维码
export const generateQRCodeApi = async (params: { qdId: string }) => {
return await post("/api/qdzx/generateQRCode", params);
};
// 年级相关API
export const findAllNj = async () => {
return await get("/api/nj/findAllNj");
};
// 职务相关API
export const zwFindAllApi = async () => {
return await get("/api/zw/findAll");
};

View File

@ -493,6 +493,50 @@
"navigationBarTitleText": "学生请假审批",
"enablePullDownRefresh": false
}
},
{
"path": "pages/view/routine/qd/index",
"style": {
"navigationBarTitleText": "签到发布"
}
},
{
"path": "pages/view/routine/qd/publish",
"style": {
"navigationBarTitleText": "新增签到"
}
},
{
"path": "pages/view/routine/qd/push-list",
"style": {
"navigationBarTitleText": "推送清单"
}
},
{
"path": "pages/view/routine/qd/detail",
"style": {
"navigationBarTitleText": "签到详情"
}
},
{
"path": "pages/view/routine/qd/selectTeachers",
"style": {
"navigationBarTitleText": "选择教师"
}
},
{
"path": "pages/view/routine/qd/qr-code",
"style": {
"navigationBarTitleText": "签到二维码",
"navigationStyle": "custom"
}
},
{
"path": "pages/view/routine/qd/confirm",
"style": {
"navigationBarTitleText": "确认签到",
"navigationStyle": "custom"
}
}
],
"globalStyle": {

View File

@ -232,6 +232,13 @@ const sections = reactive<Section[]>([
show: true,
path: "/pages/view/notice/index",
},
{
id: "r10",
icon: "draftfill",
text: "签到发布",
show: true,
path: "/pages/view/routine/qd/index",
},
],
},
{

View File

@ -295,7 +295,7 @@ const signatureStatusText = computed(() => {
});
//
const treeData = ref<any[]>([]);
const treeData = ref([]);
const treeRef = ref();
//
@ -310,23 +310,19 @@ const displayNames = computed(() => {
// ref
const coverUploadRef = ref();
//
interface ApiResponse<T = any> {
resultCode: number;
result?: T;
resultMsg?: string;
data?: any;
[key: string]: any;
}
//
const loadTreeData = async () => {
try {
const res = await findAllNjBjTree() as ApiResponse<any[]>;
if (res && res.resultCode === 1 && res.result) {
const res = await findAllNjBjTree();
if (res.resultCode === 1 && res.result) {
//
console.log("=== 后端返回的原始树形数据 ===");
console.log("res.result:", res.result);
console.log("================================");
// BasicTree njmcId
const convertTreeData = (items: Array<{ key: string; title: string; njmcId?: string; children?: any[] }>): any[] => {
return items.map((item) => ({
const convertTreeData = (items: any[]): any[] => {
return items.map((item: any) => ({
key: item.key,
title: item.title,
njmcId: item.njmcId, // njmcId
@ -335,6 +331,11 @@ const loadTreeData = async () => {
};
treeData.value = convertTreeData(res.result);
//
console.log("=== 转换后的树形数据 ===");
console.log("treeData.value:", treeData.value);
console.log("==========================");
}
} catch (error) {
uni.showToast({ title: "加载班级数据失败", icon: "error" });
@ -408,7 +409,9 @@ const resetForm = () => {
const loadJlData = async (jlId: string) => {
try {
uni.showLoading({ title: "加载数据中..." });
const response = await jlFindByIdApi({ id: jlId }) as ApiResponse<any>;
//
const response = await jlFindByIdApi({ id: jlId });
if (response && response.resultCode === 1 && response.result) {
const jlData = response.result;
@ -426,10 +429,10 @@ const loadJlData = async (jlId: string) => {
//
if (jlData.jlfj) {
const attachmentUrls = jlData.jlfj.split(",");
formData.attachments = attachmentUrls.map((url: string) => ({
formData.attachments = attachmentUrls.map(url => ({
name: url.split("/").pop() || "附件",
type: "file",
url: url as string
url: url
}));
}
@ -596,7 +599,7 @@ const getAttachmentIcon = (type: string): string => {
const previewAttachment = (attachment: Attachment) => {
//
if (attachment.type === "image") {
const fullUrl = imagUrl(attachment.url as string);
const fullUrl = imagUrl(attachment.url);
uni.previewImage({
urls: [fullUrl],
current: fullUrl,
@ -617,11 +620,11 @@ const showClassTree = () => {
};
//
const onTreeConfirm = async (selectedItems: Array<any>) => {
const onTreeConfirm = async (selectedItems: any[]) => {
if (selectedItems.length > 0) {
//
const classNames = selectedItems.map((item: any) => item.title);
const classNames = selectedItems.map((item) => item.title);
formData.targetClass = classNames.join(", ");
formData.targetNames = [];
formData.targetStudentIds = [];
@ -638,7 +641,7 @@ const onTreeConfirm = async (selectedItems: Array<any>) => {
const bjIds: string[] = [];
const gradeNames: string[] = []; //
selectedItems.forEach((item: any) => {
selectedItems.forEach((item) => {
// parents
if (item.parents && item.parents.length > 0) {
const parent = item.parents[0]; //
@ -649,13 +652,23 @@ const onTreeConfirm = async (selectedItems: Array<any>) => {
// keytreeDatanjmcId
let njmcId: string | undefined;
for (const grade of treeData.value || []) {
for (const grade of treeData.value) {
if (grade.key === njId) {
njmcId = grade.njmcId;
break;
}
}
//
console.log("=== 年级信息调试 ===");
console.log("item:", item);
console.log("parent:", parent);
console.log("njId:", njId);
console.log("njmcId (从treeData查找):", njmcId);
console.log("bjId:", bjId);
console.log("gradeName:", gradeName);
console.log("====================");
//
if (njId && bjId && gradeName) {
njIds.push(njId);
@ -689,6 +702,14 @@ const onTreeConfirm = async (selectedItems: Array<any>) => {
const uniqueNjIds = [...new Set(njIds)];
const uniqueNjmcIds = [...new Set(njmcIds)];
//
console.log("=== 最终设置调试 ===");
console.log("njIds:", njIds);
console.log("njmcIds:", njmcIds);
console.log("uniqueNjIds:", uniqueNjIds);
console.log("uniqueNjmcIds:", uniqueNjmcIds);
console.log("====================");
// IDIDIDformData
formData.targetNjIds = uniqueNjIds;
formData.targetNjmcIds = uniqueNjmcIds;
@ -700,7 +721,7 @@ const onTreeConfirm = async (selectedItems: Array<any>) => {
bjId: bjIds.join(","),
};
const response = await mobilejlstudentListApi(params) as ApiResponse<any[]>;
const response = await mobilejlstudentListApi(params);
if (response && response.resultCode === 1 && response.result) {
//
@ -730,6 +751,7 @@ const onTreeConfirm = async (selectedItems: Array<any>) => {
icon: "success",
});
} catch (error) {
console.error("获取学生列表失败:", error);
uni.hideLoading();
uni.showToast({ title: "获取学生列表失败", icon: "error" });
@ -844,6 +866,14 @@ const buildJlDto = (status: string) => {
const user = userData.value;
const js = jsData.value;
//
console.log("=== buildJlDto 调试信息 ===");
console.log("formData.targetNjIds:", formData.targetNjIds);
console.log("formData.targetNjmcIds:", formData.targetNjmcIds);
console.log("formData.targetBjIds:", formData.targetBjIds);
console.log("targetNjmcIds.join(','):", formData.targetNjmcIds.join(","));
console.log("==========================");
return {
id: formData.id || "", // ID
jlmc: formData.title.trim(), //
@ -878,13 +908,13 @@ const saveDraft = async () => {
const jlDto = buildJlDto("B"); // B
//
const response = await jlSaveApi(jlDto) as ApiResponse<any>;
const response = await jlSaveApi(jlDto);
uni.hideLoading();
if (response && response.resultCode === 1) {
// ID
const jlId = response.result || (response as any).data || jlDto.id;
const jlId = response.result || response.data || jlDto.id;
// ID
if (jlId) {
@ -903,12 +933,15 @@ const saveDraft = async () => {
}, 2000);
} else {
uni.showToast({
title: (response as any)?.resultMsg || "保存草稿失败",
title: response?.resultMsg || "保存草稿失败",
icon: "error"
});
}
} catch (error) {
uni.hideLoading();
console.error("保存草稿失败:", error);
//
if (error instanceof Error) {
uni.showToast({
title: error.message,
@ -941,7 +974,7 @@ const publishNotice = async () => {
// ID
if (formData.id) {
try {
const response = await jlFindByIdApi({ id: formData.id }) as ApiResponse<any>;
const response = await jlFindByIdApi({ id: formData.id });
if (response && response.resultCode === 1 && response.result) {
const jlData = response.result;
if (jlData.jlStatus === 'A') {
@ -949,7 +982,9 @@ const publishNotice = async () => {
return;
}
}
} catch (error) {}
} catch (error) {
console.error("检查接龙状态失败:", error);
}
}
try {
@ -959,21 +994,27 @@ const publishNotice = async () => {
// JlDto
const jlDto = buildJlDto("A"); // A
//
console.log("=== 发布接龙调试信息 ===");
console.log("jlDto:", jlDto);
console.log("==========================");
//
const response = await Promise.race([
jlSaveApi(jlDto) as Promise<ApiResponse<any>>,
jlSaveApi(jlDto),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("请求超时")), 300000) // 5
)
]) as ApiResponse<any>;
]);
uni.hideLoading();
if (response && response.resultCode === 1) {
// ID使ID使ID
const jlId = response.result || (response as any).data || jlDto.id;
const jlId = response.result || response.data || jlDto.id;
if (!jlId) {
console.error("发布成功但未获取到接龙ID");
uni.showToast({
title: "发布成功但获取接龙ID失败",
icon: "error"
@ -1001,7 +1042,7 @@ const publishNotice = async () => {
}, 2000);
} else {
//
const errorMsg = (response as any)?.resultMsg || (response as any)?.message || "发布失败";
const errorMsg = response?.resultMsg || response?.message || "发布失败";
uni.showToast({
title: errorMsg,
icon: "none",
@ -1010,6 +1051,9 @@ const publishNotice = async () => {
}
} catch (error) {
uni.hideLoading();
console.error("发布接龙失败:", error);
//
if (error instanceof Error) {
if (error.message === "请求超时") {
uni.showToast({
@ -1025,7 +1069,7 @@ const publishNotice = async () => {
}
} else if (error && typeof error === 'object' && 'errMsg' in error) {
// uni-app
if ((error as any).errMsg && (error as any).errMsg.includes('timeout')) {
if (error.errMsg && error.errMsg.includes('timeout')) {
uni.showToast({
title: "请求超时,请检查网络连接或稍后重试",
icon: "none",
@ -1033,7 +1077,7 @@ const publishNotice = async () => {
});
} else {
uni.showToast({
title: (error as any).errMsg || "发布失败,请重试",
title: error.errMsg || "发布失败,请重试",
icon: "error"
});
}

View File

@ -0,0 +1,630 @@
<template>
<view class="confirm-page">
<!-- 确认签到阶段 -->
<view v-if="currentStep === 'confirm'" class="confirm-step">
<view class="confirm-header">
<text class="confirm-title">确认签到</text>
</view>
<view class="meeting-info">
<view class="info-item">
<text class="info-label">会议名称</text>
<text class="info-value">{{ meetingInfo?.qdmc || '未设置' }}</text>
</view>
<view class="info-item">
<text class="info-label">会议时间</text>
<text class="info-value">{{ formatTime(meetingInfo?.qdkstime) }} - {{ formatTime(meetingInfo?.qdjstime) }}</text>
</view>
<view class="info-item">
<text class="info-label">会议地点</text>
<text class="info-value">{{ meetingInfo?.qdwz || '未设置' }}</text>
</view>
<view class="info-item">
<text class="info-label">签到人员</text>
<text class="info-value">{{ userInfo?.jsxm || '未设置' }}</text>
</view>
</view>
<view v-if="sign_file" class="signature-preview">
<text class="preview-label">签名预览</text>
<image :src="sign_file" class="signature-preview-img" />
</view>
<button @click="submitSignIn" class="submit-btn">确认签到</button>
</view>
<!-- 签到成功阶段 -->
<view v-else-if="currentStep === 'success'" class="success-step">
<view class="success-content">
<u-icon name="checkmark-circle" size="80" color="#67C23A" />
<text class="success-title">签到成功</text>
<text class="success-time">{{ formatTime(new Date()) }}</text>
<text class="success-tip">您已成功完成签到</text>
</view>
</view>
<!-- 不在签到名单阶段 -->
<view v-else-if="currentStep === 'notInList'" class="not-in-list-step">
<view class="not-in-list-content">
<u-icon name="close-circle" size="80" color="#F56C6C" />
<text class="not-in-list-title">您不在签到名单中</text>
<text class="not-in-list-subtitle">抱歉您没有在此次会议的签到名单中</text>
<text class="not-in-list-tip">请联系会议组织者确认您的参会资格</text>
<view class="meeting-info-card">
<text class="card-title">会议信息</text>
<view class="info-item">
<text class="info-label">会议名称</text>
<text class="info-value">{{ meetingInfo?.qdmc || '未设置' }}</text>
</view>
<view class="info-item">
<text class="info-label">会议时间</text>
<text class="info-value">{{ formatTime(meetingInfo?.qdkstime) }} - {{ formatTime(meetingInfo?.qdjstime) }}</text>
</view>
<view class="info-item">
<text class="info-label">会议地点</text>
<text class="info-value">{{ meetingInfo?.qdwz || '未设置' }}</text>
</view>
</view>
<button @click="goBack" class="back-btn">返回</button>
</view>
</view>
<!-- 签到时间已结束阶段 -->
<view v-else-if="currentStep === 'timeExpired'" class="time-expired-step">
<view class="time-expired-content">
<u-icon name="clock" size="80" color="#E6A23C" />
<text class="time-expired-title">签到时间已结束</text>
<text class="time-expired-subtitle">抱歉本次签到的时间已经结束</text>
<text class="time-expired-tip">请关注下次签到通知</text>
<view class="meeting-info-card">
<text class="card-title">会议信息</text>
<view class="info-item">
<text class="info-label">会议名称</text>
<text class="info-value">{{ meetingInfo?.qdmc || '未设置' }}</text>
</view>
<view class="info-item">
<text class="info-label">会议时间</text>
<text class="info-value">{{ formatTime(meetingInfo?.qdkstime) }} - {{ formatTime(meetingInfo?.qdjstime) }}</text>
</view>
<view class="info-item">
<text class="info-label">会议地点</text>
<text class="info-value">{{ meetingInfo?.qdwz || '未设置' }}</text>
</view>
<view class="info-item">
<text class="info-label">当前时间</text>
<text class="info-value">{{ formatTime(new Date()) }}</text>
</view>
</view>
<button @click="goBack" class="back-btn">返回</button>
</view>
</view>
<!-- 二维码过期阶段 -->
<view v-else-if="currentStep === 'qrExpired'" class="qr-expired-step">
<view class="qr-expired-content">
<u-icon name="close-circle" size="80" color="#F56C6C" />
<text class="qr-expired-title">二维码已过期</text>
<text class="qr-expired-subtitle">抱歉您扫描的二维码已经过期</text>
<text class="qr-expired-tip">请重新扫描最新的二维码</text>
<view class="qr-info-card">
<text class="card-title">二维码信息</text>
<view class="info-item">
<text class="info-label">二维码有效期</text>
<text class="info-value">60</text>
</view>
<view class="info-item">
<text class="info-label">当前时间</text>
<text class="info-value">{{ formatTime(new Date()) }}</text>
</view>
</view>
<button @click="goBack" class="back-btn">返回</button>
</view>
</view>
</view>
<!-- 签名组件 -->
<BasicSign v-if="showSignature" ref="signCompRef" :title="signTitle"></BasicSign>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { qdFindByIdApi, qdzxSignInApi, qdzxFindByQdParamsApi, qdzxFindByQdAndJsApi } from '@/api/base/server';
import { useUserStore } from '@/store/modules/user';
//
const qdId = ref('');
const qdzxId = ref('');
//
const currentStep = ref<'sign' | 'confirm' | 'success' | 'notInList' | 'timeExpired' | 'qrExpired'>('confirm');
//
const userInfo = ref<any>(null);
//
const meetingInfo = ref<any>(null);
//
const qdzxRecord = ref<any>(null);
//
const signCompRef = ref<any>(null);
const signTitle = ref<string>("签到签名");
const sign_file = ref<string>("");
const showSignature = ref<boolean>(false);
// store
const userStore = useUserStore();
onLoad(async (options) => {
if (options?.qdId) {
qdId.value = options.qdId;
}
if (options?.qdzxId) {
qdzxId.value = options.qdzxId;
}
//
const jsData = userStore.getJs;
if (jsData && Object.keys(jsData).length > 0) {
userInfo.value = jsData;
console.log('获取到教师信息:', jsData);
} else {
console.error('未找到教师信息,请先登录');
uni.showToast({ title: '请先登录', icon: 'none' });
//
setTimeout(() => {
uni.navigateTo({
url: '/pages/system/login/login'
});
}, 1500);
return;
}
//
if (options?.timestamp) {
const qrTimestamp = parseInt(options.timestamp);
const currentTimestamp = Date.now();
const timeDiff = currentTimestamp - qrTimestamp;
const maxValidTime = 60 * 1000; // 60
console.log('二维码时间戳验证:', {
qrTimestamp,
currentTimestamp,
timeDiff,
maxValidTime
});
if (timeDiff > maxValidTime) {
console.error('二维码已过期');
currentStep.value = 'qrExpired';
return;
}
}
// qdIdID
await checkTeacherSignInPermission();
});
//
const checkTeacherSignInPermission = async () => {
try {
const result = await qdzxFindByQdAndJsApi({
qdId: qdId.value,
jsId: userInfo.value.id
});
if (result && result.resultCode === 1 && result.result) {
//
qdzxRecord.value = result.result;
qdzxId.value = result.result.id;
console.log('找到教师签到记录:', result.result);
//
await loadMeetingInfo();
} else {
// ""
currentStep.value = 'notInList';
await loadMeetingInfo(); //
}
} catch (error) {
console.error('查询教师签到权限失败:', error);
uni.showToast({ title: '查询签到权限失败', icon: 'none' });
}
};
//
const loadMeetingInfo = async () => {
try {
const result = await qdFindByIdApi({ id: qdId.value });
if (result && result.resultCode === 1) {
meetingInfo.value = result.result;
// 1.
const currentTime = new Date();
const endTime = meetingInfo.value?.qdjstime ? new Date(meetingInfo.value.qdjstime) : null;
if (endTime && currentTime > endTime) {
// ""
currentStep.value = 'timeExpired';
return;
}
// qdzxId
if (qdzxId.value) {
//
if (meetingInfo.value?.mdqz === '1') {
//
await handleSignature();
} else {
//
currentStep.value = 'confirm';
}
}
}
} catch (error) {
console.error('加载会议信息失败:', error);
uni.showToast({ title: '加载会议信息失败', icon: 'none' });
}
};
//
const handleSignature = async () => {
try {
showSignature.value = true;
sign_file.value = '';
signTitle.value = "签到签名";
//
await nextTick();
const data = await signCompRef.value.getSyncSignature();
sign_file.value = data.base64;
showSignature.value = false;
//
currentStep.value = 'confirm';
} catch (error) {
showSignature.value = false;
console.error('签名失败:', error);
uni.showToast({ title: '签名失败', icon: 'none' });
}
};
//
const submitSignIn = async () => {
try {
const params: any = {
qdzxId: qdzxId.value,
signInTime: new Date().toISOString() // ISO
};
//
if (sign_file.value) {
params.signatureImage = sign_file.value;
}
console.log('提交签到参数:', JSON.stringify(params, null, 2));
console.log('参数类型检查:', {
qdzxId: typeof params.qdzxId,
signInTime: typeof params.signInTime,
signatureImage: typeof params.signatureImage
});
const result = await qdzxSignInApi(params);
console.log('签到结果:', result);
if (result && result.resultCode === 1) {
currentStep.value = 'success';
} else {
const errorMsg = result?.msg || result?.message || '签到失败';
uni.showToast({ title: errorMsg, icon: 'none' });
}
} catch (error) {
console.error('签到失败:', error);
uni.showToast({ title: '网络异常,请重试', icon: 'none' });
}
};
//
const formatTime = (time: string | Date) => {
if (!time) return '未设置';
const date = new Date(time);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
//
const goBack = () => {
uni.navigateBack();
};
</script>
<style lang="scss" scoped>
.confirm-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
box-sizing: border-box;
}
//
.confirm-step {
background: white;
border-radius: 16px;
padding: 24px;
margin-top: 40px;
}
.confirm-header {
text-align: center;
margin-bottom: 24px;
.confirm-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
}
.meeting-info {
margin-bottom: 24px;
.info-item {
display: flex;
margin-bottom: 12px;
.info-label {
font-size: 14px;
color: #666;
min-width: 80px;
}
.info-value {
font-size: 14px;
color: #333;
flex: 1;
}
}
}
.signature-preview {
margin-bottom: 24px;
.preview-label {
display: block;
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.signature-preview-img {
width: 100%;
height: 100px;
border: 1px solid #ddd;
border-radius: 8px;
}
}
.submit-btn {
width: 100%;
height: 44px;
background: linear-gradient(135deg, #67C23A 0%, #85ce61 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
}
//
.success-step {
background: white;
border-radius: 16px;
padding: 40px 24px;
margin-top: 40px;
text-align: center;
}
.success-content {
.success-title {
display: block;
font-size: 24px;
font-weight: 600;
color: #333;
margin: 16px 0 8px;
}
.success-time {
display: block;
font-size: 16px;
color: #666;
margin-bottom: 16px;
}
.success-tip {
font-size: 14px;
color: #999;
}
}
//
.not-in-list-step {
background: white;
border-radius: 16px;
padding: 24px;
margin-top: 40px;
text-align: center;
}
.not-in-list-content {
.not-in-list-title {
display: block;
font-size: 24px;
font-weight: 600;
color: #333;
margin: 16px 0 8px;
}
.not-in-list-subtitle {
font-size: 14px;
color: #666;
margin-bottom: 16px;
}
.not-in-list-tip {
font-size: 14px;
color: #999;
margin-bottom: 24px;
}
}
.meeting-info-card {
background: #f9f9f9;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
.card-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.info-item {
display: flex;
margin-bottom: 8px;
.info-label {
font-size: 14px;
color: #666;
min-width: 80px;
}
.info-value {
font-size: 14px;
color: #333;
flex: 1;
}
}
}
.back-btn {
width: 100%;
height: 44px;
background: #f5f5f5;
color: #666;
border: none;
border-radius: 8px;
font-size: 16px;
}
/* 签到时间已结束阶段 */
.time-expired-step {
background: white;
border-radius: 16px;
padding: 40px 24px;
margin-top: 40px;
text-align: center;
}
.time-expired-content {
.time-expired-title {
display: block;
font-size: 24px;
font-weight: 600;
color: #333;
margin: 16px 0 8px;
}
.time-expired-subtitle {
font-size: 14px;
color: #666;
margin-bottom: 16px;
}
.time-expired-tip {
font-size: 14px;
color: #999;
margin-bottom: 24px;
}
}
/* 二维码过期阶段 */
.qr-expired-step {
background: white;
border-radius: 16px;
padding: 40px 24px;
margin-top: 40px;
text-align: center;
}
.qr-expired-content {
.qr-expired-title {
display: block;
font-size: 24px;
font-weight: 600;
color: #333;
margin: 16px 0 8px;
}
.qr-expired-subtitle {
font-size: 14px;
color: #666;
margin-bottom: 16px;
}
.qr-expired-tip {
font-size: 14px;
color: #999;
margin-bottom: 24px;
}
}
.qr-info-card {
background: #f9f9f9;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
.card-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.info-item {
display: flex;
margin-bottom: 8px;
.info-label {
font-size: 14px;
color: #666;
min-width: 80px;
}
.info-value {
font-size: 14px;
color: #333;
flex: 1;
}
}
}
</style>

View File

@ -0,0 +1,438 @@
<template>
<BasicLayout>
<scroll-view scroll-y class="detail-scroll-view">
<view class="detail-container">
<!-- 签到基本信息 -->
<view class="info-card">
<view class="card-header">
<text class="qd-title">{{ qdInfo.qdmc }}</text>
<text class="qd-status" :class="getStatusClass(qdInfo.qdStatus)">
{{ getStatusText(qdInfo.qdStatus) }}
</text>
</view>
<view class="info-list">
<view class="info-item">
<text class="info-label">签到地点</text>
<text class="info-value">{{ qdInfo.qdwz || '未设置' }}</text>
</view>
<view class="info-item">
<text class="info-label">开始时间</text>
<text class="info-value">{{ formatTime(qdInfo.qdkstime) }}</text>
</view>
<view class="info-item">
<text class="info-label">结束时间</text>
<text class="info-value">{{ formatTime(qdInfo.qdjstime) }}</text>
</view>
<view class="info-item">
<text class="info-label">发布者</text>
<text class="info-value">{{ qdInfo.jsxm || '未知' }}</text>
</view>
<view class="info-item">
<text class="info-label">发布时间</text>
<text class="info-value">{{ formatTime(qdInfo.qdFbtime) }}</text>
</view>
</view>
</view>
<!-- 签到人员统计 -->
<view class="stats-card">
<view class="stats-header">
<text class="stats-title">签到统计</text>
</view>
<view class="stats-content">
<view class="stat-item">
<text class="stat-number">{{ totalCount }}</text>
<text class="stat-label">总人数</text>
</view>
<view class="stat-item">
<text class="stat-number signed">{{ signedCount }}</text>
<text class="stat-label">已签到</text>
</view>
<view class="stat-item">
<text class="stat-number unsigned">{{ unsignedCount }}</text>
<text class="stat-label">未签到</text>
</view>
</view>
</view>
<!-- 签到人员列表 -->
<view class="teacher-list-card">
<view class="list-header">
<text class="list-title">签到人员</text>
</view>
<view class="teacher-list">
<view
v-for="teacher in teacherList"
:key="teacher.id"
class="teacher-item"
>
<view class="teacher-info">
<text class="teacher-name">{{ teacher.jsxm }}</text>
<text class="teacher-position">{{ teacher.dzzw || '' }} {{ teacher.qtzw || '' }}</text>
</view>
<view class="teacher-status">
<text class="status-text" :class="getStatusClass(teacher.qdStatus)">
{{ getStatusText(teacher.qdStatus) }}
</text>
<text v-if="teacher.qdwctime" class="sign-time">
{{ formatTime(teacher.qdwctime) }}
</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部操作 -->
<template #bottom>
<view class="bottom-actions">
<button class="action-btn back-btn" @click="handleBack">
返回列表
</button>
</view>
</template>
</BasicLayout>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { qdFindByIdApi, qdzxFindByQdParamsApi } from "@/api/base/server";
interface QdInfo {
id: string;
qdmc: string;
qdwz: string;
qdStatus: string;
jsId: string;
jsxm: string;
qdFbtime: string;
qdkstime: string;
qdjstime: string;
qdry: string;
}
interface TeacherInfo {
id: string;
jsId: string;
jsxm: string;
dzzw: string;
qtzw: string;
qdStatus: string;
qdwctime: string;
}
const qdId = ref<string>('');
const qdInfo = ref<QdInfo>({} as QdInfo);
const teacherList = ref<TeacherInfo[]>([]);
const totalCount = computed(() => teacherList.value.length);
const signedCount = computed(() => teacherList.value.filter(t => t.qdStatus === '1').length);
const unsignedCount = computed(() => totalCount.value - signedCount.value);
onLoad((options) => {
if (options && options.id) {
qdId.value = options.id;
loadQdDetail();
loadTeacherList();
} else {
uni.showToast({ title: "缺少签到ID参数", icon: "error" });
setTimeout(() => {
uni.navigateBack();
}, 1500);
}
});
const loadQdDetail = async () => {
try {
const result = await qdFindByIdApi({ id: qdId.value });
if (result.resultCode === 1 && result.result) {
qdInfo.value = result.result;
}
} catch (error) {
console.error('加载签到详情失败:', error);
}
};
const loadTeacherList = async () => {
try {
const result = await qdzxFindByQdParamsApi({ qdId: qdId.value });
if (result.resultCode === 1 && result.result) {
teacherList.value = result.result;
}
} catch (error) {
console.error('加载教师列表失败:', error);
}
};
const getStatusClass = (status: string) => {
switch (status) {
case 'A':
return 'status-pending';
case 'B':
return 'status-draft';
case 'C':
return 'status-pushed';
case '1':
return 'status-signed';
case '0':
default:
return 'status-unsigned';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'A':
return '待推送';
case 'B':
return '暂存';
case 'C':
return '已推送';
case '1':
return '已签到';
case '0':
default:
return '未签到';
}
};
const formatTime = (time: string) => {
if (!time) return '未设置';
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const handleBack = () => {
uni.navigateBack();
};
</script>
<style lang="scss" scoped>
.detail-scroll-view {
height: calc(100vh - 120px);
}
.detail-container {
padding: 15px;
}
.info-card {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.qd-title {
font-size: 20px;
font-weight: 600;
color: #333;
flex: 1;
}
.qd-status {
padding: 6px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
}
.status-pending {
background: #fff3cd;
color: #856404;
}
.status-draft {
background: #d1ecf1;
color: #0c5460;
}
.status-pushed {
background: #d4edda;
color: #155724;
}
.status-signed {
background: #d4edda;
color: #155724;
}
.status-unsigned {
background: #f8d7da;
color: #721c24;
}
.info-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item {
display: flex;
align-items: center;
}
.info-label {
font-size: 14px;
color: #666;
min-width: 80px;
font-weight: 500;
}
.info-value {
font-size: 14px;
color: #333;
flex: 1;
}
.stats-card {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stats-header {
margin-bottom: 20px;
}
.stats-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.stats-content {
display: flex;
justify-content: space-around;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.stat-number {
font-size: 24px;
font-weight: 700;
color: #333;
&.signed {
color: #28a745;
}
&.unsigned {
color: #dc3545;
}
}
.stat-label {
font-size: 14px;
color: #666;
}
.teacher-list-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.list-header {
margin-bottom: 15px;
}
.list-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.teacher-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.teacher-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.teacher-info {
flex: 1;
}
.teacher-name {
display: block;
font-size: 16px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.teacher-position {
font-size: 14px;
color: #666;
}
.teacher-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.status-text {
font-size: 14px;
font-weight: 500;
padding: 2px 8px;
border-radius: 8px;
}
.sign-time {
font-size: 12px;
color: #999;
}
.bottom-actions {
padding: 15px;
background: white;
border-top: 1px solid #f0f0f0;
}
.back-btn {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
}
</style>

View File

@ -0,0 +1,558 @@
<!-- src/pages/view/routine/qd/index.vue -->
<template>
<view class="qd-list-page">
<!-- 查询组件 -->
<view class="query-component">
<view class="search-card">
<view class="search-item">
<picker
mode="date"
:value="searchForm.startTime"
@change="onStartTimeChange"
class="date-picker"
>
<view class="picker-text">{{ searchForm.startTime || '开始时间' }}</view>
</picker>
<text class="date-separator">~</text>
<picker
mode="date"
:value="searchForm.endTime"
@change="onEndTimeChange"
class="date-picker"
>
<view class="picker-text">{{ searchForm.endTime || '结束时间' }}</view>
</picker>
</view>
<view class="search-actions">
<u-button
text="查询"
type="primary"
size="small"
@click="handleSearch"
class="search-btn"
/>
<u-button
text="重置"
type="info"
size="small"
@click="handleReset"
class="reset-btn"
/>
</view>
</view>
</view>
<!-- 列表组件 -->
<view class="list-component">
<scroll-view scroll-y class="list-scroll-view">
<view v-if="isLoading" class="loading-indicator">加载中...</view>
<template v-else-if="dataList.length > 0">
<view v-for="data in dataList" :key="data.id" class="qd-card">
<view class="card-header">
<text class="qd-title">{{ data.qdmc }}</text>
<text class="qd-status" :class="getStatusClass(data.qdStatus)">
{{ getStatusText(data.qdStatus) }}
</text>
</view>
<view class="card-body">
<view class="qd-info">
<view class="info-item">
<text class="info-label">签到地点</text>
<text class="info-value">{{ data.qdwz || '未设置' }}</text>
</view>
<view class="info-item">
<text class="info-label">开始时间</text>
<text class="info-value">{{ formatTime(data.qdkstime) }}</text>
</view>
<view class="info-item">
<text class="info-label">结束时间</text>
<text class="info-value">{{ formatTime(data.qdjstime) }}</text>
</view>
<view class="info-item">
<text class="info-label">发布者</text>
<text class="info-value">{{ data.jsxm || '未知' }}</text>
</view>
<view class="info-item">
<text class="info-label">发布时间</text>
<text class="info-value">{{ formatTime(data.qdFbtime) }}</text>
</view>
</view>
</view>
<view class="card-footer">
<view class="footer-actions">
<image
src="/static/base/qr-code.png"
class="footer-action-icon qr-icon"
@click="goToQRCode(data.id)"
/>
<image
src="/static/base/details.png"
class="footer-action-icon details-icon"
@click="goToFeedback(data.id)"
/>
<image
v-if="data.qdStatus === 'A'"
src="/static/base/push.png"
class="footer-action-icon push-icon"
@click="goToPush(data.id)"
/>
</view>
</view>
</view>
</template>
<view v-else class="empty-state">暂无签到数据</view>
</scroll-view>
</view>
<!-- 新增按钮 - 固定在底部 -->
<view class="add-button-fixed">
<u-button
text="新增签到"
type="primary"
@click="goToPublish"
class="add-btn"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import { qdFindPageApi } from "@/api/base/server";
import { ref, reactive, onMounted } from "vue";
import { onShow } from "@dcloudio/uni-app";
interface QdItem {
id: string;
qdmc: string; //
qdwz: string; //
qdStatus: string; // ABC
jsId: string; // ID
jsxm: string; //
qdFbtime: string; //
qdkstime: string; //
qdjstime: string; //
qdry: string; //
}
//
const searchForm = reactive({
startTime: '',
endTime: ''
});
//
const dataList = ref<QdItem[]>([]);
const isLoading = ref(false);
//
const onStartTimeChange = (e: any) => {
searchForm.startTime = e.detail.value;
};
//
const onEndTimeChange = (e: any) => {
searchForm.endTime = e.detail.value;
};
//
const handleSearch = () => {
getQdList();
};
//
const handleReset = () => {
searchForm.startTime = '';
searchForm.endTime = '';
getQdList();
};
//
const getQdList = async () => {
isLoading.value = true;
try {
const res = await qdFindPageApi({
qdkstime: searchForm.startTime,
qdjstime: searchForm.endTime,
page: 1,
rows: 100
});
dataList.value = res.rows || [];
} catch (error) {
console.error('获取签到列表失败:', error);
dataList.value = [];
} finally {
isLoading.value = false;
}
};
//
const getStatusClass = (status: string) => {
switch (status) {
case 'A':
return 'status-pending';
case 'B':
return 'status-draft';
case 'C':
return 'status-pushed';
default:
return 'status-default';
}
};
//
const getStatusText = (status: string) => {
switch (status) {
case 'A':
return '待推送';
case 'B':
return '暂存';
case 'C':
return '已推送';
default:
return '未知';
}
};
//
const formatTime = (time: string) => {
if (!time) return '未设置';
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
//
const goToFeedback = (id: string) => {
uni.navigateTo({
url: `/pages/view/routine/qd/detail?id=${id}`
});
};
//
const goToPush = (id: string) => {
uni.navigateTo({
url: `/pages/view/routine/qd/push-list?qdId=${id}`
});
};
//
const goToPublish = () => {
uni.navigateTo({
url: '/pages/view/routine/qd/publish'
});
};
//
const goToQRCode = (id: string) => {
uni.navigateTo({
url: `/pages/view/routine/qd/qr-code?qdId=${id}`
});
};
//
onShow(() => {
getQdList();
});
//
onMounted(() => {
getQdList();
});
</script>
<style lang="scss" scoped>
.qd-list-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f7fa;
}
//
.query-component {
padding: 12px;
background-color: #fff;
border-bottom: 1px solid #eee;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
flex-shrink: 0;
}
.search-card {
background-color: #ffffff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border: 1px solid #f0f0f0;
}
.search-item {
display: flex;
align-items: center;
margin-bottom: 12px;
gap: 8px;
.date-picker {
flex: 1;
min-width: 120px;
}
.picker-text {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 8px 12px;
font-size: 14px;
color: #495057;
text-align: center;
}
.date-separator {
font-size: 16px;
color: #6c757d;
margin: 0 8px;
font-weight: bold;
}
}
.search-actions {
display: flex;
gap: 12px;
justify-content: center;
.search-btn, .reset-btn {
flex: 1;
max-width: 120px;
}
}
//
.list-component {
flex: 1;
overflow-y: auto;
padding: 12px;
padding-bottom: 80px; //
box-sizing: border-box;
background-color: #f5f7fa;
}
.list-scroll-view {
height: 100%;
}
.loading-indicator,
.empty-state {
text-align: center;
color: #999;
padding: 30px 15px;
font-size: 14px;
}
.qd-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, #007aff 0%, #0056cc 100%);
border-radius: 2px;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
gap: 12px;
.qd-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;
}
.qd-status {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
}
.status-pending {
background: #fff3cd;
color: #856404;
}
.status-draft {
background: #d1ecf1;
color: #0c5460;
}
.status-pushed {
background: #d4edda;
color: #155724;
}
.status-default {
background: #f8f9fa;
color: #6c757d;
}
.card-body {
margin-bottom: 12px;
.qd-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.info-item {
display: flex;
align-items: center;
.info-label {
font-size: 14px;
color: #666;
min-width: 80px;
}
.info-value {
font-size: 14px;
color: #333;
flex: 1;
}
}
}
.card-footer {
display: flex;
justify-content: flex-end;
align-items: center;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
.footer-actions {
display: flex;
gap: 10px;
.footer-action-icon {
padding: 5px;
}
.qr-icon {
width: 22px;
height: 22px;
cursor: pointer;
}
.details-icon {
width: 22px;
height: 22px;
cursor: pointer;
}
.push-icon {
width: 22px;
height: 22px;
cursor: pointer;
}
}
}
// -
.add-button-fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 15px;
background-color: #fff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
z-index: 10;
border-top: 1px solid #eee;
.add-btn {
width: 100%;
background: linear-gradient(135deg, #007aff 0%, #0056cc 100%);
border-radius: 12px;
padding: 12px 24px;
box-shadow: 0 4px 16px rgba(0, 122, 255, 0.3);
font-weight: 600;
font-size: 16px;
&:active {
transform: translateY(1px);
}
}
}
//
@media (max-width: 375px) {
.query-component {
padding: 8px;
}
.qd-card {
padding: 12px;
margin-bottom: 8px;
}
.card-header .qd-title {
font-size: 15px;
}
.card-footer .footer-item {
max-width: 150px;
font-size: 12px;
}
}
//
.qd-card {
animation: fadeInUp 0.3s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -0,0 +1,515 @@
<template>
<BasicLayout>
<scroll-view scroll-y class="form-scroll-view">
<view class="form-container">
<!-- 签到名称 -->
<view class="info-card">
<view class="form-item">
<uni-easyinput
type="textarea"
autoHeight
v-model="formData.qdmc"
placeholder="请输入签到名称 (必填)"
:inputBorder="false"
placeholder-style="font-weight:bold; font-size: 18px; color: #999;"
class="title-input"
></uni-easyinput>
</view>
</view>
<!-- 签到地点 -->
<view class="info-card">
<view class="form-item">
<uni-easyinput
type="textarea"
autoHeight
v-model="formData.qdwz"
placeholder="请输入签到地点 (必填)"
:inputBorder="false"
class="content-input"
></uni-easyinput>
</view>
</view>
<!-- 签到人员 -->
<view class="info-card">
<view class="card-header picker-header" @click="showTeacherTree">
<text class="section-title">签到人员</text>
<view class="target-class">
<text :class="{ placeholder: !formData.targetTeachers.length }">
{{ formData.targetTeachers.length ? `已选择${formData.targetTeachers.length}` : "请选择教师" }}
</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
<view class="name-tags">
<text
v-for="(teacher, index) in displayTeachers"
:key="teacher.id + index"
class="name-tag"
>{{ teacher.jsxm }}</text>
</view>
<view
v-if="formData.targetTeachers.length > maxDisplayCount"
class="more-btn-container"
>
<button size="mini" class="more-btn-full" @click="showAllTeachers">
更多({{ formData.targetTeachers.length - maxDisplayCount }})
</button>
</view>
</view>
<!-- 是否启用签名 -->
<view class="info-card list-item-card">
<picker
mode="selector"
:range="signatureOptions"
@change="handleSignatureChange"
>
<view class="list-item-row">
<text class="list-label">按名单签字</text>
<view class="list-value">
<text>{{ signatureStatusText }}</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</picker>
</view>
<!-- 签到开始时间 -->
<view class="info-card list-item-card">
<uni-datetime-picker type="datetime" v-model="formData.qdkstime">
<view class="list-item-row">
<text class="list-label">开始时间</text>
<view class="list-value">
<text :class="{ placeholder: !formData.qdkstime }">
{{ formData.qdkstime || '请选择开始时间' }}
</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</uni-datetime-picker>
</view>
<!-- 签到结束时间 -->
<view class="info-card list-item-card">
<uni-datetime-picker type="datetime" v-model="formData.qdjstime">
<view class="list-item-row">
<text class="list-label">结束时间</text>
<view class="list-value">
<text :class="{ placeholder: !formData.qdjstime }">
{{ formData.qdjstime || '请选择结束时间' }}
</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</uni-datetime-picker>
</view>
<!-- 发布人 -->
<view class="info-card list-item-card">
<view class="list-item-row">
<text class="list-label">发布人</text>
<view class="list-value">
<text>{{ formData.jsxm }}</text>
</view>
</view>
</view>
<!-- 发布时间 -->
<view class="info-card list-item-card">
<view class="list-item-row">
<text class="list-label">发布时间</text>
<view class="list-value">
<text>{{ formData.qdFbtime }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
<template #bottom>
<view class="bottom-actions">
<button class="action-btn cancel-btn" @click="handleCancel">
取消
</button>
<button class="action-btn confirm-btn" @click="handlePublish" :disabled="isPublishing">
{{ isPublishing ? '发布中...' : '立即发布' }}
</button>
</view>
</template>
</BasicLayout>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted, onUnmounted } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { qdSaveApi } from "@/api/base/server";
import { useUserStore } from "@/store/modules/user";
const { getJs } = useUserStore();
interface TeacherInfo {
id: string;
jsxm: string;
jsId: string;
}
const formData = reactive({
qdmc: '',
qdwz: '',
qdry: '',
mdqz: '0',
qdkstime: '',
qdjstime: '',
jsxm: '',
qdFbtime: '',
targetTeachers: [] as TeacherInfo[]
});
const isPublishing = ref(false);
const maxDisplayCount = 5;
const signatureOptions = ['不启用', '启用'];
const signatureStatusText = computed(() => {
return signatureOptions[parseInt(formData.mdqz)];
});
const displayTeachers = computed(() => {
return formData.targetTeachers.slice(0, maxDisplayCount);
});
//
const handleTeacherSelected = (teachers: TeacherInfo[]) => {
console.log('接收到教师选择事件:', teachers);
formData.targetTeachers = teachers;
};
//
onMounted(() => {
const js = getJs;
formData.jsxm = js.jsxm;
formData.qdFbtime = formatDateTime(new Date().toISOString());
//
uni.$on('teacherSelected', handleTeacherSelected);
});
//
onUnmounted(() => {
uni.$off('teacherSelected', handleTeacherSelected);
});
//
onShow(() => {
//
});
const handleSignatureChange = (e: any) => {
formData.mdqz = e.detail.value.toString();
};
const showTeacherTree = () => {
uni.navigateTo({
url: '/pages/view/routine/qd/selectTeachers'
});
};
const showAllTeachers = () => {
uni.showModal({
title: '签到人员',
content: formData.targetTeachers.map(t => t.jsxm).join('、'),
showCancel: false
});
};
const handleCancel = () => {
uni.navigateBack();
};
//
const formatDateTime = (dateTime: string) => {
if (!dateTime) return '';
const date = new Date(dateTime);
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 handlePublish = async () => {
const requestStartTime = new Date().toISOString();
console.log('=== handlePublish 开始 ===', requestStartTime);
console.log('当前表单数据:', formData);
console.log('选中的教师数量:', formData.targetTeachers.length);
console.log('选中的教师列表:', formData.targetTeachers);
// -
if (isPublishing.value) {
console.log('正在发布中,忽略重复点击', requestStartTime);
return;
}
if (!formData.qdmc.trim()) {
uni.showToast({ title: '请输入签到名称', icon: 'none' });
return;
}
if (!formData.qdwz.trim()) {
uni.showToast({ title: '请输入签到地点', icon: 'none' });
return;
}
if (!formData.qdkstime) {
uni.showToast({ title: '请选择开始时间', icon: 'none' });
return;
}
if (!formData.qdjstime) {
uni.showToast({ title: '请选择结束时间', icon: 'none' });
return;
}
if (formData.targetTeachers.length === 0) {
uni.showToast({ title: '请选择签到人员', icon: 'none' });
return;
}
//
isPublishing.value = true;
console.log('设置发布状态为true', requestStartTime);
try {
const js = getJs;
// ID
const teacherIds = formData.targetTeachers.map(t => t.jsId);
const uniqueIds = [...new Set(teacherIds)];
console.log('教师ID列表:', teacherIds);
console.log('去重后的教师ID列表:', uniqueIds);
console.log('是否有重复ID:', teacherIds.length !== uniqueIds.length);
if (teacherIds.length !== uniqueIds.length) {
console.warn('发现重复的教师ID将进行去重处理');
}
const params = {
qdmc: formData.qdmc,
qdwz: formData.qdwz,
qdry: uniqueIds.join(','), // 使ID
mdqz: formData.mdqz,
qdkstime: formatDateTime(formData.qdkstime),
qdjstime: formatDateTime(formData.qdjstime),
jsId: js.id,
jsxm: formData.jsxm,
qdFbtime: formData.qdFbtime,
qdStatus: 'A',
status: 'A'
};
console.log('准备发送的参数:', params);
console.log('qdry字段长度:', params.qdry.length);
console.log('qdry字段内容:', params.qdry);
console.log('开始调用API', requestStartTime);
const result = await qdSaveApi(params);
console.log('API返回结果:', result);
console.log('API调用完成', new Date().toISOString());
if (result.resultCode === 1) {
uni.showToast({ title: '发布成功', icon: 'success' });
//
setTimeout(() => {
uni.redirectTo({
url: `/pages/view/routine/qd/push-list?qdId=${result.result}`
});
}, 1500);
} else {
uni.showToast({ title: '发布失败', icon: 'none' });
}
} catch (error) {
console.error('发布失败:', error);
console.error('发布失败时间:', new Date().toISOString());
uni.showToast({ title: '发布失败,请重试', icon: 'none' });
} finally {
isPublishing.value = false;
console.log('=== handlePublish 结束 ===', new Date().toISOString());
}
};
</script>
<style lang="scss" scoped>
.form-scroll-view {
height: calc(100vh - 120px);
}
.form-container {
padding: 15px;
}
.info-card {
background: white;
border-radius: 12px;
margin-bottom: 15px;
padding: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.form-item {
margin-bottom: 15px;
}
.title-input {
font-size: 18px;
font-weight: bold;
color: #333;
:deep(.uni-easyinput__content) {
background: transparent;
}
:deep(.uni-easyinput__content-input) {
color: #333;
}
:deep(.uni-easyinput__placeholder-class) {
color: #999;
}
}
.content-input {
font-size: 16px;
color: #333;
:deep(.uni-easyinput__content) {
background: transparent;
}
:deep(.uni-easyinput__content-input) {
color: #333;
}
:deep(.uni-easyinput__placeholder-class) {
color: #999;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.target-class {
display: flex;
align-items: center;
gap: 8px;
color: #666;
font-size: 14px;
}
.placeholder {
color: #999;
}
.name-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.name-tag {
background: #e3f2fd;
color: #1976d2;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
}
.more-btn-container {
text-align: center;
}
.more-btn-full {
background: #f5f5f5;
color: #666;
border: none;
border-radius: 16px;
font-size: 12px;
padding: 4px 12px;
}
.list-item-card {
padding: 0;
}
.list-item-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-bottom: 1px solid #f0f0f0;
}
.list-item-row:last-child {
border-bottom: none;
}
.list-label {
font-size: 16px;
color: #333;
font-weight: 500;
}
.list-value {
display: flex;
align-items: center;
gap: 8px;
color: #666;
font-size: 14px;
}
.bottom-actions {
display: flex;
gap: 15px;
padding: 15px;
background: white;
border-top: 1px solid #f0f0f0;
}
.action-btn {
flex: 1;
padding: 10px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
border: none;
}
.cancel-btn {
background: #f5f5f5;
color: #666;
}
.confirm-btn {
background: linear-gradient(135deg, #007aff 0%, #0056cc 100%);
color: white;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.3);
}
.confirm-btn:disabled {
opacity: 0.6;
}
</style>

View File

@ -0,0 +1,304 @@
<template>
<BasicLayout>
<scroll-view scroll-y class="push-list-scroll-view">
<view class="push-list-container">
<!-- 页面标题 -->
<view class="page-header">
<text class="page-title">推送清单</text>
<text class="teacher-count"> {{ teacherList.length }} 名教师</text>
</view>
<!-- 教师信息卡片列表 -->
<view class="teacher-cards">
<view
v-for="(teacher, index) in teacherList"
:key="teacher.id || teacher.jsId || index"
class="teacher-card"
>
<view class="teacher-main-info">
<text class="teacher-name">{{ teacher.jsxm || '未知姓名' }}</text>
<text class="teacher-position">{{ teacher.dzzw || '' }} {{ teacher.qtzw || '' }}</text>
</view>
<view class="teacher-status">
<text class="status-label">状态</text>
<text class="status-value" :class="getStatusClass(teacher.qdStatus)">
{{ getStatusText(teacher.qdStatus) }}
</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="teacherList.length === 0" class="empty-state">
<uni-icons type="info" size="60" color="#ccc"></uni-icons>
<text class="empty-text">暂无教师信息</text>
</view>
</view>
</scroll-view>
<!-- Bottom slot -->
<template #bottom>
<view class="bottom-actions">
<button class="action-btn cancel-btn" @click="handleCancel">
取消
</button>
<button class="action-btn confirm-btn" @click="handleConfirmPush" :disabled="isPushing">
{{ isPushing ? '推送中...' : '确认推送' }}
</button>
</view>
</template>
</BasicLayout>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { qdzxFindByQdParamsApi, xxtsSaveByQdzxParamsApi } from "@/api/base/server";
interface TeacherInfo {
id: string;
jsId: string;
jsxm: string;
dzzw: string;
qtzw: string;
qdStatus: string;
qdId: string;
qdmc: string;
}
const qdId = ref<string>('');
const teacherList = ref<TeacherInfo[]>([]);
const isPushing = ref(false);
onLoad((options) => {
if (options && options.qdId && options.qdId !== 'null' && options.qdId !== 'undefined') {
qdId.value = options.qdId;
loadTeacherList();
} else {
uni.showToast({ title: "缺少签到ID参数", icon: "error" });
setTimeout(() => {
uni.navigateBack();
}, 1500);
}
});
const loadTeacherList = async () => {
try {
uni.showLoading({ title: "加载教师信息中..." });
console.log('开始调用APIqdId:', qdId.value);
const result = await qdzxFindByQdParamsApi({ qdId: qdId.value });
uni.hideLoading();
console.log('API返回结果:', result);
console.log('result.rows:', result?.rows);
console.log('result.rows类型:', typeof result?.rows);
console.log('result.rows是否为数组:', Array.isArray(result?.rows));
console.log('result.rows长度:', result?.rows?.length);
// PageresultCode
if (result && result.rows && Array.isArray(result.rows)) {
teacherList.value = result.rows;
console.log('成功从rows字段获取到教师数据:', result.rows.length, '条');
console.log('教师数据详情:', result.rows);
} else {
console.warn('未找到有效的教师数据API返回结构:', result);
console.warn('result的所有字段:', Object.keys(result || {}));
teacherList.value = [];
uni.showToast({ title: "未找到教师数据", icon: "none" });
}
console.log('最终teacherList.value:', teacherList.value);
console.log('teacherList.value长度:', teacherList.value.length);
} catch (error) {
uni.hideLoading();
console.error('加载教师列表失败:', error);
uni.showToast({ title: "加载教师列表失败", icon: "none" });
}
};
const getStatusClass = (status: string) => {
switch (status) {
case '1':
return 'status-signed';
case '0':
default:
return 'status-unsigned';
}
};
const getStatusText = (status: string) => {
switch (status) {
case '1':
return '已推送';
case '0':
default:
return '未推送';
}
};
const handleCancel = () => {
uni.navigateBack();
};
const handleConfirmPush = async () => {
isPushing.value = true;
try {
const result = await xxtsSaveByQdzxParamsApi({ qdId: qdId.value });
if (result.resultCode === 1) {
uni.showToast({ title: '推送成功', icon: 'success' });
setTimeout(() => {
//
uni.redirectTo({
url: '/pages/view/routine/qd/index'
});
}, 1500);
} else {
uni.showToast({ title: '推送失败', icon: 'none' });
}
} catch (error) {
console.error('推送失败:', error);
uni.showToast({ title: '推送失败,请重试', icon: 'none' });
} finally {
isPushing.value = false;
}
};
</script>
<style lang="scss" scoped>
.push-list-scroll-view {
height: calc(100vh - 120px);
}
.push-list-container {
padding: 15px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: white;
}
.page-title {
font-size: 18px;
font-weight: 600;
}
.teacher-count {
font-size: 14px;
opacity: 0.9;
}
.teacher-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.teacher-card {
background: white;
border-radius: 12px;
padding: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.teacher-main-info {
margin-bottom: 10px;
}
.teacher-name {
display: block;
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.teacher-position {
font-size: 14px;
color: #666;
}
.teacher-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-label {
font-size: 14px;
color: #666;
}
.status-value {
font-size: 14px;
font-weight: 500;
padding: 2px 8px;
border-radius: 8px;
}
.status-signed {
background: #d4edda;
color: #155724;
}
.status-unsigned {
background: #f8d7da;
color: #721c24;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #999;
}
.empty-text {
margin-top: 15px;
font-size: 16px;
}
.bottom-actions {
display: flex;
gap: 15px;
padding: 15px;
background: white;
border-top: 1px solid #f0f0f0;
}
.action-btn {
flex: 1;
padding: 12px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
border: none;
}
.cancel-btn {
background: #f5f5f5;
color: #666;
}
.confirm-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.confirm-btn:disabled {
opacity: 0.6;
}
</style>

View File

@ -0,0 +1,443 @@
<template>
<view class="qr-code-page">
<view class="qr-container">
<!-- 页面头部 -->
<view class="page-header">
<text class="page-title">签到二维码</text>
<text class="page-subtitle">请使用微信扫描二维码进行签到</text>
</view>
<!-- 二维码显示区域 -->
<view class="qr-display">
<view class="qr-card">
<view class="qr-header">
<text class="qr-title">{{ meetingInfo?.qdmc || '签到二维码' }}</text>
<view class="qr-timer">
<text class="timer-text">{{ qrTimer }}s</text>
</view>
</view>
<view class="qr-content">
<canvas
canvas-id="qrcode"
class="qr-canvas"
:style="{ width: qrSize + 'px', height: qrSize + 'px' }"
></canvas>
</view>
<view class="qr-info">
<view class="info-item">
<text class="info-label">会议时间</text>
<text class="info-value">{{ formatTime(meetingInfo?.qdkstime) }} - {{ formatTime(meetingInfo?.qdjstime) }}</text>
</view>
<view class="info-item">
<text class="info-label">会议地点</text>
<text class="info-value">{{ meetingInfo?.qdwz || '未设置' }}</text>
</view>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-buttons">
<button @click="refreshQRCode" class="refresh-btn">
<u-icon name="reload" size="16" color="#409EFF" />
<text class="btn-text">刷新二维码</text>
</button>
<button @click="goBack" class="back-btn">
<u-icon name="arrow-left" size="16" color="#666" />
<text class="btn-text">返回</text>
</button>
</view>
<!-- 使用说明 -->
<view class="usage-tips">
<view class="tips-header">
<u-icon name="info-circle" size="16" color="#409EFF" />
<text class="tips-title">使用说明</text>
</view>
<view class="tips-content">
<text class="tip-item">1. 二维码有效期为60秒请及时扫描</text>
<text class="tip-item">2. 使用微信扫一扫功能扫描二维码</text>
<text class="tip-item">3. 扫描后将跳转到签到确认页面</text>
<text class="tip-item">4. 请确保网络连接正常</text>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { qdFindByIdApi, generateQRCodeApi } from '@/api/base/server';
//
const qdId = ref('');
//
const meetingInfo = ref<any>(null);
//
const qrTimer = ref(60);
const qrSize = ref(200);
let qrTimerInterval: any = null;
let qrCanvas: any = null;
onLoad((options) => {
if (options?.qdId) {
qdId.value = options.qdId;
}
loadMeetingInfo();
});
onMounted(() => {
initQRCode();
});
//
const loadMeetingInfo = async () => {
try {
const result = await qdFindByIdApi({ id: qdId.value });
if (result && result.resultCode === 1) {
meetingInfo.value = result.result;
}
} catch (error) {
console.error('加载会议信息失败:', error);
}
};
//
const initQRCode = async () => {
try {
// qdId
const result = await generateQRCodeApi({
qdId: qdId.value
});
if (result && result.resultCode === 1) {
//
const qrData = result.result || '';
console.log('二维码数据:', qrData);
generateQRCodeImage(qrData);
} else {
throw new Error('生成二维码失败');
}
//
startTimer();
} catch (error) {
console.error('生成二维码失败:', error);
uni.showToast({ title: '生成二维码失败', icon: 'none' });
}
};
//
const generateQRCodeImage = (qrData: string) => {
try {
// 使qrcode
import('qrcode').then((QRCode) => {
// 使
(QRCode as any).toDataURL(qrData, {
width: qrSize.value,
height: qrSize.value,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
},
errorCorrectionLevel: 'M'
}, (err: any, url: string) => {
if (err) {
console.error('生成二维码失败:', err);
//
qrCanvas = uni.createCanvasContext('qrcode');
qrCanvas.clearRect(0, 0, qrSize.value, qrSize.value);
qrCanvas.setFillStyle('#ffffff');
qrCanvas.fillRect(0, 0, qrSize.value, qrSize.value);
qrCanvas.setFillStyle('#ff0000');
qrCanvas.setFontSize(12);
qrCanvas.fillText('二维码生成失败', qrSize.value / 2 - 40, qrSize.value / 2);
qrCanvas.draw();
return;
}
// 使uni-app
// canvas
qrCanvas = uni.createCanvasContext('qrcode');
qrCanvas.clearRect(0, 0, qrSize.value, qrSize.value);
// 使uni-appdrawImage
qrCanvas.drawImage(url, 0, 0, qrSize.value, qrSize.value);
qrCanvas.draw();
console.log('二维码生成成功');
});
}).catch((error) => {
console.error('加载qrcode库失败:', error);
// qrcode
qrCanvas = uni.createCanvasContext('qrcode');
qrCanvas.clearRect(0, 0, qrSize.value, qrSize.value);
qrCanvas.setFillStyle('#ffffff');
qrCanvas.fillRect(0, 0, qrSize.value, qrSize.value);
qrCanvas.setFillStyle('#666666');
qrCanvas.setFontSize(12);
qrCanvas.fillText('请安装qrcode库', qrSize.value / 2 - 30, qrSize.value / 2);
qrCanvas.draw();
});
console.log('二维码数据:', qrData);
} catch (error) {
console.error('生成二维码异常:', error);
//
qrCanvas = uni.createCanvasContext('qrcode');
qrCanvas.clearRect(0, 0, qrSize.value, qrSize.value);
qrCanvas.setFillStyle('#ffffff');
qrCanvas.fillRect(0, 0, qrSize.value, qrSize.value);
qrCanvas.setFillStyle('#ff0000');
qrCanvas.setFontSize(12);
qrCanvas.fillText('二维码生成异常', qrSize.value / 2 - 40, qrSize.value / 2);
qrCanvas.draw();
}
};
//
const startTimer = () => {
qrTimer.value = 60;
if (qrTimerInterval) {
clearInterval(qrTimerInterval);
}
qrTimerInterval = setInterval(() => {
qrTimer.value--;
if (qrTimer.value <= 0) {
clearInterval(qrTimerInterval);
uni.showToast({ title: '二维码已过期', icon: 'none' });
//
refreshQRCode();
}
}, 1000);
};
//
const refreshQRCode = async () => {
try {
await initQRCode();
uni.showToast({ title: '二维码已刷新', icon: 'success' });
} catch (error) {
uni.showToast({ title: '刷新失败', icon: 'none' });
}
};
//
const goBack = () => {
uni.navigateBack();
};
//
const formatTime = (time: string) => {
if (!time) return '未设置';
const date = new Date(time);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
//
onUnmounted(() => {
if (qrTimerInterval) {
clearInterval(qrTimerInterval);
}
});
</script>
<style lang="scss" scoped>
.qr-code-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
box-sizing: border-box;
}
.qr-container {
max-width: 400px;
margin: 0 auto;
}
//
.page-header {
text-align: center;
margin-bottom: 30px;
.page-title {
display: block;
font-size: 28px;
font-weight: 600;
color: white;
margin-bottom: 8px;
}
.page-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.8);
}
}
//
.qr-display {
margin-bottom: 30px;
}
.qr-card {
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.qr-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.qr-title {
font-size: 18px;
font-weight: 600;
color: #333;
flex: 1;
}
.qr-timer {
background: linear-gradient(135deg, #ff4757 0%, #ff3742 100%);
padding: 6px 12px;
border-radius: 20px;
.timer-text {
color: white;
font-size: 14px;
font-weight: 600;
}
}
}
.qr-content {
display: flex;
justify-content: center;
margin-bottom: 20px;
.qr-canvas {
border: 2px solid #f0f0f0;
border-radius: 8px;
background: white;
}
}
.qr-info {
.info-item {
display: flex;
margin-bottom: 8px;
.info-label {
font-size: 14px;
color: #666;
min-width: 80px;
}
.info-value {
font-size: 14px;
color: #333;
flex: 1;
}
}
}
//
.action-buttons {
display: flex;
gap: 12px;
margin-bottom: 30px;
.refresh-btn, .back-btn {
flex: 1;
height: 44px;
border: none;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 16px;
}
.refresh-btn {
background: #409EFF;
color: white;
}
.back-btn {
background: #f5f5f5;
color: #666;
}
.btn-text {
font-size: 14px;
}
}
// 使
.usage-tips {
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 20px;
backdrop-filter: blur(10px);
.tips-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
.tips-title {
font-size: 16px;
font-weight: 600;
color: white;
}
}
.tips-content {
.tip-item {
display: block;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 6px;
line-height: 1.4;
}
}
}
//
@media (max-width: 375px) {
.qr-code-page {
padding: 15px;
}
.qr-card {
padding: 20px;
}
.qr-size {
width: 180px;
height: 180px;
}
.page-header .page-title {
font-size: 24px;
}
}
</style>

View File

@ -0,0 +1,844 @@
<template>
<BasicLayout>
<scroll-view scroll-y class="teacher-scroll-view">
<view class="teacher-container">
<!-- 筛选条件区域 -->
<view class="filter-section">
<view class="filter-title">📋 选择条件</view>
<!-- 选择类别 -->
<view class="filter-row">
<view class="filter-label">选择类别</view>
<picker
mode="selector"
:range="selectTypeOptions"
range-key="label"
@change="onSelectTypeChange"
class="filter-picker"
>
<view class="picker-display">
<text :class="{ placeholder: !selectType }">
{{ getSelectTypeText() || '请选择类别' }}
</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</picker>
</view>
<!-- 职务类型选择器 -->
<view class="filter-row" v-if="selectType === 4">
<view class="filter-label">职务类型</view>
<picker
mode="selector"
:range="zwTypeOptions"
range-key="label"
@change="onZwTypeChange"
class="filter-picker"
>
<view class="picker-display">
<text>{{ getZwTypeText() }}</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</picker>
</view>
<!-- 具体选择 -->
<view class="filter-row" v-if="selectType && datas.length > 0">
<view class="filter-label">{{ getSecondSelectLabel() }}</view>
<view class="multi-select-container">
<view class="selected-items-display" @click="showMultiSelectModal">
<text :class="{ placeholder: !selectTwoType.length }">
{{ getSecondSelectText() || '请选择' }}
</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</view>
<!-- 多选弹窗 -->
<view v-if="showModal" class="modal-overlay" @click="hideMultiSelectModal">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ getSecondSelectLabel() }}</text>
<view class="modal-actions">
<text class="modal-btn cancel-btn" @click="hideMultiSelectModal">取消</text>
<text class="modal-btn confirm-btn" @click="confirmMultiSelect">确定</text>
</view>
</view>
<view class="modal-body">
<view class="checkbox-list">
<view
v-for="item in datas"
:key="item.value"
class="checkbox-item"
@click="toggleItemSelection(item.value)"
>
<view class="checkbox">
<uni-icons
:type="selectTwoType.includes(item.value) ? 'checkbox-filled' : 'circle'"
size="20"
:color="selectTwoType.includes(item.value) ? '#007aff' : '#ccc'"
></uni-icons>
</view>
<text class="checkbox-label">{{ item.label }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 选择结果区域 -->
<view class="result-section">
<view class="result-title">
👥 选择结果
<text v-if="selectedTeachers.length > 0" class="result-count">
({{ selectedTeachers.length }})
</text>
</view>
<!-- 已选教师标签 -->
<view class="name-tags" v-if="selectedTeachers.length > 0">
<view
v-for="teacher in selectedTeachers"
:key="teacher.id"
class="name-tag"
>
<text>{{ teacher.jsxm }}</text>
<view class="remove-icon" @click="removeTeacher(teacher.id)">
<uni-icons type="close" size="12" color="#ffffff"></uni-icons>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="selectedTeachers.length === 0 && !isLoading" class="empty-state">
<uni-icons type="info" size="60" color="#ccc"></uni-icons>
<text class="empty-text">
{{ selectType ? '请先选择条件,加载教师列表' : '请选择筛选条件' }}
</text>
</view>
<!-- 加载状态 -->
<view v-if="isLoading" class="loading-state">
<uni-icons type="spinner-cycle" size="40" color="#007aff"></uni-icons>
<text class="loading-text">加载中...</text>
</view>
</view>
</view>
</scroll-view>
<!-- 底部操作 -->
<template #bottom>
<view class="bottom-actions">
<button class="action-btn cancel-btn" @click="handleCancel">
取消
</button>
<button class="action-btn confirm-btn" @click="handleConfirm">
确定 ({{ selectedTeachers.length }})
</button>
</view>
</template>
</BasicLayout>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue";
import {
jsdfindJsByPhoneApi,
jsFindByNjIdOrBjIdApi,
jsFindByKmIdApi,
jsFindByBzrNjIdApi,
jsFindByZwIdApi,
findAllNj,
kmFindAllApi,
zwFindAllApi
} from "@/api/base/server";
interface TeacherInfo {
id: string;
jsxm: string;
jsId: string;
dzzw: string;
qtzw: string;
}
//
const selectType = ref<number | null>(null);
const selectTwoType = ref<string[]>([]);
const zwType = ref<number>(1);
const datas = ref<any[]>([]);
const selectedTeachers = ref<TeacherInfo[]>([]);
const isLoading = ref(false);
const showModal = ref(false); //
//
const selectTypeOptions = [
{ value: 1, label: '所有老师' },
{ value: 2, label: '科目' },
{ value: 3, label: '班主任' },
{ value: 4, label: '职务' }
];
//
const zwTypeOptions = [
{ value: 1, label: '党政职务' },
{ value: 2, label: '其他职务' }
];
//
const getSelectTypeText = () => {
const option = selectTypeOptions.find(item => item.value === selectType.value);
return option ? option.label : '';
};
//
const getZwTypeText = () => {
const option = zwTypeOptions.find(item => item.value === zwType.value);
return option ? option.label : '';
};
//
const getSecondSelectLabel = () => {
switch (selectType.value) {
case 1:
return '年级/班级';
case 2:
return '科目';
case 3:
return '年级';
case 4:
return '具体职务';
default:
return '请选择';
}
};
//
const getSecondSelectText = () => {
if (!selectTwoType.value.length) return '';
const selectedItems = datas.value.filter(item =>
selectTwoType.value.includes(item.value)
);
if (selectedItems.length === 1) {
return selectedItems[0].label;
} else if (selectedItems.length > 1) {
return `已选择${selectedItems.length}`;
}
return '';
};
//
const onSelectTypeChange = async (e: any) => {
selectType.value = selectTypeOptions[e.detail.value].value;
selectTwoType.value = [];
selectedTeachers.value = [];
if (selectType.value === 1 || selectType.value === 3) {
await loadNjData();
} else if (selectType.value === 2) {
await loadKmData();
} else if (selectType.value === 4) {
await loadZwData();
}
};
//
const onZwTypeChange = async (e: any) => {
zwType.value = zwTypeOptions[e.detail.value].value;
selectTwoType.value = [];
selectedTeachers.value = [];
await loadZwData();
};
//
const showMultiSelectModal = () => {
showModal.value = true;
};
const hideMultiSelectModal = () => {
showModal.value = false;
};
const toggleItemSelection = (value: string) => {
const index = selectTwoType.value.indexOf(value);
if (index > -1) {
selectTwoType.value.splice(index, 1);
} else {
selectTwoType.value.push(value);
}
};
const confirmMultiSelect = () => {
hideMultiSelectModal();
loadTeachers();
};
//
const loadNjData = async () => {
try {
const result = await findAllNj();
console.log('年级API返回结果:', result);
//
let njData = [];
if (Array.isArray(result)) {
njData = result;
} else if (result && typeof result === 'object') {
//
if (Array.isArray(result.result)) {
njData = result.result;
} else if (result.resultCode === 1 && Array.isArray(result.result)) {
njData = result.result;
} else if (Array.isArray(result.data)) {
njData = result.data;
} else {
console.warn('年级数据格式异常:', result);
njData = [];
}
} else {
console.warn('年级数据格式异常:', result);
njData = [];
}
// 使
datas.value = njData.map((item: any) => ({
label: item.njmc || item.name || item.label,
value: item.id || item.value
}));
console.log('处理后的年级数据:', datas.value);
} catch (error) {
console.error('加载年级数据失败:', error);
uni.showToast({ title: '加载年级数据失败', icon: 'none' });
}
};
//
const loadKmData = async () => {
try {
const result = await kmFindAllApi();
console.log('科目API返回结果:', result);
//
let kmData = [];
if (Array.isArray(result)) {
kmData = result;
} else if (result && typeof result === 'object') {
//
if (Array.isArray(result.result)) {
kmData = result.result;
} else if (result.resultCode === 1 && Array.isArray(result.result)) {
kmData = result.result;
} else if (Array.isArray(result.data)) {
kmData = result.data;
} else {
console.warn('科目数据格式异常:', result);
kmData = [];
}
} else {
console.warn('科目数据格式异常:', result);
kmData = [];
}
// sortsort
const sortedData = kmData.sort((a: any, b: any) => {
const sortA = a.sort || 0;
const sortB = b.sort || 0;
if (sortA !== sortB) {
return sortA - sortB;
} else {
return (a.kmmc || a.name || '').localeCompare(b.kmmc || b.name || '');
}
});
datas.value = sortedData.map((item: any) => ({
label: item.kmmc || item.name || item.label,
value: item.id || item.value
}));
console.log('处理后的科目数据:', datas.value);
} catch (error) {
console.error('加载科目数据失败:', error);
uni.showToast({ title: '加载科目数据失败', icon: 'none' });
}
};
//
const loadZwData = async () => {
try {
const result = await zwFindAllApi();
console.log('职务API返回结果:', result);
//
let allZwData = [];
if (Array.isArray(result)) {
allZwData = result;
} else if (result && typeof result === 'object') {
//
if (Array.isArray(result.result)) {
allZwData = result.result;
} else if (result.resultCode === 1 && Array.isArray(result.result)) {
allZwData = result.result;
} else if (Array.isArray(result.data)) {
allZwData = result.data;
} else {
console.warn('职务数据格式异常:', result);
allZwData = [];
}
} else {
console.warn('职务数据格式异常:', result);
allZwData = [];
}
//
const filteredData = allZwData.filter((item: any) => {
if (zwType.value === 1) {
return item.zwlx === '1' || item.zwlx === '党政职务' || item.zwlx === 'A' || item.zwlx === 'dzzw';
} else if (zwType.value === 2) {
return item.zwlx === '2' || item.zwlx === '其他职务' || item.zwlx === 'B' || item.zwlx === 'qtzw';
}
return true;
});
// sortsort
const sortedData = filteredData.sort((a: any, b: any) => {
const sortA = a.sort || 0;
const sortB = b.sort || 0;
if (sortA !== sortB) {
return sortA - sortB;
} else {
return (a.zwmc || a.name || '').localeCompare(b.zwmc || b.name || '');
}
});
datas.value = sortedData.map((item: any) => ({
label: item.zwmc || item.name || item.label,
value: item.id || item.value
}));
console.log('处理后的职务数据:', datas.value);
} catch (error) {
console.error('加载职务数据失败:', error);
uni.showToast({ title: '加载职务数据失败', icon: 'none' });
}
};
//
const loadTeachers = async () => {
if (!selectType.value || !selectTwoType.value.length) return;
isLoading.value = true;
try {
let allTeacherData: any[] = [];
// API
for (const selectedValue of selectTwoType.value) {
let result;
if (selectType.value === 1) {
result = await jsFindByNjIdOrBjIdApi({
njOrBjId: selectedValue
});
} else if (selectType.value === 2) {
result = await jsFindByKmIdApi({
kmId: selectedValue
});
} else if (selectType.value === 3) {
result = await jsFindByBzrNjIdApi({
njId: selectedValue
});
} else if (selectType.value === 4) {
result = await jsFindByZwIdApi({
zwId: selectedValue,
zwType: zwType.value.toString()
});
}
console.log('教师API返回结果:', result);
//
let teacherData = [];
if (Array.isArray(result)) {
teacherData = result;
} else if (result && typeof result === 'object') {
//
if (Array.isArray(result.result)) {
teacherData = result.result;
} else if (result.resultCode === 1 && Array.isArray(result.result)) {
teacherData = result.result;
} else if (Array.isArray(result.data)) {
teacherData = result.data;
} else {
console.warn('教师数据格式异常:', result);
teacherData = [];
}
} else {
console.warn('教师数据格式异常:', result);
teacherData = [];
}
//
allTeacherData = allTeacherData.concat(teacherData);
}
// ID
const uniqueTeachers = allTeacherData.filter((teacher, index, self) =>
index === self.findIndex(t => (t.id || t.jsId) === (teacher.id || teacher.jsId))
);
console.log('API返回的教师数据总数:', allTeacherData.length);
console.log('去重后的教师数据总数:', uniqueTeachers.length);
console.log('是否有重复数据:', allTeacherData.length !== uniqueTeachers.length);
if (allTeacherData.length !== uniqueTeachers.length) {
console.warn('发现重复的教师数据,已进行去重处理');
}
// 使
selectedTeachers.value = uniqueTeachers.map((item: any) => ({
id: item.id || item.jsId,
jsxm: item.jsxm || item.name || item.label,
jsId: item.id || item.jsId,
dzzw: item.dzzw || '',
qtzw: item.qtzw || ''
}));
console.log('最终处理后的教师数据:', selectedTeachers.value);
} catch (error) {
console.error('加载教师列表失败:', error);
uni.showToast({ title: '加载教师列表失败', icon: 'none' });
} finally {
isLoading.value = false;
}
};
//
const removeTeacher = (teacherId: string) => {
const index = selectedTeachers.value.findIndex(teacher => teacher.id === teacherId);
if (index > -1) {
selectedTeachers.value.splice(index, 1);
}
};
//
const handleCancel = () => {
uni.navigateBack();
};
//
const handleConfirm = () => {
console.log('=== handleConfirm 开始 ===');
console.log('当前选中的教师数量:', selectedTeachers.value.length);
console.log('选中的教师数据:', selectedTeachers.value);
// ID
const teacherIds = selectedTeachers.value.map(t => t.jsId);
const uniqueIds = [...new Set(teacherIds)];
console.log('教师ID列表:', teacherIds);
console.log('去重后的教师ID列表:', uniqueIds);
console.log('是否有重复ID:', teacherIds.length !== uniqueIds.length);
if (teacherIds.length !== uniqueIds.length) {
console.warn('发现重复的教师ID将进行去重处理');
//
const uniqueTeachers = selectedTeachers.value.filter((teacher, index, self) =>
index === self.findIndex(t => t.jsId === teacher.jsId)
);
selectedTeachers.value = uniqueTeachers;
console.log('去重后的教师数据:', uniqueTeachers);
}
// 使
console.log('准备发送 teacherSelected 事件');
uni.$emit('teacherSelected', selectedTeachers.value);
console.log('teacherSelected 事件已发送');
console.log('准备返回上一页');
uni.navigateBack();
console.log('=== handleConfirm 结束 ===');
};
//
onMounted(() => {
//
});
</script>
<style lang="scss" scoped>
.teacher-scroll-view {
height: calc(100vh - 120px);
}
.teacher-container {
padding: 15px;
}
.filter-section {
background: white;
border-radius: 12px;
padding: 15px;
margin-bottom: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.filter-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 15px;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.filter-label {
width: 80px;
font-size: 14px;
color: #666;
margin-right: 10px;
}
.filter-picker {
flex: 1;
}
.picker-display {
display: flex;
justify-content: space-between;
align-items: center;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
color: #333;
}
.placeholder {
color: #999;
}
.multi-select-container {
flex: 1;
position: relative;
}
.selected-items-display {
display: flex;
justify-content: space-between;
align-items: center;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
color: #333;
cursor: pointer;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: flex-end;
z-index: 1000;
}
.modal-content {
background-color: white;
border-radius: 12px 12px 0 0;
width: 100%;
max-height: 70%;
display: flex;
flex-direction: column;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-bottom: 1px solid #f0f0f0;
}
.modal-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.modal-actions {
display: flex;
gap: 15px;
}
.modal-btn {
padding: 8px 15px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
border: none;
cursor: pointer;
}
.modal-btn.cancel-btn {
background: #f5f5f5;
color: #666;
}
.modal-btn.confirm-btn {
background: linear-gradient(135deg, #007aff 0%, #0056cc 100%);
color: white;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.3);
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 15px;
}
.checkbox-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.checkbox-item {
display: flex;
align-items: center;
padding: 10px 0;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.checkbox-item:last-child {
border-bottom: none;
}
.checkbox {
margin-right: 10px;
flex-shrink: 0;
}
.checkbox-label {
font-size: 15px;
color: #333;
flex: 1;
}
.result-section {
background: white;
border-radius: 12px;
padding: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.result-title {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 15px;
}
.result-count {
color: #007aff;
font-size: 14px;
margin-left: 8px;
}
.name-tags {
display: flex;
flex-wrap: wrap;
gap: 8px 10px; // Row gap, Column gap
margin-bottom: 15px;
.name-tag {
position: relative;
font-size: 13px;
padding: 5px 8px;
border-radius: 4px;
text-align: center;
flex-grow: 0;
flex-shrink: 0;
box-sizing: border-box;
flex-basis: calc((100% - 30px) / 4);
height: 30px;
line-height: 20px;
border: 1px solid #f4f4f5;
white-space: normal;
word-break: break-all;
background-color: #f4f4f5;
color: #909399;
.remove-icon {
position: absolute;
top: -1px;
right: -1px;
width: 16px;
height: 16px;
background-color: #ff4d4f;
border-bottom-left-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
padding: 1px;
box-sizing: border-box;
cursor: pointer;
}
}
}
.empty-state, .loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #999;
}
.empty-text, .loading-text {
margin-top: 15px;
font-size: 16px;
}
.bottom-actions {
display: flex;
gap: 15px;
padding: 15px;
background: white;
border-top: 1px solid #f0f0f0;
}
.action-btn {
flex: 1;
padding: 12px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
border: none;
}
.cancel-btn {
background: #f5f5f5;
color: #666;
}
.confirm-btn {
background: linear-gradient(135deg, #007aff 0%, #0056cc 100%);
color: white;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.3);
}
</style>

BIN
src/static/base/details.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

BIN
src/static/base/push.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
src/static/base/qr-code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB