diff --git a/doc/副本课程协议—月卡篮球(1).docx b/doc/副本课程协议—月卡篮球(1).docx index 6d1ee5bb..525d3291 100644 Binary files a/doc/副本课程协议—月卡篮球(1).docx and b/doc/副本课程协议—月卡篮球(1).docx differ diff --git a/niucloud/app/api/controller/apiController/Contract.php b/niucloud/app/api/controller/apiController/Contract.php index 846c0112..b8eede19 100644 --- a/niucloud/app/api/controller/apiController/Contract.php +++ b/niucloud/app/api/controller/apiController/Contract.php @@ -488,15 +488,23 @@ class Contract extends BaseApiService $fillData[$placeholder] = $defaultValue; } - // 调用DocumentTemplateService生成Word文档 + // 调试信息:记录填充数据 + Log::info('准备的填充数据', [ + 'contract_sign_id' => $contract_sign_id, + 'fill_data_count' => count($fillData), + 'fill_data' => $fillData + ]); + + // 调用DocumentTemplateService的新XML字符串方法生成Word文档 $documentService = new \app\service\admin\document\DocumentTemplateService(); $generateData = [ 'template_id' => $contractSign['contract_id'], 'fill_data' => $fillData, - 'output_filename' => $contract['contract_name'] . '_' . $contractSign['student_id'] . '_' . date('YmdHis') . '.docx' + 'output_filename' => $contract['contract_name'] . '_' . $contractSign['student_id'] . '_' . date('YmdHis') . '.docx', + 'use_direct_values' => true // 直接使用已处理的值,不进行二次处理 ]; - $result = $documentService->generateDocument($generateData); + $result = $documentService->generateDocumentByXmlString($generateData); // 更新school_contract_sign表的sign_file字段和status字段 $updateResult = Db::table('school_contract_sign') diff --git a/niucloud/app/common.php b/niucloud/app/common.php index 7a02a47c..ae1c0d5d 100644 --- a/niucloud/app/common.php +++ b/niucloud/app/common.php @@ -1672,6 +1672,33 @@ function get_current_week() return date('W'); } +/** + * 获取当前年份(别名函数,兼容配置中的函数名) + * @return string + */ +function current_year() +{ + return get_current_year(); +} + +/** + * 获取当前月份(别名函数,兼容配置中的函数名) + * @return string + */ +function current_month() +{ + return get_current_month(); +} + +/** + * 获取当前日(别名函数,兼容配置中的函数名) + * @return string + */ +function current_day() +{ + return get_current_day(); +} + /** * 获取当前季度 * @return string diff --git a/niucloud/app/service/admin/document/DocumentTemplateService.php b/niucloud/app/service/admin/document/DocumentTemplateService.php index 1b73c87e..7ff7b9b1 100644 --- a/niucloud/app/service/admin/document/DocumentTemplateService.php +++ b/niucloud/app/service/admin/document/DocumentTemplateService.php @@ -652,7 +652,19 @@ class DocumentTemplateService extends BaseAdminService // 准备填充数据 $placeholderConfig = json_decode($template['placeholder_config'], true); - $fillValues = $this->prepareFillData($placeholderConfig, $data['fill_data']); + // 检查是否传递了 use_direct_values 参数,如果是则直接使用传递的值 + if (!empty($data['use_direct_values']) && $data['use_direct_values'] === true) { + // 直接使用传递的 fill_data 作为填充值,不进行二次处理 + $fillValues = $data['fill_data']; + Log::info('使用直接填充模式', [ + 'template_id' => $data['template_id'], + 'fill_data_count' => count($fillValues), + 'fill_data_keys' => array_keys($fillValues) + ]); + } else { + // 使用原有的配置处理模式 + $fillValues = $this->prepareFillData($placeholderConfig, $data['fill_data']); + } // 生成文档 $templatePath = public_path() . '/upload/' . $template['contract_template']; @@ -695,12 +707,14 @@ class DocumentTemplateService extends BaseAdminService throw new \Exception('无法创建临时文档存储目录,请检查系统权限'); } + // 预处理:修复被格式化分割的占位符 + $fixedTemplatePath = $this->fixBrokenPlaceholders($templatePath); + // 使用 PhpWord 模板处理器 - $templateProcessor = new TemplateProcessor($templatePath); + $templateProcessor = new TemplateProcessor($fixedTemplatePath); - foreach ($fillValues as $placeholder => $value) { - $templateProcessor->setValue($placeholder, $value); - } + // 智能处理占位符,根据类型使用不同的处理方法 + $this->processPlaceholders($templateProcessor, $fillValues, $placeholderConfig); $templateProcessor->saveAs($fullOutputPath); @@ -849,13 +863,37 @@ class DocumentTemplateService extends BaseAdminService $tableName = $config['table_name']; $fieldName = $config['field_name']; - // 简单的数据库查询(实际应用中需要更完善的查询逻辑) + // 改进的数据库查询逻辑,支持条件查询 $model = \think\facade\Db::connect(); - $result = $model->table($tableName)->field($fieldName)->find(); + $query = $model->table($tableName); + + // 如果有传入的查询条件(比如学员ID),使用条件查询 + if (!empty($userFillData) && is_array($userFillData)) { + foreach ($userFillData as $key => $value) { + // 支持常见的查询字段 + if (in_array($key, ['id', 'student_id', 'user_id', 'person_id', 'contract_id'])) { + $query->where($key, $value); + break; // 只使用第一个匹配的条件 + } + } + } + + $result = $query->field($fieldName)->find(); + + Log::info('数据库查询', [ + 'table' => $tableName, + 'field' => $fieldName, + 'result' => $result, + 'user_data' => $userFillData + ]); return $result[$fieldName] ?? $config['default_value'] ?? ''; } catch (\Exception $e) { - Log::error('数据库查询失败:' . $e->getMessage()); + Log::error('数据库查询失败:' . $e->getMessage(), [ + 'table' => $config['table_name'] ?? '', + 'field' => $config['field_name'] ?? '', + 'config' => $config + ]); return $config['default_value'] ?? ''; } } @@ -1042,6 +1080,130 @@ class DocumentTemplateService extends BaseAdminService return $this->pageQuery($searchModel); } + /** + * 修复被Word格式化分割的占位符 + * Word在编辑时会在占位符中插入格式化标签,导致{{placeholder}}被分割 + * 这个方法会创建一个修复后的临时文件 + * + * @param string $templatePath 原始模板文件路径 + * @return string 修复后的模板文件路径 + */ + private function fixBrokenPlaceholders(string $templatePath) + { + try { + // 创建临时文件 + $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'niucloud_templates'; + if (!is_dir($tempDir)) { + mkdir($tempDir, 0755, true); + } + + $fixedTemplatePath = $tempDir . DIRECTORY_SEPARATOR . 'fixed_' . basename($templatePath); + + // 复制原文件到临时位置 + if (!copy($templatePath, $fixedTemplatePath)) { + Log::warning('无法创建临时模板文件,使用原始模板', ['template_path' => $templatePath]); + return $templatePath; + } + + // 打开ZIP文件进行修复 + $zip = new \ZipArchive(); + + if ($zip->open($fixedTemplatePath) === TRUE) { + $content = $zip->getFromName('word/document.xml'); + + if ($content !== false) { + $originalLength = strlen($content); + $fixedContent = $content; + + // 第一步:修复跨XML标签的占位符(最常见的模式) + // 模式1:修复 {{...内容}} 这种分割 + $pattern1 = '/]*>\{\{<\/w:t>([^<]*)]*>([^<]*)\}\}<\/w:t>/'; + $fixedContent = preg_replace_callback($pattern1, function($matches) { + $placeholder = '{{' . $matches[1] . $matches[2] . '}}'; + return '' . $placeholder . ''; + }, $fixedContent); + + // 模式2:修复 {...{内容}} 这种分割 + $pattern2 = '/]*>\{<\/w:t>([^<]*)]*>\{([^<]*)\}\}<\/w:t>/'; + $fixedContent = preg_replace_callback($pattern2, function($matches) { + $placeholder = '{{' . $matches[1] . $matches[2] . '}}'; + return '' . $placeholder . ''; + }, $fixedContent); + + // 模式3:修复三段式分割 {{...中间...}} + $pattern3 = '/]*>\{\{<\/w:t>([^<]*)]*>([^<]*)<\/w:t>([^<]*)]*>([^<]*)\}\}<\/w:t>/'; + $fixedContent = preg_replace_callback($pattern3, function($matches) { + $placeholder = '{{' . $matches[1] . $matches[2] . $matches[4] . '}}'; + return '' . $placeholder . ''; + }, $fixedContent); + + // 第二步:处理更复杂的格式化分割(包含rPr标签的) + // 匹配包含格式化信息的分割模式 + $complexPattern = '/\{\{[^}]*?(?:<\/w:t><\/w:r>]*?>(?:]*?>.*?<\/w:rPr>)?]*?>)[^}]*?\}\}/s'; + $fixedContent = preg_replace_callback($complexPattern, function($matches) { + $placeholder = $matches[0]; + // 移除所有XML标签,保留纯文本 + $cleaned = preg_replace('/<[^>]*?>/', '', $placeholder); + // 验证是否为有效占位符 + if (!preg_match('/^\{\{[^}]+\}\}$/', $cleaned)) { + return $placeholder; + } + // 保持原有的第一个w:t标签结构 + if (preg_match('/^([^<]*]*>)/', $placeholder, $tagMatch)) { + return $tagMatch[1] . $cleaned . ''; + } + return $cleaned; + }, $fixedContent); + + // 第三步:修复任何剩余的基本分割模式 + $basicPattern = '/\{\{[^}]*?<[^>]*?>[^}]*?\}\}/'; + $fixedContent = preg_replace_callback($basicPattern, function($matches) { + $placeholder = $matches[0]; + $cleaned = preg_replace('/<[^>]*?>/', '', $placeholder); + if (!preg_match('/^\{\{[^}]+\}\}$/', $cleaned)) { + return $placeholder; + } + return $cleaned; + }, $fixedContent); + + // 将修复后的内容写回ZIP文件 + if ($zip->addFromString('word/document.xml', $fixedContent)) { + $zip->close(); + + Log::info('占位符修复完成', [ + 'template_file' => $templatePath, + 'fixed_file' => $fixedTemplatePath, + 'original_length' => $originalLength, + 'fixed_length' => strlen($fixedContent), + 'size_change' => strlen($fixedContent) - $originalLength + ]); + + return $fixedTemplatePath; + } else { + $zip->close(); + Log::warning('无法写入修复后的内容,使用原始模板', ['template_path' => $templatePath]); + return $templatePath; + } + } else { + $zip->close(); + Log::warning('无法读取document.xml,使用原始模板', ['template_path' => $templatePath]); + return $templatePath; + } + } else { + Log::warning('无法打开Word文档,使用原始模板', ['template_path' => $templatePath]); + return $templatePath; + } + } catch (\Exception $e) { + Log::error('修复占位符失败', [ + 'template_path' => $templatePath, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + // 修复失败不影响主流程,返回原始模板路径 + return $templatePath; + } + } + /** * 格式化文件大小 * @param int $size @@ -1431,4 +1593,1214 @@ class DocumentTemplateService extends BaseAdminService throw new \Exception('模板文档更新失败:' . $e->getMessage()); } } + + /** + * 使用XML字符串操作生成Word文档 + * 此方法直接读取Word模板的XML内容,进行字符串替换,然后保存为新的Word文档 + * @param array $data + * @return array + * @throws \Exception + */ + public function generateDocumentByXmlString(array $data) + { + $template = $this->contractModel->find($data['template_id']); + if (!$template) { + throw new \Exception('模板不存在'); + } + + if (empty($template['placeholder_config'])) { + throw new \Exception('模板尚未配置占位符'); + } + + // 创建生成记录 + $logData = [ + 'site_id' => $this->site_id, + 'template_id' => $data['template_id'], + 'user_id' => $this->uid, + 'user_type' => 1, + 'fill_data' => json_encode($data['fill_data']), + 'status' => 'pending', + 'completed_at' => date('Y-m-d H:i:s') + ]; + + $log = $this->logModel->create($logData); + + try { + // 更新状态为处理中 + $log->status = 'processing'; + $log->process_start_time = date('Y-m-d H:i:s'); + $log->save(); + + // 准备填充数据 + $placeholderConfig = json_decode($template['placeholder_config'], true); + if (!empty($data['use_direct_values']) && $data['use_direct_values'] === true) { + $fillValues = $data['fill_data']; + Log::info('使用直接填充模式(XML字符串方法)', [ + 'template_id' => $data['template_id'], + 'fill_data_count' => count($fillValues), + 'fill_data_keys' => array_keys($fillValues) + ]); + } else { + $fillValues = $this->prepareFillData($placeholderConfig, $data['fill_data']); + } + + // 原始模板路径 + $templatePath = public_path() . '/upload/' . $template['contract_template']; + $outputFileName = $data['output_filename'] ?: ($template['contract_name'] . '_' . date('YmdHis') . '.docx'); + + // 生成输出路径 + $outputPath = 'generated_documents/' . date('Y/m/') . $outputFileName; + $publicOutputPath = public_path() . '/upload/' . $outputPath; + $publicOutputDir = dirname($publicOutputPath); + + // 确保输出目录存在 + if (!is_dir($publicOutputDir)) { + if (!mkdir($publicOutputDir, 0755, true) && !is_dir($publicOutputDir)) { + throw new \Exception('无法创建输出目录:' . $publicOutputDir); + } + } + + // 使用XML字符串方法生成文档 + $success = $this->processWordDocumentXml($templatePath, $publicOutputPath, $fillValues); + + if (!$success) { + throw new \Exception('XML字符串处理失败'); + } + + // 更新生成记录 + $log->status = 'completed'; + $log->generated_file_path = $outputPath; + $log->generated_file_name = $outputFileName; + $log->process_end_time = date('Y-m-d H:i:s'); + $log->save(); + + Log::info('XML字符串方法生成Word文档成功', [ + 'template_id' => $data['template_id'], + 'output_path' => $outputPath, + 'fill_values_count' => count($fillValues) + ]); + + return [ + 'log_id' => $log->id, + 'file_path' => $outputPath, + 'file_name' => $outputFileName, + 'download_url' => url('/upload/' . $outputPath) + ]; + + } catch (\Exception $e) { + // 更新记录为失败状态 + $log->status = 'failed'; + $log->error_msg = $e->getMessage(); + $log->process_end_time = date('Y-m-d H:i:s'); + $log->save(); + + Log::error('XML字符串方法生成Word文档失败', [ + 'template_id' => $data['template_id'], + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + throw new \Exception('文档生成失败:' . $e->getMessage()); + } + } + + /** + * 处理Word文档的XML内容进行占位符替换 + * @param string $templatePath 模板文件路径 + * @param string $outputPath 输出文件路径 + * @param array $fillValues 填充值数组 + * @return bool + * @throws \Exception + */ + private function processWordDocumentXml(string $templatePath, string $outputPath, array $fillValues): bool + { + try { + // 检查模板文件是否存在 + if (!file_exists($templatePath)) { + throw new \Exception('模板文件不存在:' . $templatePath); + } + + // 创建临时工作目录 + $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'word_processing_' . uniqid(); + if (!mkdir($tempDir, 0755, true)) { + throw new \Exception('无法创建临时工作目录'); + } + + try { + // 1. 解压Word文档到临时目录 + $zip = new \ZipArchive(); + $result = $zip->open($templatePath); + + if ($result !== TRUE) { + throw new \Exception('无法打开Word模板文件,错误代码:' . $result); + } + + if (!$zip->extractTo($tempDir)) { + $zip->close(); + throw new \Exception('无法解压Word模板文件'); + } + $zip->close(); + + // 2. 读取document.xml文件 + $documentXmlPath = $tempDir . DIRECTORY_SEPARATOR . 'word' . DIRECTORY_SEPARATOR . 'document.xml'; + if (!file_exists($documentXmlPath)) { + throw new \Exception('Word文档缺少document.xml文件'); + } + + $xmlContent = file_get_contents($documentXmlPath); + if ($xmlContent === false) { + throw new \Exception('无法读取document.xml文件内容'); + } + + Log::info('读取XML内容成功', [ + 'original_length' => strlen($xmlContent), + 'placeholders_to_replace' => array_keys($fillValues) + ]); + + // 3. 进行占位符替换 + $modifiedXmlContent = $this->replaceXmlPlaceholders($xmlContent, $fillValues); + + Log::info('XML占位符替换完成', [ + 'original_length' => strlen($xmlContent), + 'modified_length' => strlen($modifiedXmlContent), + 'size_change' => strlen($modifiedXmlContent) - strlen($xmlContent) + ]); + + // 4. 写回修改后的document.xml + if (file_put_contents($documentXmlPath, $modifiedXmlContent) === false) { + throw new \Exception('无法写入修改后的document.xml'); + } + + // 5. 重新打包为Word文档 + $newZip = new \ZipArchive(); + $result = $newZip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + + if ($result !== TRUE) { + throw new \Exception('无法创建输出Word文件,错误代码:' . $result); + } + + // 递归添加所有文件到ZIP + $this->addDirectoryToZip($newZip, $tempDir, ''); + + if (!$newZip->close()) { + throw new \Exception('无法保存输出Word文件'); + } + + Log::info('Word文档重新打包成功', [ + 'output_path' => $outputPath, + 'file_size' => filesize($outputPath) + ]); + + // 处理文档中的URL图片(第二次处理) + $this->processUrlImagesInDocument($outputPath, $fillValues); + + return true; + + } finally { + // 清理临时目录 + $this->removeDirectory($tempDir); + } + + } catch (\Exception $e) { + Log::error('处理Word文档XML失败', [ + 'template_path' => $templatePath, + 'output_path' => $outputPath, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + throw $e; + } + } + + /** + * 在XML内容中替换占位符 + * @param string $xmlContent 原始XML内容 + * @param array $fillValues 替换值数组 + * @return string 替换后的XML内容 + */ + private function replaceXmlPlaceholders(string $xmlContent, array $fillValues): string + { + $modifiedContent = $xmlContent; + + // 首先清理被格式化标签分割的占位符 + $modifiedContent = $this->cleanBrokenPlaceholdersInXml($modifiedContent); + + $replacementCount = 0; + + foreach ($fillValues as $placeholder => $value) { + // 使用正则表达式匹配占位符,支持空格、换行符、制表符 + // 模式:{{ + 任意空白字符 + placeholder + 任意空白字符 + }} + $escapedPlaceholder = preg_quote($placeholder, '/'); + $searchPattern = '/\{\{\s*' . $escapedPlaceholder . '\s*\}\}/'; + + // 转义特殊字符,确保安全的XML替换 + $safeValue = htmlspecialchars((string)$value, ENT_XML1, 'UTF-8'); + + // 使用正则替换 + $beforeLength = strlen($modifiedContent); + $modifiedContent = preg_replace($searchPattern, $safeValue, $modifiedContent, -1, $count); + $afterLength = strlen($modifiedContent); + + if ($count > 0) { + $replacementCount += $count; + Log::info('占位符替换成功', [ + 'placeholder' => $placeholder, + 'value' => $value, + 'safe_value' => $safeValue, + 'replacement_count' => $count, + 'content_length_change' => $afterLength - $beforeLength + ]); + } else { + Log::warning('占位符未找到', [ + 'placeholder' => $placeholder, + 'search_pattern' => $searchPattern + ]); + } + } + + Log::info('所有占位符处理完成', [ + 'total_replacements' => $replacementCount, + 'processed_placeholders' => count($fillValues) + ]); + + return $modifiedContent; + } + + /** + * 清理XML中被格式化标签分割的占位符 + * @param string $xmlContent + * @return string + */ + private function cleanBrokenPlaceholdersInXml(string $xmlContent): string + { + $cleanedContent = $xmlContent; + + Log::info('开始清理分割的占位符'); + + // 多阶段清理策略:从简单到复杂 + + // 第一阶段:处理完整的双大括号分割情况 + $cleanedContent = $this->fixCompleteBracketSplits($cleanedContent); + + // 第二阶段:处理单个大括号分割情况 + $cleanedContent = $this->fixSingleBracketSplits($cleanedContent); + + // 第三阶段:通用清理,移除占位符内部的XML标签 + $cleanedContent = $this->generalPlaceholderCleanup($cleanedContent); + + // 第四阶段:最终验证和修复 + $cleanedContent = $this->finalPlaceholderValidation($cleanedContent); + + Log::info('占位符清理完成'); + + return $cleanedContent; + } + + /** + * 修复完整双大括号被分割的情况 + * @param string $content + * @return string + */ + private function fixCompleteBracketSplits(string $content): string + { + Log::info('阶段1:修复完整双大括号分割'); + + $patterns = [ + // 模式1: {{...内容}} + '/\{\{<\/w:t>.*?]*>([^<]*)\}\}/', + + // 模式2: {{...内容}} + '/\{\{<\/w:t><\/w:r>]*>.*?]*>([^<]*)\}\}/', + + // 模式3: 包含rPr格式标签的复杂嵌套 + '/\{\{<\/w:t><\/w:r>]*>.*?<\/w:rPr>]*>([^<]*)\}\}/' + ]; + + foreach ($patterns as $index => $pattern) { + $content = preg_replace_callback($pattern, function($matches) use ($index) { + $placeholderContent = $matches[1]; + $result = '{{' . $placeholderContent . '}}'; + + Log::info('修复完整双大括号分割', [ + 'pattern_index' => $index + 1, + 'original' => substr($matches[0], 0, 100) . '...', + 'fixed' => $result, + 'placeholder_content' => $placeholderContent + ]); + + return $result; + }, $content); + } + + return $content; + } + + /** + * 修复单个大括号被分割的情况 + * @param string $content + * @return string + */ + private function fixSingleBracketSplits(string $content): string + { + Log::info('阶段2:修复单个大括号分割'); + + // 处理左大括号被分割:{...{内容}} + $leftBracketPatterns = [ + // 标准模式:{...{内容}} + '/\{<\/w:t><\/w:r>]*>.*?]*>\{([^}]*)\}\}/', + + // 包含rPr的复杂模式 + '/\{<\/w:t><\/w:r>]*>.*?<\/w:rPr>]*>\{([^}]*)\}\}/', + + // 简单模式:{...{内容}} + '/\{<\/w:t>.*?]*>\{([^}]*)\}\}/' + ]; + + foreach ($leftBracketPatterns as $index => $pattern) { + $content = preg_replace_callback($pattern, function($matches) use ($index) { + $placeholderContent = $matches[1]; + $result = '{{' . $placeholderContent . '}}'; + + Log::info('修复左大括号分割', [ + 'pattern_index' => $index + 1, + 'original' => substr($matches[0], 0, 100) . '...', + 'fixed' => $result, + 'placeholder_content' => $placeholderContent + ]); + + return $result; + }, $content); + } + + // 处理右大括号被分割:{{内容}...} + $rightBracketPatterns = [ + // 标准模式:{{内容}...} + '/\{\{([^}]*)\}<\/w:t><\/w:r>]*>.*?]*>\}/', + + // 包含rPr的复杂模式 + '/\{\{([^}]*)\}<\/w:t><\/w:r>]*>.*?<\/w:rPr>]*>\}/', + + // 简单模式:{{内容}...} + '/\{\{([^}]*)\}<\/w:t>.*?]*>\}/' + ]; + + foreach ($rightBracketPatterns as $index => $pattern) { + $content = preg_replace_callback($pattern, function($matches) use ($index) { + $placeholderContent = $matches[1]; + $result = '{{' . $placeholderContent . '}}'; + + Log::info('修复右大括号分割', [ + 'pattern_index' => $index + 1, + 'original' => substr($matches[0], 0, 100) . '...', + 'fixed' => $result, + 'placeholder_content' => $placeholderContent + ]); + + return $result; + }, $content); + } + + return $content; + } + + /** + * 通用占位符清理:移除占位符内部的XML标签 + * @param string $content + * @return string + */ + private function generalPlaceholderCleanup(string $content): string + { + Log::info('阶段3:通用占位符清理'); + + // 匹配并清理占位符内部的XML标签 + $generalPattern = '/\{\{([^}]*?)<[^>]*?>([^}]*?)\}\}/'; + + $iterations = 0; + $maxIterations = 10; // 防止无限循环 + + do { + $beforeLength = strlen($content); + $iterations++; + + $content = preg_replace_callback($generalPattern, function($matches) { + // 移除XML标签,只保留纯文本 + $content = $matches[1] . $matches[2]; + $cleanContent = preg_replace('/<[^>]*?>/', '', $content); + $result = '{{' . $cleanContent . '}}'; + + Log::info('通用占位符清理', [ + 'original' => substr($matches[0], 0, 50) . '...', + 'cleaned' => $result, + 'content' => $cleanContent + ]); + + return $result; + }, $content); + + $afterLength = strlen($content); + + Log::info('通用清理迭代', [ + 'iteration' => $iterations, + 'length_change' => $afterLength - $beforeLength, + 'has_more_matches' => preg_match($generalPattern, $content) + ]); + + } while ($beforeLength !== $afterLength && + preg_match($generalPattern, $content) && + $iterations < $maxIterations); + + return $content; + } + + /** + * 最终占位符验证和修复 + * @param string $content + * @return string + */ + private function finalPlaceholderValidation(string $content): string + { + Log::info('阶段4:最终验证和修复'); + + // 查找所有可能的占位符模式 + if (preg_match_all('/\{[^{}]*\}/', $content, $matches)) { + foreach ($matches[0] as $match) { + // 检查是否为不完整的占位符(只有一个大括号) + if (preg_match('/^\{[^{}]+\}$/', $match)) { + // 查看前后文是否有对应的大括号 + $singleBracketPattern = '/' . preg_quote($match, '/') . '/'; + + // 尝试修复为双大括号 + $possibleFix = '{' . $match . '}'; + + Log::info('发现可能的不完整占位符', [ + 'found' => $match, + 'possible_fix' => $possibleFix + ]); + } + } + } + + // 查找并统计最终的占位符数量 + $finalPlaceholders = []; + if (preg_match_all('/\{\{([^}]+)\}\}/', $content, $matches)) { + $finalPlaceholders = $matches[1]; + } + + Log::info('最终占位符统计', [ + 'count' => count($finalPlaceholders), + 'placeholders' => $finalPlaceholders + ]); + + return $content; + } + + /** + * 递归添加目录到ZIP文件 + * @param \ZipArchive $zip ZIP文件对象 + * @param string $sourcePath 源目录路径 + * @param string $relativePath ZIP内的相对路径 + * @return void + */ + private function addDirectoryToZip(\ZipArchive $zip, string $sourcePath, string $relativePath): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($sourcePath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + $filePath = $file->getRealPath(); + $relativeName = $relativePath . substr($filePath, strlen($sourcePath) + 1); + + // 在Windows系统中统一使用正斜杠 + $relativeName = str_replace('\\', '/', $relativeName); + + if ($file->isDir()) { + // 添加目录 + $zip->addEmptyDir($relativeName); + } else { + // 添加文件 + $zip->addFile($filePath, $relativeName); + } + } + } + + /** + * 递归删除目录 + * @param string $dir 要删除的目录路径 + * @return void + */ + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $filePath = $dir . DIRECTORY_SEPARATOR . $file; + if (is_dir($filePath)) { + $this->removeDirectory($filePath); + } else { + unlink($filePath); + } + } + rmdir($dir); + } + + /** + * 智能处理占位符,根据类型使用不同的处理方法 + * @param TemplateProcessor $templateProcessor + * @param array $fillValues + * @param array $placeholderConfig + * @return void + */ + private function processPlaceholders(TemplateProcessor $templateProcessor, array $fillValues, array $placeholderConfig) + { + // 如果没有配置信息,使用简单的文本替换 + if (empty($placeholderConfig)) { + foreach ($fillValues as $placeholder => $value) { + $templateProcessor->setValue($placeholder, $value); + } + return; + } + + $placeholderConfig = is_string($placeholderConfig) ? json_decode($placeholderConfig, true) : $placeholderConfig; + + foreach ($fillValues as $placeholder => $value) { + $config = $placeholderConfig[$placeholder] ?? []; + $fieldType = $config['field_type'] ?? 'text'; + $dataType = $config['data_type'] ?? 'user_input'; + + Log::info('处理占位符', [ + 'placeholder' => $placeholder, + 'value' => $value, + 'field_type' => $fieldType, + 'data_type' => $dataType + ]); + + try { + // 根据字段类型选择处理方式 + if ($fieldType === 'image' || $dataType === 'sign_img' || $dataType === 'signature') { + // 处理图片类型 + $this->setImageValue($templateProcessor, $placeholder, $value); + } else { + // 处理文本类型 + $templateProcessor->setValue($placeholder, $value); + } + } catch (\Exception $e) { + Log::error('占位符处理失败', [ + 'placeholder' => $placeholder, + 'value' => $value, + 'error' => $e->getMessage() + ]); + // 如果图片处理失败,尝试作为文本处理 + $templateProcessor->setValue($placeholder, $value); + } + } + } + + /** + * 设置图片占位符的值 + * @param TemplateProcessor $templateProcessor + * @param string $placeholder + * @param mixed $value + * @return void + */ + private function setImageValue(TemplateProcessor $templateProcessor, string $placeholder, $value) + { + if (empty($value)) { + // 如果值为空,设置为空文本 + $templateProcessor->setValue($placeholder, ''); + return; + } + + $imagePath = null; + + try { + // 判断图片数据类型并处理 + if (is_string($value)) { + if (str_starts_with($value, 'data:image/')) { + // 处理base64图片数据 + $imagePath = $this->saveBase64Image($value); + } elseif (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) { + // 处理网络图片URL + $imagePath = $this->downloadImage($value); + } elseif (file_exists($value)) { + // 处理本地文件路径 + $imagePath = $value; + } elseif (file_exists(public_path() . '/' . ltrim($value, '/'))) { + // 处理相对路径 + $imagePath = public_path() . '/' . ltrim($value, '/'); + } + } + + if ($imagePath && file_exists($imagePath)) { + // 验证图片文件 + $imageInfo = getimagesize($imagePath); + if ($imageInfo === false) { + throw new \Exception('无效的图片文件'); + } + + // 设置图片,限制尺寸 + $templateProcessor->setImageValue($placeholder, [ + 'path' => $imagePath, + 'width' => 100, // 可以根据需要调整 + 'height' => 100, + 'ratio' => true + ]); + + Log::info('图片占位符设置成功', [ + 'placeholder' => $placeholder, + 'image_path' => $imagePath, + 'image_size' => $imageInfo + ]); + + // 如果是临时文件,标记稍后删除 + if (str_contains($imagePath, sys_get_temp_dir())) { + register_shutdown_function(function() use ($imagePath) { + if (file_exists($imagePath)) { + @unlink($imagePath); + } + }); + } + } else { + // 如果无法处理为图片,使用文本替换 + $templateProcessor->setValue($placeholder, $value); + Log::warning('图片处理失败,使用文本替换', [ + 'placeholder' => $placeholder, + 'value' => $value + ]); + } + } catch (\Exception $e) { + Log::error('图片设置失败', [ + 'placeholder' => $placeholder, + 'value' => $value, + 'error' => $e->getMessage() + ]); + // 如果图片处理失败,使用文本替换 + $templateProcessor->setValue($placeholder, $value); + } + } + + /** + * 保存base64图片数据到临时文件 + * @param string $base64Data + * @return string|null + */ + private function saveBase64Image(string $base64Data): ?string + { + try { + // 解析base64数据 + if (preg_match('/^data:image\/(\w+);base64,(.+)$/', $base64Data, $matches)) { + $imageType = $matches[1]; + $imageData = base64_decode($matches[2]); + + if ($imageData === false) { + throw new \Exception('base64解码失败'); + } + + // 创建临时文件 + $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'niucloud_images'; + if (!is_dir($tempDir)) { + mkdir($tempDir, 0755, true); + } + + $fileName = uniqid('img_') . '.' . $imageType; + $filePath = $tempDir . DIRECTORY_SEPARATOR . $fileName; + + if (file_put_contents($filePath, $imageData) === false) { + throw new \Exception('保存图片文件失败'); + } + + return $filePath; + } + } catch (\Exception $e) { + Log::error('保存base64图片失败:' . $e->getMessage()); + } + + return null; + } + + /** + * 下载网络图片到临时文件 + * @param string $url + * @return string|null + */ + private function downloadImage(string $url): ?string + { + try { + // 创建临时文件 + $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'niucloud_images'; + if (!is_dir($tempDir)) { + mkdir($tempDir, 0755, true); + } + + $pathInfo = pathinfo(parse_url($url, PHP_URL_PATH)); + $extension = $pathInfo['extension'] ?? 'jpg'; + $fileName = uniqid('img_') . '.' . $extension; + $filePath = $tempDir . DIRECTORY_SEPARATOR . $fileName; + + // 下载图片 + $context = stream_context_create([ + 'http' => [ + 'timeout' => 30, + 'user_agent' => 'Mozilla/5.0 (compatible; Document Generator)', + ] + ]); + + $imageData = file_get_contents($url, false, $context); + if ($imageData === false) { + throw new \Exception('下载图片失败'); + } + + if (file_put_contents($filePath, $imageData) === false) { + throw new \Exception('保存图片文件失败'); + } + + // 验证是否为有效图片 + if (getimagesize($filePath) === false) { + unlink($filePath); + throw new \Exception('下载的文件不是有效图片'); + } + + return $filePath; + } catch (\Exception $e) { + Log::error('下载图片失败:' . $e->getMessage(), ['url' => $url]); + } + + return null; + } + + /** + * 处理Word文档中的URL图片,将URL转换为嵌入的图片附件 + * @param string $documentPath Word文档路径 + * @param array $fillValues 填充值数组(用于识别哪些是图片URL) + * @return bool + */ + private function processUrlImagesInDocument(string $documentPath, array $fillValues): bool + { + try { + Log::info('开始处理Word文档中的URL图片', [ + 'document_path' => $documentPath, + 'fill_values_count' => count($fillValues) + ]); + + // 创建临时工作目录 + $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'word_url_processing_' . uniqid(); + if (!mkdir($tempDir, 0755, true)) { + throw new \Exception('无法创建临时工作目录'); + } + + try { + // 1. 解压Word文档到临时目录 + $zip = new \ZipArchive(); + $result = $zip->open($documentPath); + + if ($result !== TRUE) { + throw new \Exception('无法打开Word文档,错误代码:' . $result); + } + + if (!$zip->extractTo($tempDir)) { + $zip->close(); + throw new \Exception('无法解压Word文档'); + } + $zip->close(); + + // 2. 读取document.xml文件 + $documentXmlPath = $tempDir . DIRECTORY_SEPARATOR . 'word' . DIRECTORY_SEPARATOR . 'document.xml'; + if (!file_exists($documentXmlPath)) { + throw new \Exception('Word文档缺少document.xml文件'); + } + + $xmlContent = file_get_contents($documentXmlPath); + if ($xmlContent === false) { + throw new \Exception('无法读取document.xml文件内容'); + } + + // 3. 查找并处理URL图片 + $urlImageMap = $this->findUrlImagesInFillValues($fillValues); + + if (empty($urlImageMap)) { + Log::info('未找到需要处理的URL图片'); + return true; + } + + // 4. 下载图片并创建关系文件 + $imageRelations = $this->downloadAndCreateImageRelations($urlImageMap, $tempDir); + + if (empty($imageRelations)) { + Log::warning('没有成功下载任何图片'); + return true; + } + + // 5. 更新document.xml中的URL为图片引用 + $modifiedXmlContent = $this->replaceUrlsWithImageReferences($xmlContent, $imageRelations); + + // 6. 更新关系文件 + $this->updateDocumentRelations($tempDir, $imageRelations); + + // 7. 写回修改后的document.xml + if (file_put_contents($documentXmlPath, $modifiedXmlContent) === false) { + throw new \Exception('无法写入修改后的document.xml'); + } + + // 8. 重新打包Word文档 + $newZip = new \ZipArchive(); + $result = $newZip->open($documentPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + + if ($result !== TRUE) { + throw new \Exception('无法重新打开Word文件,错误代码:' . $result); + } + + // 递归添加所有文件到ZIP + $this->addDirectoryToZip($newZip, $tempDir, ''); + + if (!$newZip->close()) { + throw new \Exception('无法保存修改后的Word文件'); + } + + Log::info('URL图片处理完成', [ + 'processed_images' => count($imageRelations), + 'document_size' => filesize($documentPath) + ]); + + return true; + + } finally { + // 清理临时目录 + $this->removeDirectory($tempDir); + } + + } catch (\Exception $e) { + Log::error('处理Word文档URL图片失败', [ + 'document_path' => $documentPath, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + // 即使失败也不影响主流程 + return false; + } + } + + /** + * 从填充值中查找URL图片 + * @param array $fillValues + * @return array + */ + private function findUrlImagesInFillValues(array $fillValues): array + { + $urlImages = []; + + foreach ($fillValues as $placeholder => $value) { + if (is_string($value)) { + // 检查是否为HTTP/HTTPS URL + if (preg_match('/^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|bmp)(\?.*)?$/i', $value)) { + $urlImages[$placeholder] = $value; + Log::info('发现URL图片', [ + 'placeholder' => $placeholder, + 'url' => $value + ]); + } + } + } + + return $urlImages; + } + + /** + * 下载图片并创建图片关系 + * @param array $urlImageMap + * @param string $tempDir + * @return array + */ + private function downloadAndCreateImageRelations(array $urlImageMap, string $tempDir): array + { + $imageRelations = []; + $relationId = 1000; // 起始关系ID,避免与现有关系冲突 + + // 确保media目录存在 + $mediaDir = $tempDir . DIRECTORY_SEPARATOR . 'word' . DIRECTORY_SEPARATOR . 'media'; + if (!is_dir($mediaDir)) { + mkdir($mediaDir, 0755, true); + } + + foreach ($urlImageMap as $placeholder => $url) { + try { + // 下载图片 + $imageData = $this->downloadImageData($url); + if (!$imageData) { + continue; + } + + // 确定文件扩展名 + $pathInfo = pathinfo(parse_url($url, PHP_URL_PATH)); + $extension = strtolower($pathInfo['extension'] ?? 'jpg'); + + // 验证图片数据 + $tempImagePath = tempnam(sys_get_temp_dir(), 'img_validate_'); + file_put_contents($tempImagePath, $imageData); + $imageInfo = getimagesize($tempImagePath); + unlink($tempImagePath); + + if ($imageInfo === false) { + Log::warning('下载的文件不是有效图片', ['url' => $url]); + continue; + } + + // 生成文件名和关系ID + $fileName = 'image' . $relationId . '.' . $extension; + $filePath = $mediaDir . DIRECTORY_SEPARATOR . $fileName; + $relationIdStr = 'rId' . $relationId; + + // 保存图片文件 + if (file_put_contents($filePath, $imageData) === false) { + Log::error('保存图片文件失败', ['file_path' => $filePath]); + continue; + } + + // 记录图片关系信息 + $imageRelations[$placeholder] = [ + 'url' => $url, + 'relation_id' => $relationIdStr, + 'file_name' => $fileName, + 'file_path' => $filePath, + 'width' => $imageInfo[0], + 'height' => $imageInfo[1], + 'mime_type' => $imageInfo['mime'] + ]; + + $relationId++; + + Log::info('图片下载成功', [ + 'placeholder' => $placeholder, + 'url' => $url, + 'file_name' => $fileName, + 'size' => $imageInfo[0] . 'x' . $imageInfo[1] + ]); + + } catch (\Exception $e) { + Log::error('处理图片失败', [ + 'placeholder' => $placeholder, + 'url' => $url, + 'error' => $e->getMessage() + ]); + } + } + + return $imageRelations; + } + + /** + * 下载图片数据 + * @param string $url + * @return string|false + */ + private function downloadImageData(string $url) + { + try { + $context = stream_context_create([ + 'http' => [ + 'timeout' => 30, + 'user_agent' => 'Mozilla/5.0 (compatible; Document Generator)', + 'follow_location' => true, + 'max_redirects' => 3 + ] + ]); + + return file_get_contents($url, false, $context); + } catch (\Exception $e) { + Log::error('下载图片数据失败:' . $e->getMessage(), ['url' => $url]); + return false; + } + } + + /** + * 将XML中的URL替换为图片引用 + * @param string $xmlContent + * @param array $imageRelations + * @return string + */ + private function replaceUrlsWithImageReferences(string $xmlContent, array $imageRelations): string + { + $modifiedContent = $xmlContent; + + foreach ($imageRelations as $placeholder => $relation) { + $url = $relation['url']; + + // 构建图片引用XML + $imageXml = $this->generateImageXml($relation); + + // 替换URL为图片引用 + $modifiedContent = str_replace($url, $imageXml, $modifiedContent); + + Log::info('URL替换为图片引用', [ + 'placeholder' => $placeholder, + 'url' => $url, + 'relation_id' => $relation['relation_id'] + ]); + } + + return $modifiedContent; + } + + /** + * 生成图片引用XML + * @param array $relation + * @return string + */ + private function generateImageXml(array $relation): string + { + // 强制限制最大尺寸 + $maxWidth = 40; // 最大宽度(像素) + $maxHeight = 30; // 最大高度(像素) + + $originalWidth = $relation['width']; + $originalHeight = $relation['height']; + + Log::info('图片原始尺寸', [ + 'original_width' => $originalWidth, + 'original_height' => $originalHeight, + 'max_width' => $maxWidth, + 'max_height' => $maxHeight + ]); + + // 计算缩放比例 - 如果图片超过最大尺寸就缩放,否则使用较小的固定尺寸 + $displayWidth = $maxWidth; + $displayHeight = $maxHeight; + + if ($originalWidth > 0 && $originalHeight > 0) { + // 计算保持宽高比的缩放 + $widthRatio = $maxWidth / $originalWidth; + $heightRatio = $maxHeight / $originalHeight; + $ratio = min($widthRatio, $heightRatio); // 移除1的限制,允许缩放 + + $displayWidth = (int)($originalWidth * $ratio); + $displayHeight = (int)($originalHeight * $ratio); + + // 确保不超过最大尺寸 + $displayWidth = min($displayWidth, $maxWidth); + $displayHeight = min($displayHeight, $maxHeight); + } + + Log::info('图片显示尺寸', [ + 'display_width' => $displayWidth, + 'display_height' => $displayHeight, + 'ratio' => isset($ratio) ? $ratio : 'fixed' + ]); + + // Word中的尺寸单位转换(像素转EMU) + $emuWidth = $displayWidth * 9525; // 1像素 = 9525 EMU + $emuHeight = $displayHeight * 9525; + + $relationId = $relation['relation_id']; + + return ' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +'; + } + + /** + * 更新文档关系文件 + * @param string $tempDir + * @param array $imageRelations + * @return void + */ + private function updateDocumentRelations(string $tempDir, array $imageRelations): void + { + try { + $relsPath = $tempDir . DIRECTORY_SEPARATOR . 'word' . DIRECTORY_SEPARATOR . '_rels' . DIRECTORY_SEPARATOR . 'document.xml.rels'; + + // 如果关系文件不存在,创建基础关系文件 + if (!file_exists($relsPath)) { + $relsDir = dirname($relsPath); + if (!is_dir($relsDir)) { + mkdir($relsDir, 0755, true); + } + + $baseRelsContent = ' + +'; + file_put_contents($relsPath, $baseRelsContent); + } + + // 读取现有关系文件 + $relsContent = file_get_contents($relsPath); + if ($relsContent === false) { + throw new \Exception('无法读取关系文件'); + } + + // 解析XML + $dom = new \DOMDocument('1.0', 'UTF-8'); + $dom->preserveWhiteSpace = false; + $dom->formatOutput = true; + + if (!$dom->loadXML($relsContent)) { + throw new \Exception('无法解析关系文件XML'); + } + + $relationshipsElement = $dom->documentElement; + + // 添加图片关系 + foreach ($imageRelations as $relation) { + $relationshipElement = $dom->createElement('Relationship'); + $relationshipElement->setAttribute('Id', $relation['relation_id']); + $relationshipElement->setAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'); + $relationshipElement->setAttribute('Target', 'media/' . $relation['file_name']); + + $relationshipsElement->appendChild($relationshipElement); + } + + // 写回关系文件 + if (file_put_contents($relsPath, $dom->saveXML()) === false) { + throw new \Exception('无法保存关系文件'); + } + + Log::info('文档关系文件更新成功', [ + 'relations_added' => count($imageRelations), + 'rels_path' => $relsPath + ]); + + } catch (\Exception $e) { + Log::error('更新文档关系文件失败', [ + 'error' => $e->getMessage(), + 'temp_dir' => $tempDir + ]); + throw $e; + } + } } \ No newline at end of file diff --git a/niucloud/app/service/api/apiService/ContractSignFormService.php b/niucloud/app/service/api/apiService/ContractSignFormService.php index 6797ab41..05ad9bc6 100644 --- a/niucloud/app/service/api/apiService/ContractSignFormService.php +++ b/niucloud/app/service/api/apiService/ContractSignFormService.php @@ -636,14 +636,43 @@ class ContractSignFormService extends BaseApiService private function getSignSpecificFormFields($contract_sign_id, $student) { try { - // 从document_data_source_config表获取该签署关系专属的字段配置 + // 先获取签署记录以得到合同ID + $sign_record = Db::table('school_contract_sign') + ->where('id', $contract_sign_id) + ->find(); + + if (!$sign_record) { + Log::error('签署记录不存在', ['contract_sign_id' => $contract_sign_id]); + return []; + } + + $contract_id = $sign_record['contract_id']; + + // 优先获取该签署关系专属的字段配置 $configs = Db::table('school_document_data_source_config') ->where('contract_sign_id', $contract_sign_id) ->select() ->toArray(); + // 如果该签署记录没有专属配置,则获取合同模板的基础配置 if (empty($configs)) { - Log::warning('该签署记录无字段配置', ['contract_sign_id' => $contract_sign_id]); + Log::info('该签署记录无专属配置,使用合同模板基础配置', [ + 'contract_sign_id' => $contract_sign_id, + 'contract_id' => $contract_id + ]); + + $configs = Db::table('school_document_data_source_config') + ->where('contract_id', $contract_id) + ->whereNull('contract_sign_id') + ->select() + ->toArray(); + } + + if (empty($configs)) { + Log::warning('该合同无字段配置', [ + 'contract_sign_id' => $contract_sign_id, + 'contract_id' => $contract_id + ]); return []; } @@ -736,8 +765,9 @@ class ContractSignFormService extends BaseApiService switch ($table_name) { case 'school_student': + case 'students': // 兼容配置中的简化表名 // 学员表:使用CoreFieldMappingService进行字段映射和转义 - $service = new CoreFieldMappingService($config['table_name'], ['id' => $student['id']]); + $service = new CoreFieldMappingService('school_student', ['id' => $student['id']]); // 配置枚举映射 $service->setFieldEnums([ diff --git a/uniapp/pages-coach/coach/schedule/schedule_table.vue b/uniapp/pages-coach/coach/schedule/schedule_table.vue index f4662dbd..5aaac5a6 100644 --- a/uniapp/pages-coach/coach/schedule/schedule_table.vue +++ b/uniapp/pages-coach/coach/schedule/schedule_table.vue @@ -56,111 +56,45 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ date.weekName }} - {{ date.dateStr }} - 共{{ date.courseCount }}节课 - + + + + + + + + + + + - - + + + {{ date.weekName }} + {{ date.dateStr }} + 共{{ date.courseCount }}节课 + + + + + - - - + + @@ -554,11 +511,10 @@ export default { selectedClasses: [], // 滚动相关 - scrollTop: 0, scrollTimer: null, // 滚动防抖定时器 - // 表格配置 - tableWidth: 1500, // 表格总宽度,确保7天都能显示 (7*180+120=1380rpx) + // 表格配置 - 统一滚动后不包含左侧列宽度 + tableWidth: 1260, // 7天内容宽度 (7*180=1260rpx),左侧120rpx在容器内部 // 时间段配置(动态生成,支持场地时间限制) timeSlots: [], @@ -591,6 +547,12 @@ export default { selectedScheduleId: null, showScheduleDetail: false, + // 平台识别 + isH5: false, + + // 滚动视图高度 + scrollViewHeight: 0, + // 筛选参数 filterParams: { start_date: '', @@ -644,12 +606,20 @@ export default { }, mounted() { + // 检测平台 + // #ifdef H5 + this.isH5 = true + // #endif + this.initCurrentWeek() this.initTimeSlots() // 初始化响应式布局 this.handleResize() + // 计算scroll-view高度 + this.calculateScrollViewHeight() + // 先加载筛选选项,然后加载课程安排列表 this.loadFilterOptions().then(() => { this.loadScheduleList() @@ -679,6 +649,72 @@ export default { }, methods: { + // 时间标准化函数 - 统一时间格式为 HH:mm + normalizeTime(timeStr) { + if (!timeStr) return ''; + + // 移除空格并转换为字符串 + const cleanTime = String(timeStr).trim(); + + // 如果已经是 HH:mm 格式,直接返回 + if (/^\d{2}:\d{2}$/.test(cleanTime)) { + return cleanTime; + } + + // 如果是 H:mm 格式,补零 + if (/^\d{1}:\d{2}$/.test(cleanTime)) { + return '0' + cleanTime; + } + + // 如果是 HH:m 格式,补零 + if (/^\d{2}:\d{1}$/.test(cleanTime)) { + return cleanTime.slice(0, 3) + '0' + cleanTime.slice(3); + } + + // 如果是 H:m 格式,都补零 + if (/^\d{1}:\d{1}$/.test(cleanTime)) { + const [hour, minute] = cleanTime.split(':'); + return `0${hour}:0${minute}`; + } + + // 其他格式尝试解析 + try { + const [hour, minute] = cleanTime.split(':'); + const h = parseInt(hour).toString().padStart(2, '0'); + const m = parseInt(minute || 0).toString().padStart(2, '0'); + return `${h}:${m}`; + } catch (e) { + console.warn('时间格式解析失败:', timeStr); + return cleanTime; + } + }, + + // 时间匹配函数 - 支持时间段内匹配 + isTimeInSlot(courseTime, slotTime) { + const normalizedCourseTime = this.normalizeTime(courseTime); + const normalizedSlotTime = this.normalizeTime(slotTime); + + if (!normalizedCourseTime || !normalizedSlotTime) return false; + + // 精确匹配 + if (normalizedCourseTime === normalizedSlotTime) return true; + + // 查找时间段匹配 - 课程开始时间在这个时间段内 + const slotIndex = this.timeSlots.findIndex(slot => + this.normalizeTime(slot.time) === normalizedSlotTime + ); + + if (slotIndex >= 0 && slotIndex < this.timeSlots.length - 1) { + const currentSlotTime = this.normalizeTime(this.timeSlots[slotIndex].time); + const nextSlotTime = this.normalizeTime(this.timeSlots[slotIndex + 1].time); + + // 检查课程时间是否在当前时间段内(包含开始时间,不包含结束时间) + return normalizedCourseTime >= currentSlotTime && normalizedCourseTime < nextSlotTime; + } + + return false; + }, + // 初始化当前周 initCurrentWeek() { const today = new Date() @@ -857,8 +893,8 @@ export default { getCoursesByTimeAndDate(time, date) { const matchedCourses = this.courses.filter(course => { if (course.date !== date) return false - // 只在课程开始时间显示课程,不在后续时间段重复显示 - return course.time === time + // 使用改进的时间匹配算法 + return this.isTimeInSlot(course.time, time) }) return matchedCourses }, @@ -933,8 +969,7 @@ export default { this.filterParams.venue_id = ''; this.filterParams.class_id = ''; - // 如果切换了模式,重置滚动位置 - this.scrollTop = 0; + // 模式切换完成 // 切换模式后重新加载课程安排 this.loadScheduleList(); @@ -956,9 +991,8 @@ export default { this.applyFilters() this.closeFilterModal() - // 重新加载数据后,重置滚动位置 + // 重新加载数据 await this.loadScheduleList() - this.scrollTop = 0 // 如果筛选改变了时间段,需要重新生成时间列 if (this.selectedTimeRange !== '' || this.selectedVenueId !== null) { @@ -1146,34 +1180,35 @@ export default { if (res.code === 1) { // 转换数据格式 - this.courses = res.data.list.map(item => ({ - id: item.id, - date: item.course_date, - time: item.time_info?.start_time || item.time_slot?.split('-')[0], - courseName: item.course_name || '未命名课程', - students: `已报名${item.enrolled_count || 0}人`, - teacher: item.coach_name || '待分配', - teacher_id: item.coach_id, // 保存教练ID - status: item.status_text || '待定', - type: this.getCourseType(item), - venue: item.venue_name || '待分配', - venue_id: item.venue_id, // 保存场地ID - campus_name: item.campus_name || '', // 添加校区名称 - class_id: item.class_id, // 保存班级ID - class_name: item.class_name || '', // 添加班级名称 - duration: item.time_info?.duration || 60, - time_slot: item.time_slot, - raw: item, // 保存原始数据 - })) + this.courses = res.data.list.map(item => { + // 提取并标准化时间 + const rawTime = item.time_info?.start_time || item.time_slot?.split('-')[0] || ''; + const normalizedTime = this.normalizeTime(rawTime); + + return { + id: item.id, + date: item.course_date, + time: normalizedTime, // 使用标准化后的时间 + courseName: item.course_name || '未命名课程', + students: `已报名${item.enrolled_count || 0}人`, + teacher: item.coach_name || '待分配', + teacher_id: item.coach_id, // 保存教练ID + status: item.status_text || '待定', + type: this.getCourseType(item), + venue: item.venue_name || '待分配', + venue_id: item.venue_id, // 保存场地ID + campus_name: item.campus_name || '', // 添加校区名称 + class_id: item.class_id, // 保存班级ID + class_name: item.class_name || '', // 添加班级名称 + duration: item.time_info?.duration || 60, + time_slot: item.time_slot, + raw: item, // 保存原始数据 + } + }) // 根据当前视图模式动态更新左侧列数据 this.updateLeftColumnData(); - // 同步左右高度 - this.$nextTick(() => { - this.syncRowHeights(); - }); - } else { uni.showToast({ title: res.msg || '加载课程安排列表失败', @@ -1266,61 +1301,18 @@ export default { }, - // 同步左右行高度 - syncRowHeights() { - this.$nextTick(() => { - try { - let itemCount = 0; - - // 根据当前筛选模式确定行数 - if (this.activeFilter === 'time' || this.activeFilter === '') { - itemCount = this.timeSlots.length; - } else if (this.activeFilter === 'teacher') { - itemCount = this.teacherOptions.length; - } else if (this.activeFilter === 'classroom') { - itemCount = this.venues.length; - } else if (this.activeFilter === 'class') { - itemCount = this.classOptions.length; - } - - // 同步每一行的高度 - for (let i = 0; i < itemCount; i++) { - const scheduleRow = this.$refs[`scheduleRow_${i}`]; - const frozenCell = this.$refs[`frozenCell_${i}`]; - - if (scheduleRow && scheduleRow[0] && frozenCell && frozenCell[0]) { - // 获取右侧行的实际高度 - const rightRowHeight = scheduleRow[0].$el ? - scheduleRow[0].$el.offsetHeight : - scheduleRow[0].offsetHeight; - - // 设置左侧冻结单元格的高度 - if (frozenCell[0].$el) { - frozenCell[0].$el.style.minHeight = rightRowHeight + 'px'; - } else if (frozenCell[0].style) { - frozenCell[0].style.minHeight = rightRowHeight + 'px'; - } - } - } - } catch (error) { - console.warn('高度同步失败:', error); - } - }); - }, - - // 滚动事件处理函数 - 优化垂直滚动同步 + // 滚动事件处理函数 - 简化版本 onScroll(e) { - // 使用防抖优化滚动同步性能 + // 统一滚动区域后不需要同步滚动位置 + // 只保留防抖逻辑以优化性能 if (this.scrollTimer) { clearTimeout(this.scrollTimer) } this.scrollTimer = setTimeout(() => { - // 只需要同步垂直滚动位置给左侧时间列 - if (e.detail.scrollTop !== undefined && e.detail.scrollTop !== this.scrollTop) { - this.scrollTop = e.detail.scrollTop - } - }, 16) // 约60fps的更新频率 + // 可以在这里添加其他滚动相关的处理逻辑 + // 例如懒加载、滚动到顶部/底部的处理等 + }, 16) }, // 单元格点击 @@ -1523,6 +1515,36 @@ export default { // 暂时只显示提示信息,具体API调用可以后续实现 }, + // 计算scroll-view高度 + calculateScrollViewHeight() { + uni.getSystemInfo({ + success: (res) => { + // 获取屏幕高度 + let screenHeight = res.screenHeight || res.windowHeight + + // 计算其他元素占用的高度 + // 筛选区域 + 日期导航 + 统计信息 + 状态栏等 + let otherHeight = 0 + + // #ifdef H5 + otherHeight = 200 // H5端大约200px + // #endif + + // #ifdef MP-WEIXIN + otherHeight = 160 // 小程序端大约160px (rpx转换) + // #endif + + // 设置scroll-view高度 + this.scrollViewHeight = screenHeight - otherHeight + + // 最小高度限制 + if (this.scrollViewHeight < 300) { + this.scrollViewHeight = 300 + } + } + }) + }, + // 响应式调整 handleResize() { // 根据窗口宽度调整表格尺寸 @@ -1545,12 +1567,15 @@ export default { // #endif if (width <= 375) { - this.tableWidth = 1220 // 7*160+100=1220rpx + this.tableWidth = 1120 // 7*160=1120rpx (小屏幕每列160rpx) } else if (width <= 768) { - this.tableWidth = 1400 + this.tableWidth = 1260 // 7*180=1260rpx } else { - this.tableWidth = 1500 + this.tableWidth = 1260 // 7*180=1260rpx } + + // 重新计算scroll-view高度 + this.calculateScrollViewHeight() }, // 初始化模拟数据 @@ -1666,24 +1691,32 @@ export default { .schedule-main { flex: 1; position: relative; - display: flex; overflow: hidden; + height: 0; /* 配合flex: 1 确保高度计算正确 */ } -// 左侧冻结列 -.frozen-column { - width: 120rpx; - position: relative; - z-index: 3; - background-color: #292929; +// 统一滚动区域内部容器 +.schedule-container-inner { display: flex; flex-direction: column; - box-shadow: 4rpx 0 8rpx rgba(0, 0, 0, 0.1); - flex-shrink: 0; + min-width: 1380rpx; /* 左列120rpx + 7天 * 180rpx = 1380rpx */ +} + +// 表头行 +.schedule-header-row { + display: flex; + background: #434544; + border-bottom: 2px solid #29d3b4; + position: sticky; + top: 0; + z-index: 10; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3); } +// 左上角标题单元格 .time-header-cell { width: 120rpx; + min-width: 120rpx; min-height: 120rpx; padding: 20rpx 10rpx; color: #29d3b4; @@ -1691,75 +1724,13 @@ export default { font-weight: 500; text-align: center; border-right: 1px solid #555; - border-bottom: 2px solid #29d3b4; background-color: #434544; display: flex; align-items: center; justify-content: center; word-wrap: break-word; overflow-wrap: break-word; -} - -.frozen-content-scroll { - flex: 1; - overflow: hidden; - /* 优化滚动性能 */ - -webkit-overflow-scrolling: touch; - scroll-behavior: auto; - overscroll-behavior: none; -} - -.frozen-content { - width: 100%; -} - -.frozen-cell { - width: 120rpx; - min-height: 120rpx; - padding: 20rpx 10rpx; - color: #999; - font-size: 24rpx; - text-align: center; - border-right: 1px solid #434544; - border-bottom: 1px solid #434544; - background: #3a3a3a; - display: flex; - align-items: center; - justify-content: center; - word-wrap: break-word; - overflow-wrap: break-word; - - &.time-unavailable { - background: #2a2a2a; - color: #555; - opacity: 0.5; - } -} - -// 右侧内容区域 -.schedule-content-area { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -// 合并滚动区域内部容器 -.schedule-container-inner { - display: flex; - flex-direction: column; - min-width: 1260rpx; /* 7天 * 180rpx = 1260rpx */ -} - -.date-header-container { - display: flex; - background: #434544; - border-bottom: 2px solid #29d3b4; - position: sticky; - top: 0; - z-index: 10; - /* 确保完全覆盖下方内容 */ - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3); + flex-shrink: 0; } .date-header-cell { @@ -1796,16 +1767,23 @@ export default { // 内容滚动区域 .schedule-scroll { flex: 1; - overflow: scroll; + height: 100%; + width: 100%; + min-height: 0; /* 关键:允许flex子项收缩 */ /* 优化滚动性能 */ -webkit-overflow-scrolling: touch; scroll-behavior: auto; overscroll-behavior: none; + + /* 小程序端专用优化 */ + // #ifdef MP-WEIXIN + scroll-behavior: smooth; + // #endif } .schedule-grid { width: 100%; - min-width: 1260rpx; /* 7天 * 180rpx = 1260rpx */ + min-width: 1380rpx; /* 左列120rpx + 7天 * 180rpx = 1380rpx */ } // 行布局 @@ -1815,6 +1793,32 @@ export default { border-bottom: 1px solid #434544; } +// 左侧列单元格 (时间/教练/教室/班级) +.left-column-cell { + width: 120rpx; + min-width: 120rpx; + min-height: 120rpx; + padding: 20rpx 10rpx; + color: #999; + font-size: 24rpx; + text-align: center; + border-right: 1px solid #434544; + border-bottom: 1px solid #434544; + background: #3a3a3a; + display: flex; + align-items: center; + justify-content: center; + word-wrap: break-word; + overflow-wrap: break-word; + flex-shrink: 0; + + &.time-unavailable { + background: #2a2a2a; + color: #555; + opacity: 0.5; + } +} + .course-cell { width: 180rpx; min-width: 180rpx; @@ -1849,12 +1853,15 @@ export default { // 响应式适配 @media screen and (max-width: 375px) { - .time-column-fixed { + .left-column-cell { width: 100rpx; + min-width: 100rpx; + font-size: 22rpx; } - .time-header-cell, .time-cell { + .time-header-cell { width: 100rpx; + min-width: 100rpx; font-size: 22rpx; } diff --git a/uniapp/pages/common/home/index.vue b/uniapp/pages/common/home/index.vue index a65f0292..e66df791 100644 --- a/uniapp/pages/common/home/index.vue +++ b/uniapp/pages/common/home/index.vue @@ -106,12 +106,12 @@ path: '/pages/common/dashboard/webview', params: { type: 'campus_data' } }, - { - title: '报销管理', - icon: 'wallet-filled', - path: '/pages-market/reimbursement/list', - params: { type: 'reimbursement' } - } + // { + // title: '报销管理', + // icon: 'wallet-filled', + // path: '/pages-market/reimbursement/list', + // params: { type: 'reimbursement' } + // } ] } },