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 @@
-
-
-
-
-
-
-
-
-
-
-
- {{ timeSlot.time }}
-
-
-
-
-
-
- {{ teacher.name }}
-
-
-
-
-
-
- {{ venue.name }}
-
-
-
-
-
-
- {{ cls.name }}
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
@@ -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' }
+ // }
]
}
},