diff --git a/.gitignore b/.gitignore index 4f7e0922..ba04b3a0 100755 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,6 @@ uniapp/TASK.md uniapp/PLANNING.md niucloud/TASK.md niucloud/PLANNING.md +openspec diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..06696994 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + \ No newline at end of file diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 00000000..e3998edf --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,128 @@ +# 智慧教务系统 + +## 项目概述 + +课程预约、消课、服务管理的企业级教务系统,支持学员端和教练端。 + +## 项目结构 + +``` +zhjwxt/ +├── admin/ # 管理后台 (Vue3 + Element Plus + TypeScript) +├── uniapp/ # 移动端客户端 (UniApp + Vue3 + TypeScript) +├── niucloud/ # ThinkPHP8 后端服务 +├── openspec/ # OpenSpec 规范文档 +├── docker/ # Docker 配置 +├── doc/ # 文档目录 +└── vendor/ # Composer 依赖 +``` + +## 技术栈 + +| 模块 | 技术栈 | +|------|--------| +| 后端 | ThinkPHP 8.0 + MySQL + Redis | +| 管理后台 | Vue3 + Element Plus + TypeScript + Vite | +| 移动端 | UniApp + Vue3 + TypeScript + Pinia | + +## 运行环境 + +### 数据库 +- **数据库名**: `niucloud` +- **用户名**: `niucloud` +- **密码**: `niucloud123` + +### 服务端口 +- **后端 API**: `http://localhost:20080` + - 客户端接口: `/api` + - 管理端接口: `/adminapi` +- **管理后台**: `http://localhost:23000` +- **移动端 H5**: `http://localhost:8080` + +### 启动命令 +```bash +# 启动所有服务 (Docker) +./start.sh + +# 单独启动后端 +cd niucloud && php think serve + +# 启动管理后台 +cd admin && npm run dev + +# 启动移动端 +cd uniapp && npm run dev:h5 +``` + +## 核心功能模块 + +### 课程管理 +- 课程创建/编辑/删除 +- 课程排期 +- 课程类型管理 + +### 预约管理 +- 学员预约 +- 预约审核 +- 预约取消/改期 + +### 消课管理 +- 消课记录 +- 消课统计 +- 消课审核 + +### 学员管理 +- 学员信息 +- 学员卡管理 +- 学员消费记录 + +### 教练管理 +- 教练信息 +- 教练排班 +- 教练绩效 + +## 端点说明 + +### 移动端目录 +| 目录 | 说明 | +|------|------| +| `pages/common/` | 通用页面(登录、设置等) | +| `pages/student/` | 学员端页面 | +| `pages/coach/` | 教练端页面 | +| `pages-coach/` | 教练端专属页面 | +| `pages-common/` | 通用页面模块 | + +### 后端目录 +| 目录 | 说明 | +|------|------| +| `app/adminapi/controller/` | 管理后台接口 | +| `app/api/controller/` | 客户端接口 | +| `app/service/admin/` | 管理后台业务逻辑 | +| `app/service/api/` | 客户端业务逻辑 | +| `app/model/` | 数据模型 | + +## 测试账号 + +### 管理后台 +- **地址**: `http://localhost:23000` +- **用户名**: `admin` +- **密码**: `123123` + +## 开发规范 + +### 新增功能流程 +1. 后端:控制器 → Service → Model +2. 前端:API → 页面组件 +3. 路由:注册新页面路由 + +### 文件命名 +- 控制器:`Xxx.php` +- Service:`XxxService.php` +- Model:`Xxx.php` +- 页面:`.vue` + +## 相关项目 + +- **钜惠云仓商城项目**: `/Users/mac/coding/juhuiyuncang/juhuiyuncloudadmin` +- **数据采集项目**: `/Users/mac/coding/juhuiyuncang/datahandle` +- **物流项目**: `/Users/mac/coding/juhuiyuncang/groupeddelivery` diff --git a/admin/bug.md b/admin/bug.md new file mode 100644 index 00000000..e69de29b diff --git a/admin/src/app/views/login/index.vue b/admin/src/app/views/login/index.vue index 2e8718b7..456e28c2 100755 --- a/admin/src/app/views/login/index.vue +++ b/admin/src/app/views/login/index.vue @@ -200,16 +200,40 @@ const loginFn = (data = {}) => { loading.value = true userStore .login({ username: form.username, password: form.password, ...data }) - .then((res) => { - const { - query: { redirect }, - } = route - const path = typeof redirect === 'string' ? redirect : '/' - const url = router.resolve(path) - // console.log(url); - location.href = '/' + .then(async (res) => { + try { + // 获取用户菜单权限 + await userStore.getAuthMenusFn() + + // 登录成功,找到第一个有效路由并跳转 + const { + query: { redirect }, + } = route + + // 如果有重定向路径,优先使用;否则跳转到第一个有效路由 + if (redirect && typeof redirect === 'string') { + console.log('登录成功,跳转到重定向页面:', redirect) + await router.push(redirect) + } else { + // 查找第一个有效的菜单路由 + const { findFirstValidRoute } = await import('@/router/routers') + const firstRoute = findFirstValidRoute(userStore.routers) + if (firstRoute) { + console.log('登录成功,跳转到第一个有效路由:', firstRoute) + await router.push({ name: firstRoute }) + } else { + console.error('未找到有效的菜单路由') + throw new Error('未找到有效的菜单路由') + } + } + console.log('跳转完成') + } catch (error) { + console.error('登录后跳转失败:', error) + loading.value = false + } }) - .catch(() => { + .catch((err) => { + console.error('登录失败:', err) loading.value = false }) } diff --git a/admin/src/router/index.ts b/admin/src/router/index.ts index 365a3b49..be86633c 100755 --- a/admin/src/router/index.ts +++ b/admin/src/router/index.ts @@ -92,12 +92,28 @@ router.beforeEach(async (to: any, from, next) => { } else if (userStore.token) { // 如果已加载路由 if (userStore.routers.length) { + console.log('路由守卫: 已加载路由数量=', userStore.routers.length, '目标路径=', to.path) + if (to.path === loginPath) { + console.log('路由守卫: 从登录页跳转到应用根路径') next(`/${getAppType()}`) + } else if (to.path === `/${getAppType()}`) { + // 如果访问的是应用根路径,重定向到第一个有效路由 + const firstRoute = findFirstValidRoute(userStore.routers) + console.log('路由守卫: 访问根路径,找到的第一个路由=', firstRoute) + if (firstRoute) { + console.log('路由守卫: 重定向到第一个路由', { name: firstRoute }) + next({ name: firstRoute }) + } else { + console.log('路由守卫: 没有找到有效路由') + next() + } } else { + console.log('路由守卫: 正常访问', to.path) next() } } else { + console.log('路由守卫: 未加载路由,开始加载...') try { if (!systemStore.apps.length) { await systemStore.getInstallAddons() @@ -112,6 +128,16 @@ router.beforeEach(async (to: any, from, next) => { firstRoute = userStore.addonIndexRoute[systemStore.apps[0].key] } + console.log('路由守卫: 首个路由=', firstRoute, '路由列表=', userStore.routers) + + // 如果没有找到有效路由,使用默认的登录页面或404页面 + if (!firstRoute) { + console.warn('未找到有效的菜单路由,请检查用户权限配置') + // 清除token并重新登录 + userStore.logout() + return + } + ROOT_ROUTER.redirect = { name: firstRoute } router.addRoute(ROOT_ROUTER) @@ -129,9 +155,19 @@ router.beforeEach(async (to: any, from, next) => { // 动态添加可访问路由表 router.addRoute(ADMIN_ROUTE.name, route) }) - next(to) + + console.log('路由守卫: 动态路由已添加,目标路径=', to.path) + + // 如果访问的是登录页或应用根路径,跳转到第一个有效路由 + if (to.path === loginPath || to.path === `/${getAppType()}`) { + console.log('路由守卫: 跳转到首个路由', { name: firstRoute }) + next({ name: firstRoute }) + } else { + console.log('路由守卫: 继续访问目标页面', to.path) + next(to) + } } catch (err) { - console.log(err) + console.error('路由守卫: 加载路由失败', err) next({ path: loginPath, query: { redirect: to.fullPath } }) } } diff --git a/admin/src/router/routers.ts b/admin/src/router/routers.ts index bd488681..e26d5635 100755 --- a/admin/src/router/routers.ts +++ b/admin/src/router/routers.ts @@ -34,8 +34,7 @@ export const ADMIN_ROUTE: RouteRecordRaw = { children: [ { path: '', - name: Symbol('adminRoot'), - component: Default, + redirect: '/admin/login', // 默认重定向到登录页,登录后会由路由守卫动态修改 }, { path: 'login', @@ -142,15 +141,26 @@ export function formatRouters( export function findFirstValidRoute( routes: RouteRecordRaw[] ): string | undefined { + console.log('findFirstValidRoute: 开始查找,路由数量=', routes.length) + for (const route of routes) { + console.log('findFirstValidRoute: 检查路由', route.name, 'meta=', route.meta) + if (route.meta?.type == 1 && route.meta?.show) { + console.log('findFirstValidRoute: 找到有效路由=', route.name) return route.name as string } + if (route.children) { + console.log('findFirstValidRoute: 检查子路由,数量=', route.children.length) const name = findFirstValidRoute(route.children) if (name) { + console.log('findFirstValidRoute: 在子路由中找到有效路由=', name) return name } } } + + console.log('findFirstValidRoute: 没有找到有效路由') + return undefined } diff --git a/admin/src/stores/modules/user.ts b/admin/src/stores/modules/user.ts index b50294fb..86812a14 100755 --- a/admin/src/stores/modules/user.ts +++ b/admin/src/stores/modules/user.ts @@ -60,7 +60,10 @@ const useUserStore = defineStore('user', { return new Promise((resolve, reject) => { getAuthMenus({}) .then((res) => { + console.log('获取菜单数据:', res.data) this.routers = formatRouters(res.data) + console.log('格式化后的路由:', this.routers) + // 获取插件的首个菜单 this.routers.forEach((item, index) => { if (item.meta.app !== '') { @@ -73,9 +76,11 @@ const useUserStore = defineStore('user', { } } }) + console.log('插件首页路由:', this.addonIndexRoute) resolve(res) }) .catch((error) => { + console.error('获取菜单失败:', error) reject(error) }) }) diff --git a/docker-compose.yml b/docker-compose.yml index 17dc82cf..2cbbf365 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,6 @@ services: - ./niucloud:/var/www/html - ./docker/php/php.ini:/usr/local/etc/php/php.ini - ./docker/php/www.conf:/usr/local/etc/php-fpm.d/www.conf - # 时区同步 - - /etc/localtime:/etc/localtime:ro - - /etc/timezone:/etc/timezone:ro working_dir: /var/www/html depends_on: - mysql @@ -35,9 +32,6 @@ services: - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf - ./docker/nginx/conf.d:/etc/nginx/conf.d - ./docker/logs/nginx:/var/log/nginx - # 时区同步 - - /etc/localtime:/etc/localtime:ro - - /etc/timezone:/etc/timezone:ro environment: - TZ=Asia/Shanghai depends_on: @@ -62,9 +56,6 @@ services: - ./docker/data/mysql:/var/lib/mysql - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf - ./docker/logs/mysql:/var/log/mysql - # 时区同步 - - /etc/localtime:/etc/localtime:ro - - /etc/timezone:/etc/timezone:ro command: --default-authentication-plugin=mysql_native_password networks: - niucloud_network diff --git a/niucloud/app/adminapi/controller/auth/Auth.php b/niucloud/app/adminapi/controller/auth/Auth.php index 8f4748ce..fbc01bbc 100755 --- a/niucloud/app/adminapi/controller/auth/Auth.php +++ b/niucloud/app/adminapi/controller/auth/Auth.php @@ -57,6 +57,21 @@ class Auth extends BaseAdminController return success('MODIFY_SUCCESS'); } + /** + * 测试接口 - 用于调试 + * @return Response + */ + public function test() + { + $data = [ + 'uid' => $this->request->uid(), + 'username' => $this->request->username(), + 'token' => $this->request->adminToken(), + 'message' => 'Token验证成功' + ]; + return success($data); + } + /** * 更新用户 */ diff --git a/niucloud/app/adminapi/middleware/AdminCheckToken.php b/niucloud/app/adminapi/middleware/AdminCheckToken.php index e6413bb4..fefb75d3 100755 --- a/niucloud/app/adminapi/middleware/AdminCheckToken.php +++ b/niucloud/app/adminapi/middleware/AdminCheckToken.php @@ -26,9 +26,20 @@ class AdminCheckToken { //通过配置来设置系统header参数 $token = $request->adminToken(); - $token_info = (new LoginService())->parseToken($token); - $request->uid($token_info['uid']); - $request->username($token_info['username']); - return $next($request); + + if (empty($token)) { + throw new \core\exception\AuthException('MUST_LOGIN', 401); + } + + try { + $token_info = (new LoginService())->parseToken($token); + $request->uid($token_info['uid']); + $request->username($token_info['username']); + return $next($request); + } catch (\Throwable $e) { + // 记录错误日志以便调试 + \think\facade\Log::error('Token验证失败: ' . $e->getMessage()); + throw new \core\exception\AuthException('LOGIN_EXPIRE', 401); + } } } diff --git a/niucloud/app/adminapi/route/auth.php b/niucloud/app/adminapi/route/auth.php index 61af423d..d174db88 100755 --- a/niucloud/app/adminapi/route/auth.php +++ b/niucloud/app/adminapi/route/auth.php @@ -30,6 +30,9 @@ Route::group('auth', function () { Route::put('edit', 'auth.Auth/edit'); //授权用户信息 Route::put('modify/:field', 'auth.Auth/modify'); + + // 测试接口 - 仅用于调试token验证问题 + Route::get('test', 'auth.Auth/test'); })->middleware([ AdminCheckToken::class, AdminCheckRole::class, diff --git a/niucloud/app/model/document/DocumentGenerateLog.php b/niucloud/app/model/document/DocumentGenerateLog.php index c7437237..75a49570 100755 --- a/niucloud/app/model/document/DocumentGenerateLog.php +++ b/niucloud/app/model/document/DocumentGenerateLog.php @@ -12,14 +12,6 @@ class DocumentGenerateLog extends BaseModel { protected $pk = 'id'; protected $name = 'document_generate_log'; - - // 字段类型定义 - protected $type = [ - 'created_at' => 'timestamp', - 'updated_at' => 'timestamp', - 'process_start_time' => 'datetime', - 'process_end_time' => 'datetime' - ]; /** * 关联合同表 diff --git a/niucloud/app/service/admin/document/DocumentTemplateService.php b/niucloud/app/service/admin/document/DocumentTemplateService.php index 45cd3c45..3fbd0fc7 100755 --- a/niucloud/app/service/admin/document/DocumentTemplateService.php +++ b/niucloud/app/service/admin/document/DocumentTemplateService.php @@ -83,13 +83,11 @@ class DocumentTemplateService extends BaseAdminService $info['placeholders'] = $info['placeholders'] ? json_decode($info['placeholders'], true) : []; $info['file_size_formatted'] = $this->formatFileSize($info['file_size']); - // 基于placeholder_config构建数据源配置,不查询school_document_data_source_config表 + // 获取数据源配置信息,从placeholder_config字段获取 $dataSourceConfigs = []; - if (!empty($info['placeholders'])) { - foreach ($info['placeholders'] as $placeholder) { - // 使用placeholder_config中的配置,如果没有则使用默认配置 - $config = $info['placeholder_config'][$placeholder] ?? []; - + if (!empty($info['placeholder_config'])) { + // 转换placeholder_config格式为前端需要的data_source_configs格式 + foreach ($info['placeholder_config'] as $placeholder => $config) { $dataSourceConfigs[] = [ 'id' => 0, 'placeholder' => $placeholder, @@ -107,6 +105,27 @@ class DocumentTemplateService extends BaseAdminService } $info['data_source_configs'] = $dataSourceConfigs; + + // 如果没有数据源配置,但有占位符,则创建默认配置 + if (empty($dataSourceConfigs) && !empty($info['placeholders'])) { + $defaultConfigs = []; + foreach ($info['placeholders'] as $placeholder) { + $defaultConfigs[] = [ + 'id' => 0, + 'placeholder' => $placeholder, + 'data_type' => 'user_input', + 'table_name' => '', + 'field_name' => '', + 'system_function' => '', + 'user_input_value' => '', + 'sign_party' => '', + 'field_type' => 'text', + 'is_required' => 0, + 'default_value' => '' + ]; + } + $info['data_source_configs'] = $defaultConfigs; + } } return $info; @@ -145,14 +164,11 @@ class DocumentTemplateService extends BaseAdminService $insertData[] = [ 'contract_id' => $contractId, 'placeholder' => $config['placeholder'], - 'data_type' => $config['data_type'] ?? 'user_input', 'table_name' => $config['table_name'] ?? '', 'field_name' => $config['field_name'] ?? '', - 'field_type' => $config['field_type'] ?? 'text', + 'field_type' => $config['field_type'] ?? 'string', 'is_required' => $config['is_required'] ?? 0, 'default_value' => $config['default_value'] ?? '', - 'system_function' => $config['system_function'] ?? '', - 'sign_party' => $config['sign_party'] ?? '', 'created_at' => date('Y-m-d H:i:s') ]; } @@ -417,17 +433,12 @@ class DocumentTemplateService extends BaseAdminService try { // 1. 保存配置到合同表的placeholder_config字段(保持兼容性) $template->placeholder_config = json_encode($configData); - - // 2. 更新placeholder字段为占位符的逗号分隔文本 - $placeholders = array_keys($configData); - $template->placeholder = implode(',', $placeholders); - - // 3. 更新placeholders字段为JSON数组 - $template->placeholders = json_encode($placeholders); - $template->updated_at = date('Y-m-d H:i:s'); $template->save(); + // 2. 同时保存到独立的数据源配置表(用户期望的表) + $this->saveConfigToDataSourceTable($templateId, $configData); + \think\facade\Db::commit(); return true; } catch (\Exception $e) { @@ -641,19 +652,7 @@ class DocumentTemplateService extends BaseAdminService // 准备填充数据 $placeholderConfig = json_decode($template['placeholder_config'], true); - // 检查是否传递了 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']); - } + $fillValues = $this->prepareFillData($placeholderConfig, $data['fill_data']); // 生成文档 $templatePath = public_path() . '/upload/' . $template['contract_template']; @@ -696,14 +695,12 @@ class DocumentTemplateService extends BaseAdminService throw new \Exception('无法创建临时文档存储目录,请检查系统权限'); } - // 预处理:修复被格式化分割的占位符 - $fixedTemplatePath = $this->fixBrokenPlaceholders($templatePath); - // 使用 PhpWord 模板处理器 - $templateProcessor = new TemplateProcessor($fixedTemplatePath); + $templateProcessor = new TemplateProcessor($templatePath); - // 智能处理占位符,根据类型使用不同的处理方法 - $this->processPlaceholders($templateProcessor, $fillValues, $placeholderConfig); + foreach ($fillValues as $placeholder => $value) { + $templateProcessor->setValue($placeholder, $value); + } $templateProcessor->saveAs($fullOutputPath); @@ -737,36 +734,20 @@ class DocumentTemplateService extends BaseAdminService 'download_url' => url('/upload/' . $outputPath) ]; } else { - // 复制失败,使用runtime目录作为替代方案 - $runtimeDir = runtime_path() . 'generated_documents' . DIRECTORY_SEPARATOR; - if (!is_dir($runtimeDir)) { - @mkdir($runtimeDir, 0755, true); - } - - $runtimePath = $runtimeDir . $outputFileName; - $runtimeCopySuccess = false; - - // 尝试复制到runtime目录 - if (@copy($fullOutputPath, $runtimePath)) { - $runtimeCopySuccess = true; - @unlink($fullOutputPath); // 删除临时文件 - } - - // 更新生成记录 + // 复制失败,保留临时文件,提供临时下载路径 $log->status = 'completed'; - $log->generated_file_path = $runtimeCopySuccess ? 'runtime/generated_documents/' . $outputFileName : 'temp/' . $outputFileName; + $log->generated_file_path = 'temp/' . $outputFileName; $log->generated_file_name = $outputFileName; - $log->temp_file_path = $runtimeCopySuccess ? $runtimePath : $fullOutputPath; $log->process_end_time = date('Y-m-d H:i:s'); $log->save(); return [ 'log_id' => $log->id, - 'file_path' => $runtimeCopySuccess ? 'runtime/generated_documents/' . $outputFileName : 'temp/' . $outputFileName, + 'file_path' => 'temp/' . $outputFileName, 'file_name' => $outputFileName, - 'download_url' => url('/adminapi/document/download/' . $log->id), - 'temp_file_path' => $runtimeCopySuccess ? $runtimePath : $fullOutputPath, - 'message' => $runtimeCopySuccess ? '文档已生成,使用临时下载链接' : '文档已生成,但无法复制到公共目录,请联系管理员处理权限问题' + 'download_url' => '', + 'temp_file_path' => $fullOutputPath, + 'message' => '文档已生成,但无法复制到公共目录,请联系管理员处理权限问题' ]; } @@ -852,37 +833,13 @@ class DocumentTemplateService extends BaseAdminService $tableName = $config['table_name']; $fieldName = $config['field_name']; - // 改进的数据库查询逻辑,支持条件查询 + // 简单的数据库查询(实际应用中需要更完善的查询逻辑) $model = \think\facade\Db::connect(); - $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 - ]); + $result = $model->table($tableName)->field($fieldName)->find(); return $result[$fieldName] ?? $config['default_value'] ?? ''; } catch (\Exception $e) { - Log::error('数据库查询失败:' . $e->getMessage(), [ - 'table' => $config['table_name'] ?? '', - 'field' => $config['field_name'] ?? '', - 'config' => $config - ]); + Log::error('数据库查询失败:' . $e->getMessage()); return $config['default_value'] ?? ''; } } @@ -1012,24 +969,9 @@ class DocumentTemplateService extends BaseAdminService throw new \Exception('文档尚未生成完成'); } - // 优先尝试从public目录下载 $filePath = public_path() . '/upload/' . $log['generated_file_path']; - - // 如果public目录文件不存在,尝试从临时文件路径下载 - if (!file_exists($filePath) && !empty($log['temp_file_path'])) { - $filePath = $log['temp_file_path']; - } - - // 如果还是不存在,尝试从runtime目录 - if (!file_exists($filePath)) { - $runtimePath = runtime_path() . 'generated_documents' . DIRECTORY_SEPARATOR . $log['generated_file_name']; - if (file_exists($runtimePath)) { - $filePath = $runtimePath; - } - } - if (!file_exists($filePath)) { - throw new \Exception('文件不存在或已被删除'); + throw new \Exception('文件不存在'); } // 更新下载统计 @@ -1069,130 +1011,6 @@ 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 @@ -1432,15 +1250,10 @@ class DocumentTemplateService extends BaseAdminService // 更新占位符配置 if (!empty($newConfig)) { $template->placeholder_config = json_encode($newConfig); - - // 更新placeholder字段为占位符的逗号分隔文本 - $placeholders = array_keys($newConfig); - $template->placeholder = implode(',', $placeholders); - - // 更新placeholders字段为JSON数组 - $template->placeholders = json_encode($placeholders); - $template->save(); + + // 同步更新数据源配置表 + $this->saveConfigToDataSourceTable($id, $newConfig); } return [ @@ -1563,15 +1376,10 @@ class DocumentTemplateService extends BaseAdminService // 更新占位符配置 if (!empty($newConfig)) { $template->placeholder_config = json_encode($newConfig); - - // 更新placeholder字段为占位符的逗号分隔文本 - $placeholders = array_keys($newConfig); - $template->placeholder = implode(',', $placeholders); - - // 更新placeholders字段为JSON数组 - $template->placeholders = json_encode($placeholders); - $template->save(); + + // 同步更新数据源配置表 + $this->saveConfigToDataSourceTable($id, $newConfig); } return [ @@ -1592,1214 +1400,4 @@ 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/admin/upgrade/UpgradeService.php b/niucloud/app/service/admin/upgrade/UpgradeService.php index 2aa5b069..abe9a14d 100755 --- a/niucloud/app/service/admin/upgrade/UpgradeService.php +++ b/niucloud/app/service/admin/upgrade/UpgradeService.php @@ -317,14 +317,14 @@ class UpgradeService extends BaseAdminService if ($addon == AddonDict::FRAMEWORK_KEY) { $composer = '/niucloud/composer.json'; - $admin_package = '/admin/mysql-mcp-config.json'; - $web_package = '/web/mysql-mcp-config.json'; - $uniapp_package = '/uni-app/mysql-mcp-config.json'; + $admin_package = '/admin/package.json'; + $web_package = '/web/package.json'; + $uniapp_package = '/uniapp/package.json'; } else { $composer = "/niucloud/addon/{$addon}/package/composer.json"; - $admin_package = "/niucloud/addon/{$addon}/package/admin-mysql-mcp-config.json"; - $web_package = "/niucloud/addon/{$addon}/package/web-mysql-mcp-config.json"; - $uniapp_package = "/niucloud/addon/{$addon}/package/uni-app-mysql-mcp-config.json"; + $admin_package = "/niucloud/addon/{$addon}/package/admin-package.json"; + $web_package = "/niucloud/addon/{$addon}/package/web-package.json"; + $uniapp_package = "/niucloud/addon/{$addon}/package/uni-app-package.json"; } if (in_array($composer, $change_files)) { @@ -392,7 +392,7 @@ class UpgradeService extends BaseAdminService $this->compileLocale($this->root_path . 'uni-app' . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR, $addon); // 合并插件依赖 - $addon_uniapp_package = str_replace('/', DIRECTORY_SEPARATOR, project_path() . "niucloud/addon/{$addon}/package/uni-app-mysql-mcp-config.json"); + $addon_uniapp_package = str_replace('/', DIRECTORY_SEPARATOR, project_path() . "niucloud/addon/{$addon}/package/uni-app-package.json"); if (file_exists($addon_uniapp_package)) { $original = $depend_service->getNpmContent('uni-app'); @@ -402,7 +402,7 @@ class UpgradeService extends BaseAdminService $original[$name] = isset($original[$name]) && is_array($original[$name]) ? array_merge($original[$name], $new[$name]) : $new[$name]; } - $uniapp_package = $this->root_path . 'uni-app' . DIRECTORY_SEPARATOR . 'mysql-mcp-config.json'; + $uniapp_package = $this->root_path . 'uniapp' . DIRECTORY_SEPARATOR . 'package.json'; $depend_service->writeArrayToJsonFile($original, $uniapp_package); } } diff --git a/niucloud/app/service/core/addon/CoreAddonInstallService.php b/niucloud/app/service/core/addon/CoreAddonInstallService.php index 7d171091..9b9dca1d 100755 --- a/niucloud/app/service/core/addon/CoreAddonInstallService.php +++ b/niucloud/app/service/core/addon/CoreAddonInstallService.php @@ -220,10 +220,10 @@ class CoreAddonInstallService extends CoreAddonBaseService }, $package_file); $tips = [get_lang('dict_addon.install_after_update')]; - if (in_array('admin-mysql-mcp-config.json', $package_file)) $tips[] = get_lang('dict_addon.install_after_admin_update'); + if (in_array('admin-package.json', $package_file)) $tips[] = get_lang('dict_addon.install_after_admin_update'); if (in_array('composer.json', $package_file)) $tips[] = get_lang('dict_addon.install_after_composer_update'); - if (in_array('uni-app-mysql-mcp-config.json', $package_file)) $tips[] = get_lang('dict_addon.install_after_wap_update'); - if (in_array('web-mysql-mcp-config.json', $package_file)) $tips[] = get_lang('dict_addon.install_after_web_update'); + if (in_array('uni-app-package.json', $package_file)) $tips[] = get_lang('dict_addon.install_after_wap_update'); + if (in_array('web-package.json', $package_file)) $tips[] = get_lang('dict_addon.install_after_web_update'); return $tips; } return true; diff --git a/niucloud/app/service/core/addon/CoreDependService.php b/niucloud/app/service/core/addon/CoreDependService.php index a23ac046..3d5d8db6 100755 --- a/niucloud/app/service/core/addon/CoreDependService.php +++ b/niucloud/app/service/core/addon/CoreDependService.php @@ -25,9 +25,9 @@ class CoreDependService extends CoreAddonBaseService { parent::__construct(); $this->server_composer_file = $this->root_path . 'niucloud' . DIRECTORY_SEPARATOR . 'composer.json'; - $this->admin_npm_file = $this->root_path . 'admin' . DIRECTORY_SEPARATOR . 'mysql-mcp-config.json'; - $this->web_npm_file = $this->root_path . 'web' . DIRECTORY_SEPARATOR . 'mysql-mcp-config.json'; - $this->wap_npm_file = $this->root_path . 'uni-app' . DIRECTORY_SEPARATOR . 'mysql-mcp-config.json'; + $this->admin_npm_file = $this->root_path . 'admin' . DIRECTORY_SEPARATOR . 'package.json'; + $this->web_npm_file = $this->root_path . 'web' . DIRECTORY_SEPARATOR . 'package.json'; + $this->wap_npm_file = $this->root_path . 'uniapp' . DIRECTORY_SEPARATOR . 'package.json'; } /** @@ -141,11 +141,11 @@ class CoreDependService extends CoreAddonBaseService public function getAddonNpmContent(string $addon, string $type) { if ($type == 'admin') { - $file_path = $this->geAddonPackagePath($addon) . 'admin-mysql-mcp-config.json'; + $file_path = $this->geAddonPackagePath($addon) . 'admin-package.json'; } elseif ($type == 'web') { - $file_path = $this->geAddonPackagePath($addon) . 'web-mysql-mcp-config.json'; + $file_path = $this->geAddonPackagePath($addon) . 'web-package.json'; } else { - $file_path = $this->geAddonPackagePath($addon) . 'uni-app-mysql-mcp-config.json'; + $file_path = $this->geAddonPackagePath($addon) . 'uni-app-package.json'; } return $this->jsonFileToArray($file_path); } diff --git a/niucloud/bug.md b/niucloud/bug.md new file mode 100644 index 00000000..e69de29b diff --git a/question.md b/question.md new file mode 100644 index 00000000..773e157e --- /dev/null +++ b/question.md @@ -0,0 +1,84 @@ +# 智慧教务系统 - 问题记录 + +> 记录浏览代码过程中遇到的业务问题,等待老王解答 + +## 1. 课程预约流程 + +**问题**:学员预约课程的完整流程是什么? + +- 学员如何查看可预约的课程? +- 预约是否需要教练确认? +- 预约失败的原因有哪些? + +**涉及文件**: +- `niucloud/app/api/controller/` (预约相关控制器) +- `uniapp/pages/student/` (学员端页面) + +--- + +## 2. 消课机制 + +**问题**:消课是如何触发的? + +- 教练手动消课还是系统自动消课? +- 消课前是否需要学员确认? +- 消课金额如何计算? + +**涉及文件**: +- `niucloud/app/service/api/` (消课相关 Service) +- `uniapp/pages/` (消课页面) + +--- + +## 3. 学员卡类型 + +**问题**:学员卡的类型和有效期规则是什么? + +- 看到学员卡相关的数据库表 +- 支持哪些类型的卡(次卡、月卡、季卡等)? +- 过期后如何处理? + +--- + +## 4. 教练排班 + +**问题**:教练的排班是如何管理的? + +- 排班是教练自己设置还是管理员设置? +- 排班冲突如何检测? +- 临时调课如何处理? + +--- + +## 5. 多端权限 + +**问题**:学员端和教练端的权限如何区分? + +- 看到 `pages/student/` 和 `pages/coach/` 目录 +- 权限是如何控制的? +- 是否支持管理员角色? + +--- + +## 6. 数据同步 + +**问题**:管理后台和移动端的数据如何同步? + +- 看到有 `adminapi` 和 `api` 两套接口 +- 数据是否实时同步? +- 是否有离线支持? + +--- + +## 待确认的业务规则 + +1. **预约规则**:学员可以提前多久预约?是否可以取消预约? +2. **消课规则**:消课后是否立即扣减学员卡余额? +3. **教练提成**:教练的提成是如何计算的? +4. **退款规则**:学员如何申请退款?退款流程是什么? +5. **课时管理**:总课时和已消课时的关系是什么? + +--- + +> **记录时间**:2026-02-01 +> **状态**:待回答 diff --git a/uniapp/bug.md b/uniapp/bug.md new file mode 100644 index 00000000..e69de29b