-
一、概述1.1 案例介绍银发智伴(ElderlyCare AI) 是一款面向子女、老年家庭成员和应用后台管理员三类用户的智能体检报告管理应用。子女可在 PC 端上传父母的 PDF、JPG 或 PNG 体检报告,系统通过 OCR 识别、指标提取和大模型健康解读,将专业检验结果转换为通俗说明、健康建议和饮食计划;随后生成适老化 H5 分享链接与二维码,父母通过手机即可查看大字版报告并使用语音播报。后台管理员负责用户状态、角色、系统概览和审计信息管理,不参与用户报告内容的日常操作。本案例采用 FastAPI、Vue 3、SQLite、Redis、Nginx 和华为云 ECS 构建,集成华为云 OBS 与 OCR,并使用华为云码道(CodeArts)代码智能体完成需求分析、存量代码理解、任务拆解、增量开发和问题调试。应用还提供家庭成员管理、历年指标趋势、异常变化提醒、报告对比、分享密码、访问次数限制和后台治理,形成“报告上传、智能解析、健康解读、长期跟踪、适老分享、后台治理”的完整业务闭环。GitCode 源码与演示视频:https://gitcode.com/SDSXshlbz/elderlycare-ai本案例中的演示账号、家庭成员和体检指标均为合成数据,不对应任何真实个人。健康解读仅供参考,不构成医疗诊断建议。1.2 适用对象企业开发者个人开发者高校学生学习本案例前,建议具备 Python、JavaScript、Linux 常用命令和 HTTP API 的基础知识。1.3 案例时间本案例总时长预计90分钟,其中云资源准备约20分钟,项目配置与部署约40分钟,功能验证与资源清理约30分钟。1.4 案例流程[1. 准备云资源] | v [2. 配置项目与密钥] -> [3. 构建 PC/H5 前端和 FastAPI 后端] | v [5. H5适老分享] <- [4. OCR识别、指标提取与健康解读]说明:在华为云创建 ECS、VPC、安全组、EIP 和 OBS 桶,公网仅长期开放演示所需的 80 端口;准备华为云 AK/SK、OCR、OBS 和大模型配置,通过 .env 注入运行参数,不将密钥写入源码;将源码上传到 Ubuntu 22.04 ECS,执行 deploy_ecs.sh,自动安装依赖、构建两个前端并配置 Nginx、Redis 和 systemd;用户上传报告后,后端依次完成 OCR 降级识别、指标提取、风险判定、健康解读和饮食计划生成;子女在 PC 端查看趋势和对比结果,并生成带二维码、可选访问密码和语音播报的适老化 H5 分享页面。1.5 资源总览本案例使用按需计费资源,建议将完整体验控制在2小时内。不同区域和活动价格可能变化,实际费用以华为云控制台订单页为准。体验完成后请及时释放 ECS、EIP 和不再使用的 OBS 数据,避免产生多余费用。资源名称规格单价(元)弹性云服务器 ECS通用计算增强型 c7.large.2 | 2 vCPUs | 4 GiB | Ubuntu 22.04 Server | 40 GiB GPSSD以控制台实时价格为准弹性公网IP(Elastic IP,简称EIP)按流量计费 | 5Mbit/s按实际公网流量计费虚拟私有云 VPC 和安全组1个VPC、1个子网、1个安全组VPC和安全组本身免费对象存储服务 OBS标准存储,用于报告文件、二维码等对象按存储量与请求次数计费文字识别 OCR通用文字识别、通用表格识别按调用量计费或使用套餐包华为云码道(CodeArts)代码智能体通用体验版以开通页面为准阿里云百炼大模型 APIqwen-turbo、qwen-vl-ocr、qwen-vl-plus按模型调用量计费二、环境和资源准备2.1 购买华为云 ECS 弹性云服务器登录华为云控制台,依次进入 服务列表 > 计算 > 弹性云服务器 ECS,点击 购买弹性云服务器。计费模式选择 按需计费,区域选择 北京四 cn-north-4,规格选择 通用计算增强型 c7.large.2,2 vCPUs,4 GiB。镜像选择 Ubuntu Server 22.04 64bit,系统盘选择 40 GiB GPSSD。创建或选择 VPC 与子网,购买 5Mbit/s、按流量计费 的弹性公网 IP。创建安全组,长期入方向规则只放行 TCP/80。部署期间如需 SSH,可临时放行 TCP/22,并将源地址限制为管理员当前公网 IP,部署完成后立即删除 22 端口规则。点击 立即购买,确认按需资源并等待 ECS 状态变为“运行中”。记录 ECS 公网 IP,后文用 <ECS公网IP> 表示。关键节点说明:安全组只控制云侧入站流量,Nginx 仍需在实例内监听 80 端口。后端 Uvicorn 仅监听 127.0.0.1:8005,不能绕过 Nginx 直接从公网访问。2.2 准备华为云 OBS、OCR 和访问密钥进入 服务列表 > 存储 > 对象存储服务 OBS,创建私有桶,例如 elderlycare,区域需与 ECS 一致。进入 服务列表 > 人工智能 > 文字识别 OCR,开通通用文字识别和通用表格识别。点击控制台右上角用户名,进入 我的凭证 > 访问密钥,创建并妥善保存 AK/SK。在 我的凭证 > API凭证 中记录项目 ID,项目所属区域应为 cn-north-4。AK/SK 具有云资源访问权限,只能写入服务器上的 .env,不得提交到 Git、案例文档、截图或前端代码中。OBS 桶保持私有,文件由后端 SDK 访问。2.3 准备大模型与 OCR 降级服务登录阿里云百炼控制台,地址:https://dashscope.console.aliyun.com/,开通 DashScope 服务。在 API-KEY 管理 中创建 API Key,并确认账号可调用 qwen-turbo、qwen-vl-ocr 和 qwen-vl-plus。将 API Key 仅配置在 .env 中。项目首先调用华为云 OCR;若该调用失败,再依次尝试 qwen-vl-ocr 和 qwen-vl-plus,避免单一识别服务异常导致整条解析链路不可用。2.4 准备本地和云端环境本案例以 Windows 作为开发端、Ubuntu 作为运行端,建议环境如下:环境软件及版本用途Windows 10/11PowerShell 5.1+、OpenSSH Client、tar.exe源码检查、测试、打包和上传本地 PythonPython 3.10+,本案例测试环境为 Python 3.12后端测试本地 Node.jsNode.js 20、npm 10PC/H5 生产构建Ubuntu ECSUbuntu 22.04、Python 3.10、Node.js 20、Nginx、Redis演示环境运行执行以下命令复制配置模板:Copy-Item .env.example .env编辑 .env,将占位内容替换为实际配置。不要删除未使用的键,也不要在等号两侧添加多余引号:HUAWEI_CLOUD_AK=你的华为云AK HUAWEI_CLOUD_SK=你的华为云SK HUAWEI_CLOUD_REGION=cn-north-4 HUAWEI_CLOUD_PROJECT_ID=你的华为云项目ID OBS_BUCKET_NAME=elderlycare OBS_ENDPOINT=obs.cn-north-4.myhuaweicloud.com JWT_SECRET_KEY=至少32字节的随机字符串 REDIS_URL=redis://localhost:6379/0 DATABASE_URL=sqlite+aiosqlite:///./elderlycare.db MCP_SERVER_URL= LLM_API_KEY=你的千问APIKey LLM_API_BASE=https://dashscope.aliyuncs.com/compatible-mode/v1 LLM_MODEL_NAME=qwen-turbo ALIYUN_OCR_API_KEY=你的阿里云OCR_APIKey ALIYUN_OCR_MODEL=qwen-vl-ocr SHARE_BASE_URL=http://<ECS公网IP>/h5在 Windows PowerShell 中生成随机 JWT 密钥:$bytes = New-Object byte[] 48 [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) [Convert]::ToBase64String($bytes) 三、构建银发智伴应用3.1 使用 CodeArts 代码智能体创建项目登录华为云码道(CodeArts),创建 Python + Vue 3 项目。在 CodeArts IDE 中输入以下需求描述,让代码智能体先理解目标和约束,再结合本案例存量源码进行增量开发:创建“银发智伴”体检报告管理应用。后端使用 Python FastAPI 和 SQLAlchemy Async,PC 端与 H5 端使用 Vue 3。子女在 PC 端上传父母体检报告,后端通过 OCR 提取指标,生成健康解读、饮食计划、趋势分析和报告对比;H5 使用大字号、清晰风险标签和中文语音播报。项目部署到 Ubuntu ECS,由 Nginx 统一提供 PC、H5 和 API 入口。本项目工作区保留了代码智能体生成并持续修订的规格、设计和任务文件,使用过程如下:阶段代码智能体产物或操作人工确认的关键节点需求规格化.codeartsdoer/specs/elderlycare_ai/spec.md明确子女、父母、管理员三类角色,以及“仅提供健康参考、不替代医生诊断”的职责边界存量代码分析.codeartsdoer/specs/elderlycare_ai/design.md对照现有认证、报告和分享模块,判断哪些功能复用、哪些功能增量扩展,避免大幅改变原有结构任务拆解.codeartsdoer/specs/elderlycare_ai/tasks.md将工作拆分为认证、报告、OCR、健康解读、分享、管理员、测试和部署等可验证任务代码开发根据任务逐个生成或补全 FastAPI 服务、Vue 页面和部署配置审查接口权限、异步数据库事务、上传限制、异常映射和前后端字段契约调试验证结合错误日志定位登录、复制、H5 子路径和 Ubuntu 兼容问题每次修复后执行后端测试、两个前端生产构建和 ECS 健康检查本案例使用代码智能体辅助完成的典型调试过程如下:登录错误调试:根据后端日志和认证服务调用链,发现错误凭据触发的业务异常未正确映射。修正空用户判断和异常处理器后,错误账号由 HTTP 500 变为 HTTP 401,并返回“账号或密码错误”;分享链接复制调试:公网演示环境使用 HTTP,浏览器可能禁用安全上下文中的 Clipboard API。智能体协助定位兼容性原因,并在 navigator.clipboard.writeText() 失败时使用 document.execCommand('copy') 回退;Windows 到 Ubuntu 迁移调试:检查 CRLF/LF、文件名大小写、依赖锁文件和路径分隔符;前端使用 npm ci 复现依赖,部署脚本保持 LF,源码导入路径与真实文件名大小写一致;H5 子路径调试:统一 Vite base: '/h5/'、Vue Router 的 import.meta.env.BASE_URL 与 Nginx try_files,解决分享深层路由刷新 404;验证闭环:本地运行 python -m pytest tests -q,分别执行 PC/H5 的 npm run build,部署后访问 /health,并验证登录、趋势、对比、分享和管理员接口。代码智能体用于加速分析、编码和定位问题,不能替代人工评审。代码生成后必须逐项检查权限边界、异常处理、上传限制、密钥加载、医疗免责声明和敏感信息,并通过测试及生产构建后才能部署。3.2 部署项目代码1)解决方案总体设计银发智伴采用“多端访问、统一 API、智能服务可降级、云上统一接入”的解决方案。PC 端承载报告管理、趋势对比和后台治理,H5 端专注适老化查看与语音播报;Nginx 提供统一公网入口,FastAPI 编排认证、报告、分享和智能解析流程;OBS 保存私有报告对象,SQLite 保存结构化业务数据,Redis 保存令牌黑名单和运行期状态。OCR 与大模型均设置超时、错误处理和本地降级,避免第三方服务短暂不可用时整个应用失效。用户上传报告后,请求依次经过文件校验、私有存储、OCR 识别、指标提取、风险判定、大模型健康解读和合规过滤;处理状态通过状态机管理。子女可在 PC 端查看结果、趋势和对比,也可生成带随机状态 ID、有效期、可选密码和访问次数限制的分享链接。父母通过 H5 查看大字版内容;管理员通过角色鉴权后的接口查看系统概览和管理用户。2)系统架构设计用户层:PC 子女端 / H5 父母端 / PC 管理后台 | 接入层:EIP + Nginx(/、/h5/、/api/、/health) | 应用层:FastAPI API / JWT认证 / 报告 / 分享 / 趋势 / 对比 / 管理 | 智能层:华为云OCR -> 百炼OCR -> Qwen VL / MCP大模型 / 合规工具 / 本地规则 | 数据层:SQLite业务数据 / Redis状态与令牌 / OBS私有对象 | 运维层:systemd / 健康检查 / 审计日志 / 华为云安全组架构关键点:公网只暴露 Nginx 的 80 端口,FastAPI 仅监听 127.0.0.1:8005;PC、H5 与 API 共用同一主机,减少跨域配置;智能服务通过统一服务层调用,业务 API 不直接依赖某一家模型厂商;配置和密钥全部由 .env 注入,仓库只提供不含真实值的 .env.example。核心技术难点与解决思路核心难点解决思路验证方式OCR 服务超时、配额不足或结果为空依次调用华为云 OCR、百炼 OCR 和 Qwen VL,仅在获得有效文本时结束;全失败时明确标记报告失败Mock 各级返回值,检查降级顺序和失败状态大模型输出不稳定且健康内容有合规风险传入结构化指标,以系统提示词约束输出;执行合规过滤并固定展示免责声明;调用失败时使用本地规则生成基础解读断开模型服务后仍能生成可查看的基础结果报告解析耗时,用户难以判断进度使用 OCR_PROCESSING、EXTRACTING、INTERPRETING、COMPLETED、FAILED 状态机,并通过 SSE 推送进度检查页面进度变化及异常后最终状态Windows 开发代码迁移到 Ubuntu锁定 Python/Node 依赖,使用 npm ci;Shell 脚本使用 LF;检查源码路径大小写,不使用 Windows 专属路径本地测试、双前端生产构建和 ECS 健康检查均通过H5 部署在 /h5/ 后深层路由刷新 404Vite base、Router base 和 Nginx try_files 使用一致的 /h5/ 前缀直接访问 /h5/share/<state_id> 返回 HTTP 200HTTP 演示站点复制分享链接失败优先使用 Clipboard API,失败时回退到隐藏文本框和 document.execCommand('copy')本地 HTTPS/localhost 与 ECS HTTP 环境分别点击复制医疗报告、账号和后台权限安全bcrypt 保存密码,JWT 鉴权,失败次数锁定,角色依赖保护管理员接口,OBS 私有存储并记录审计日志错误登录返回 401,普通用户访问管理员接口返回 4033)通过 GitCode 下载源码并了解项目结构本案例源码公开托管于 GitCode:https://gitcode.com/SDSXshlbz/elderlycare-ai。在 Windows PowerShell 或 Ubuntu 终端执行:git clone https://gitcode.com/SDSXshlbz/elderlycare-ai.git cd elderlycare-ai仓库不包含 .env、数据库、用户上传文件或真实医疗图片。克隆后需根据 .env.example 创建本地 .env 并填写自己的服务配置。项目结构如下:├── app/ # FastAPI后端 │ ├── api/ # 登录、报告、分享、趋势、对比等API路由 │ ├── core/ # 配置、数据库、安全、OBS、OCR、异常处理 │ ├── mcp/ # 大模型调用和本地工具降级 │ ├── models/ # SQLAlchemy ORM模型 │ ├── schemas/ # 请求和响应数据模型 │ ├── services/ # 认证、报告、解析、解读、分享等业务服务 │ └── main.py # FastAPI入口、路由和健康检查 ├── frontend/ │ ├── pc/ # 子女使用的PC端Vue 3应用 │ │ └── src/views/ # 工作台、详情、趋势、对比、家庭成员等页面 │ └── h5/ # 父母使用的适老化H5应用 │ └── src/views/SharePage.vue # 大字版健康报告和语音播报 ├── mcp_server/ # 可独立运行的模型编排与工具服务 ├── migrations/ # Alembic数据库版本记录 ├── tests/ # 后端单元测试与集成测试 ├── deploy_ecs.sh # Ubuntu ECS一键部署脚本 ├── requirements.txt # 固定版本的Python依赖 └── .env.example # 环境变量模板,不包含真实密钥 项目采用前后端分离和 API、服务、模型分层结构。浏览器请求先到 app/api/ 路由层,Pydantic app/schemas/ 完成输入输出校验,app/services/ 编排业务规则,app/models/ 通过异步 SQLAlchemy 持久化数据;OCR、OBS、安全和异常处理集中在 app/core/,模型调用与合规、营养、参考范围工具位于 app/mcp/。PC 与 H5 分别构建为静态文件,Nginx 将 / 映射到 PC,将 /h5/ 映射到 H5,将 /api/ 和 /health 反向代理到 FastAPI。4)在 Windows 本地执行构建与测试在项目根目录执行后端测试:.\.venv\Scripts\python.exe -m pytest tests -q预期结果:54 passed分别构建 PC 和 H5 生产包:Set-Location frontend\pc npm ci npm run build Set-Location ..\h5 npm ci npm run build Set-Location ..\.. npm ci 会严格按照 package-lock.json 安装依赖,避免 Windows 和 Ubuntu 使用不同依赖版本。两个 npm run build 均应成功生成各自的 dist 目录。PC 端构建时可能提示 ECharts 相关分块超过 Vite 默认的 500 KB 建议阈值,该提示不影响产物生成,后续可通过 manualChunks 进一步拆包。依赖审计警告不会阻断构建,但应单独评估,不建议直接执行可能引入破坏性升级的 npm audit fix --force。5)关键代码讲解OCR 三级降级机制(app/core/ocr_client.py)OCR 是报告解析的入口。代码先调用华为云通用文字和表格识别;如果没有识别到有效文本,再调用百炼 OCR 模型;最后使用千问 VL 多模态模型兜底。每一级仅在返回非空文本时结束链路,所有方法失败时返回空结果并由解析状态机标记失败。async def recognize_medical_report(self, image_data: bytes, file_type: str = "image") -> dict: image_b64 = base64.b64encode(image_data).decode("utf-8") result = await self._try_huawei_ocr(image_b64) if result: return result result = await self._try_aliyun_ocr(image_b64, file_type) if result: return result result = await self._try_qwen_vl(image_b64, file_type) if result: return result logger.error("All OCR methods failed (huawei -> aliyun -> qwen-vl)") return {"text": "", "confidence": 0.0} 关键释义:base64.b64encode() 将图片转为云 OCR API 可接收的 Base64 内容;降级调用相互独立,单个云服务超时或配额不足不会立即终止处理;日志只记录调用结果和错误,不输出图片、AK/SK 或 API Key;最终空文本不是伪造成功结果,业务层会将报告标记为 FAILED,便于用户重新处理。报告解析状态机和指标风险判定(app/services/parse_service.py)报告处理依次进入 OCR_PROCESSING、EXTRACTING、INTERPRETING 和 COMPLETED。前端根据状态显示处理进度;任何未捕获异常都会进入 FAILED,避免报告长期停留在处理中。await self._update_status(report, ParseStatus.OCR_PROCESSING) ocr_text = await self._run_ocr(report) if not ocr_text or not ocr_text.strip(): report.status = ParseStatus.FAILED await self.db.flush() return await self._update_status(report, ParseStatus.EXTRACTING) indicators = self._extract_indicators(ocr_text) abnormal = [i for i in indicators if i.status != IndicatorStatus.NORMAL] if abnormal: names = [i.name for i in abnormal[:5]] report.abnormal_summary = f"发现{len(abnormal)}项异常指标: {', '.join(names)}" await self._update_status(report, ParseStatus.INTERPRETING) report.status = ParseStatus.COMPLETED指标提取阶段会校验名称长度、医学单位、数值与参考范围。高于上限标记为 HIGH,低于下限标记为 LOW;偏离比例达到 1.2 或 1.5 时,风险等级依次提升为中风险或高风险。大模型健康解读与本地降级(app/mcp/mcp_client.py)后端使用 OpenAI 兼容接口调用配置的大模型,系统提示词约束输出范围,用户提示词携带结构化指标。HTTP 状态异常通过 raise_for_status() 进入异常分支;大模型不可用时,generate_interpretation() 会调用本地规则生成基础解读,保证报告仍可查看。async def _call_llm(self, system_prompt: str, user_prompt: str, temperature: float = 0.7) -> str: if not self._can_call_llm(): raise AppException(40004, "LLM API 未配置") async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT, proxy=None) as client: response = await client.post( f"{self._llm_api_base}/chat/completions", json={ "model": self._llm_model, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], "temperature": temperature, }, headers={"Authorization": f"Bearer {self._llm_api_key}"}, ) response.raise_for_status() data = response.json() return data.get("choices", [{}])[0].get("message", {}).get("content", "") 登录失败返回业务错误而不是服务器内部错误(app/services/auth_service.py、app/core/exceptions.py)登录时先判断用户是否存在,再校验 bcrypt 密码。账号不存在或密码错误均抛出业务码 60003,统一异常处理器将其映射为 HTTP 401。这样错误凭据不会因为空用户访问、密码哈希异常或未处理业务异常而显示“服务器内部错误”。if user is None: raise AppException(60003, "账号或密码错误") if not verify_password(password, user.password_hash): user.login_fail_count += 1 if user.login_fail_count >= settings.LOGIN_MAX_FAILURES: user.status = AccountStatus.LOCKED user.locked_until = datetime.utcnow() + timedelta( minutes=settings.LOGIN_LOCK_MINUTES ) await self.db.flush() raise AppException( 60002, f"密码错误次数过多,账号已锁定{settings.LOGIN_LOCK_MINUTES}分钟", ) await self.db.flush() raise AppException( 60003, f"账号或密码错误,还剩{settings.LOGIN_MAX_FAILURES - user.login_fail_count}次机会", ) if exc.code in (60002, 50005): status_code = 429 elif exc.code in (60003, 60004): status_code = 401 连续失败达到阈值后账号临时锁定,可以降低暴力尝试风险;成功登录后失败次数会被清零,并签发访问令牌和刷新令牌。报告分享、二维码和访问限制(app/services/share_service.py)分享状态使用随机 UUID,默认30天有效。可选4位数字访问密码使用 bcrypt 哈希保存,数据库中不存储明文;同时支持访问次数上限、失败次数锁定和主动撤销。state_id = str(uuid.uuid4()) expire_time = datetime.utcnow() + timedelta( days=settings.SHARE_DEFAULT_EXPIRE_DAYS ) password_hash = None if access_password: if not access_password.isdigit() or len(access_password) != 4: raise AppException(10001, "访问密码需为4位数字") password_hash = _bcrypt.hashpw( access_password.encode("utf-8"), _bcrypt.gensalt() ).decode("utf-8") share_url = f"{settings.SHARE_BASE_URL}/share/{state_id}" qr = qrcode.QRCode(version=1, box_size=10, border=5) qr.add_data(share_url) qr.make(fit=True) H5 适老化语音播报(frontend/h5/src/views/SharePage.vue)H5 使用浏览器原生 Web Speech API,无需额外安装播放器。播报内容由健康解读、建议、注意事项和指标组成,语速设置为 0.8,优先选择中文语音;用户再次点击按钮时立即停止。function toggleSpeech() { if (!speechSupported.value) return const synth = window.speechSynthesis if (isSpeaking.value) { synth.cancel() isSpeaking.value = false return } speechUtterance = new SpeechSynthesisUtterance(getSpeechText()) speechUtterance.lang = 'zh-CN' speechUtterance.rate = 0.8 speechUtterance.pitch = 1.0 speechUtterance.volume = 1.0 synth.speak(speechUtterance) isSpeaking.value = true } H5 子路径适配(frontend/h5/vite.config.ts、frontend/h5/src/router/index.ts)生产环境将 H5 部署在 /h5/,构建资源路径和 Vue Router 必须使用同一个基础路径,否则在 Ubuntu Nginx 下刷新分享深层路由会出现资源 404 或空白页。export default defineConfig({ base: '/h5/', plugins: [vue()], }) const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/share/:stateId', name: 'SharePage', component: () => import('../views/SharePage.vue'), }, ], }) 6)打包并上传源码不要上传 .env 等本地敏感文件,也不要上传真实 elderlycare.db、测试数据库、医疗图片、.venv、node_modules 和 dist。在 Windows PowerShell 的项目根目录执行:tar.exe -czf elderlycare-deploy.tar.gz ` --exclude='.venv' ` --exclude='*/node_modules' ` --exclude='*/dist' ` --exclude='.env' ` --exclude='elderlycare.db' ` --exclude='test.db' ` --exclude='*.jpg' ` . scp .\elderlycare-deploy.tar.gz root@<ECS公网IP>:/tmp/ scp .\.env root@<ECS公网IP>:/tmp/elderlycare.env ssh root@<ECS公网IP> 如果使用 SSH 密钥,则在三个 SSH/SCP 命令中增加 -i <私钥路径>。上传完成后,在 ECS 中执行:sudo mkdir -p /opt/elderlycare sudo tar -xzf /tmp/elderlycare-deploy.tar.gz -C /opt/elderlycare sudo install -m 600 /tmp/elderlycare.env /opt/elderlycare/.env cd /opt/elderlycare sudo chmod +x deploy_ecs.sh sudo ./deploy_ecs.sh也可以先在 Windows PowerShell 中单独上传仅保存在本地的 .env:scp .\.env root@<ECS公网IP>:/tmp/elderlycare.env ssh root@<ECS公网IP> 登录 ECS 后,直接通过 GitCode 拉取公开源码并部署:/opt/elderlycare 是 Ubuntu ECS 上的绝对安装目录,不是 GitCode 仓库中的 opt 文件夹。git clone 命令的第二个参数会把仓库内容直接检出到该目录;后续部署脚本、Nginx 和 systemd 均统一使用此路径。sudo mkdir -p /opt/elderlycare sudo chown -R "$USER:$USER" /opt/elderlycare git clone https://gitcode.com/SDSXshlbz/elderlycare-ai.git /opt/elderlycare sudo install -m 600 /tmp/elderlycare.env /opt/elderlycare/.env cd /opt/elderlycare sudo chmod +x deploy_ecs.sh sudo ./deploy_ecs.sh关键节点说明:GitCode 只传输可公开的源码和合成演示截图,.env 必须沿独立安全通道上传。后续更新可在确认服务器无未提交修改后执行 git pull --ff-only,再重新运行 deploy_ecs.sh;数据库和用户上传文件应提前备份,不能用代码仓库代替业务数据备份。7)理解并执行一键部署脚本deploy_ecs.sh 使用 set -euo pipefail,任一关键命令失败都会终止部署。脚本执行七个阶段:安装系统依赖、准备运行用户、安装 Python 依赖、构建 PC/H5、配置 Nginx、注册 systemd 服务、执行健康检查。Nginx 的关键配置如下:location /h5/ { alias /opt/elderlycare/frontend/h5/dist/; index index.html; try_files $uri $uri/ /h5/index.html; } location /api/ { proxy_pass http://127.0.0.1:8005; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 120s; } location / { root /opt/elderlycare/frontend/pc/dist; index index.html; try_files $uri $uri/ /index.html; } try_files 是 PC 和 H5 深层路由刷新可用的关键。后端服务使用独立用户运行,并等待网络和 Redis:[Unit] After=network-online.target redis-server.service Wants=network-online.target redis-server.service [Service] User=elderlycare Group=elderlycare WorkingDirectory=/opt/elderlycare ExecStart=/opt/elderlycare/.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8005 Restart=always RestartSec=5 脚本结束前会轮询后端健康接口。成功时输出:Backend health check passed. ======================================== Deployment completed PC: http://<ECS_PUBLIC_IP>/ H5: http://<ECS_PUBLIC_IP>/h5/ Health: http://<ECS_PUBLIC_IP>/health ========================================8)记录数据库版本并检查服务项目启动时通过 SQLAlchemy create_all() 创建当前模型表。首次部署成功后,执行 alembic stamp head 记录当前迁移基线,不重复执行已由模型创建的历史表结构:cd /opt/elderlycare source .venv/bin/activate alembic stamp head deactivate sudo nginx -t sudo systemctl is-active nginx redis-server elderlycare curl --fail http://127.0.0.1:8005/health预期结果:nginx: configuration file /etc/nginx/nginx.conf test is successful active active active {"status":"ok","version":"1.0.0"}如健康检查失败,使用以下命令定位问题:sudo journalctl -u elderlycare -n 100 --no-pager sudo tail -n 100 /var/log/nginx/error.log常见问题与处理方法:现象原因处理方法deploy_ecs.sh: /bin/bash^MWindows CRLF 换行将脚本保存为 LF 后重新上传H5 分享链接刷新后 404Vite 基础路径或 Nginx 回退错误检查 base: '/h5/' 和 /h5/index.html登录显示服务器内部错误业务异常未映射或数据库字段不完整查看 journalctl,确认 60003 返回 401,数据库版本已记录上传后一直处理中OCR、OBS 或大模型配置错误检查 .env、OBS 区域、OCR权限及后端日志CSS/模块在 Ubuntu 找不到Windows 文件名大小写不敏感统一源码导入路径与真实文件名大小写9)安全收尾部署完成后删除包含密钥的临时文件和部署压缩包,并在安全组中删除临时 22 端口规则:rm -f /tmp/elderlycare.env /tmp/elderlycare-deploy.tar.gz服务器上的 /opt/elderlycare/.env 权限应为 600,所有者应为 elderlycare:sudo stat -c '%U:%G %a %n' /opt/elderlycare/.env预期输出:elderlycare:elderlycare 600 /opt/elderlycare/.env3.3 运行效果展示本案例已部署到华为云北京四 ECS。演示环境仅使用合成数据,环境信息如下:项目演示环境信息区域北京四 cn-north-4ECS实例elderlycare-demo,实例ID 5d96458e-5a74-47e7-8859-b78c14be13a8规格与系统c7.large.2,2 vCPU / 4 GiB,Ubuntu 22.04公网IP1.92.111.234PC端URLhttp://1.92.111.234/H5端URLhttp://1.92.111.234/h5/健康检查http://1.92.111.234/health,HTTP 200GitCode源码https://gitcode.com/SDSXshlbz/elderlycare-ai普通功能测试账号邮箱:demo@elderlycare.cn密码:Demo2026!应用后台管理员测试账号邮箱:admin20260726@elderlycare.cn密码:Sc9!Vt4#Lq7@Hm2普通账号用于演示报告上传、解读、趋势、对比和分享;管理员账号登录后进入后台管理页面,用于演示系统概览、用户列表、账号状态和角色权限。演示 ECS 当前为运行状态,公网安全组不保留 SSH 入站规则,只开放作品 Web 演示所需端口。当前地址使用 HTTP,仅用于作品演示,不要录入真实个人信息、医疗报告或其他敏感数据。正式上线应配置域名、HTTPS、数据库备份、日志脱敏和监控告警。核心功能1:报告上传与年度报告管理登录后进入工作台,选择家庭成员后拖拽或点击上传 PDF、JPG、PNG 文件。页面同步展示解析状态和历史报告。演示账号已准备 2024、2025、2026 三份年度报告,用于展示长期健康变化。关键节点说明:上传请求先校验文件数量、扩展名和大小,再将报告与家庭成员关联。后端完成 OCR、指标提取和解读后,状态变为“已完成”,点击报告名称进入详情。核心功能2:智能健康解读与饮食计划报告详情将异常指标转换为通俗说明,展示风险提示、健康建议、推荐食物、忌口食物、每日食谱和指标参考范围。每份解读固定展示医疗免责声明。执行后效果:2026年度合成报告识别出空腹血糖、总胆固醇、尿酸、收缩压和 BMI 偏高,并给出饮食控制、运动、定期监测等建议;血红蛋白显示为正常。核心功能3:历次指标趋势和异常变化提醒进入 趋势分析,可以按家庭成员和指标筛选。ECharts 折线图按报告时间排序,并使用参考范围背景区间辅助判断。执行后效果:演示数据包含6个指标,每个指标有3个年度数据点。系统可识别“正常转异常”“异常恢复”“状态变化”和“数值变化”,当前演示数据生成12条异常变化记录。核心功能4:两份报告逐项对比进入 报告对比,选择较早报告 A 与较近报告 B,点击 开始对比。后端按指标名称求交集,计算 报告B数值 - 报告A数值,并按变化绝对值排序。执行后效果:2024与2026两份报告共有6项指标。尿酸由 330 umol/L 变为 455 umol/L,收缩压由 128 mmHg 变为 148 mmHg,状态从正常变为偏高;页面同时展示 BMI、空腹血糖、总胆固醇和血红蛋白的变化。核心功能5:适老化 H5 分享与语音播报在报告详情中生成分享链接或二维码,父母通过手机打开 H5。页面采用大字号、高行距、明显风险标签和大尺寸圆形播报按钮;点击播放按钮后,图标切换为暂停,再次点击立即停止。执行后效果:在 390 × 844 移动视口下页面无横向溢出,分享接口返回6项指标;语音播报可以正常开始与暂停,H5 深层路由及生产资源均返回 HTTP 200。核心功能6:家庭成员管理进入 家庭成员,可维护父母等家庭成员的关系、性别、出生日期、身高、体重、慢性病、过敏史、用药和备注。报告关联成员后,趋势与对比功能可以按成员筛选,避免多人报告混合统计。核心功能7:应用后台管理与角色权限管理员登录后可查看系统用户、报告和分享数量,按角色或账号状态筛选用户,并执行受保护的管理操作。注册页面不提供管理员角色选择,普通用户不能通过修改前端参数自行获得管理员权限;管理员接口由后端角色依赖统一保护,并具有禁止管理员自降级和最后一名管理员保护逻辑。执行后效果:管理员账号可进入 /admin 页面,普通演示账号访问同一路由时会被前端守卫拦截,直接请求管理员 API 时后端返回 HTTP 403。角色变更记录写入审计日志,便于追溯操作者、目标用户和变更时间。功能验收结果验证项实际结果正确账号登录HTTP 200,进入工作台错误账号登录HTTP 401,返回“账号或密码错误”,无服务器内部错误报告列表3份合成年度报告趋势分析6项指标,每项3个年度数据点异常变化12条变化记录报告对比6项共有指标及数值、状态变化H5分享返回健康解读、饮食计划和6项指标二维码返回 image/png管理员权限管理员可访问后台,普通用户访问管理员接口返回 HTTP 403PC/H5控制台无错误或警告Ubuntu服务Nginx、Redis、FastAPI均为 active自动化测试54 passed演示视频已上传至gitcode四、释放资源4.1 删除ECS弹性云服务器登录华为云控制台,进入 服务列表 > 计算 > 弹性云服务器 ECS。在 ECS 列表勾选本案例创建的服务器,点击 更多 > 删除。在确认对话框中勾选 释放云服务器绑定的弹性公网IP地址;如创建了额外数据盘,同时勾选删除对应数据盘。核对实例名称和公网 IP,确认无需要保留的数据后点击 是。进入 网络 > 弹性公网IP和带宽,确认 EIP 已释放;进入 对象存储服务 OBS,删除不再需要的报告、二维码和桶;进入安全组确认不存在遗留的公网 22 端口规则。删除 ECS、EIP、系统盘或 OBS 数据属于不可逆操作。释放前请先备份需要保留的源码、数据库和日志。仅关机通常仍会产生系统盘、EIP等费用,应以控制台资源状态和账单为准。五、扩展资料说明华为云 ECS:cid:link_2华为云 OBS:cid:link_4华为云 OCR:cid:link_1华为云开发者空间:cid:link_6华为云码道 CodeArts:cid:link_7银发智伴 GitCode 源码:https://gitcode.com/SDSXshlbz/elderlycare-aiFastAPI:https://fastapi.tiangolo.com/Vue 3:https://vuejs.org/Nginx:https://nginx.org/en/docs/阿里云百炼 Model Studio:https://help.aliyun.com/zh/model-studio/
-
在跨端开发(如 Web、iOS、Android、小程序)中,状态管理是决定应用性能与用户体验的关键枢纽。由于各端渲染机制与存储特性存在差异,构建全局统一的状态管理方案不能仅靠简单的数据共享,而需要从分层架构、模型抽象、同步机制与工程化实践四个核心维度进行系统性设计。首先,在架构设计上,必须采用“分层管理”策略以应对不同端的环境差异。最底层的存储层需适配各端特性,例如在 Web 端结合 IndexedDB 实现大容量离线存储,在移动端则利用原生文件系统保障数据安全。中间的状态逻辑层负责处理所有的状态变更,将复杂的业务逻辑与具体的端环境彻底解耦。最上层的状态交互层则根据各端渲染机制(如 Web 的 DOM 操作或移动端的响应式数据流),将状态变化精准传递给视图层。同时,在应用内部应实行全局与局部状态的分级管控,将用户信息、全局配置等交由 Redux Toolkit 等全局库统一管控,而页面私有状态则通过 Context API 等轻量级方案处理,避免全局状态冗余。其次,定义一套“抽象状态模型”是实现跨端统一的核心。跨端状态管理的本质不是将状态简单复制,而是让各端基于统一规则推导一致状态。通过抽象出如 UserState 这样的通用数据模型,不同端的开发人员都能基于同一套模型进行开发。在状态更新时,各端必须调用统一的状态更新函数,这不仅保证了状态变更的一致性,也极大地方便了状态的序列化与反序列化,为跨端数据传输奠定基础。第三,在多端协同与数据同步场景下,需引入事件驱动与冲突解决机制。当状态发生变化时,通过发布事件通知相关模块,各端视图层订阅事件并自动更新。对于多设备同时在线的复杂场景,必须区分哪些状态需要同步(如服务端权威的业务数据、用户偏好),哪些属于设备局部状态(如窗口大小、滚动位置)。针对多端并发修改引发的冲突,可采用类似 Git 的版本控制思想记录版本号,结合“乐观更新”与“最后写入优先”等策略,在保障数据一致性的同时提供流畅的用户体验。最后,在工程化落地与调试层面,需借助完善的工具链保障方案可靠性。在状态更新时,可引入 Immer 等不可变数据更新库,简化操作并避免直接修改状态带来的隐患。同时,建立严格的测试与调试机制至关重要。通过编写单元测试验证状态更新逻辑的正确性,并利用各端的开发者工具实时监控状态变化。对于复杂应用,还可以引入有限状态机(如 XState)来定义状态流转规则,并将副作用抽离为独立服务,从而确保跨端逻辑的绝对一致。综上所述,跨端项目的全局统一状态管理是一项兼顾架构抽象与工程细节的系统工程。通过分层解耦、统一模型、事件驱动以及严谨的冲突处理机制,开发者能够彻底解决多端状态不同步的痛点,为跨端应用的高效迭代提供坚实保障。
-
OpenTiny NEXT 前端智能化系列直播 ——第五期带你了解TinRobot,讲师将带来《TinyRobot助你一站式搞定AI界面开发》的主题分享。AI应用落地日趋广泛,智能交互产品形态愈发多元,市面通用开发组件难以契合AI交互特质,开发效率与适配效果存在短板,专属开发方案成为刚需。本次分享将介绍TinyRobot组件库定位,展示完整AI界面效果,实操搭建页面并讲解场景扩展能力,带你一站式完成AI界面开发。全程干货、可落地、可实战,更有一手课程资料、贡献者证书、AtomGit & OpenTiny 周边好礼等你来拿!主题:TinyRobot助你一站式搞定AI界面开发时间:05/26(周二) 19:00-20:00 (UTC+08:00)Beijing更多精彩活动1.评论区提问赢好礼,专家现场解答每期直播开始前,前往AtomGit 活动帖评论区提问,围绕本期直播主题与 AI 前端技术发布你的问题,即可参与!活动评论区:cid:link_02.实战出真知,OpenTiny 前端智能化实践营本次 OpenTiny NEXT 系列直播每期配套专属实战任务,让你边学边练、学完即用,真正把 AI 前端技术落地掌握。你只需在 AtomGit 平台关注并 Star OpenTiny 组织,Fork 对应仓库,根据直播内容完成实践任务,提交你的代码 PR,即可参与活动。所有提交的作品将由专业团队评审,优质实践作品将获得 AtomGit & OpenTiny 定制周边奖励,优秀 PR 还会被合并进 OpenTiny 官方仓库,成为开源贡献者,获得贡献者证书,为你的技术履历加分。AtomGit 地址:cid:link_23.参与征文有奖活动期间参与 OpenTiny NEXT 前端智能化系列征文活动,无论你是直播学习者、技术爱好者,还是开源实践者,均可围绕 AI 前端、WebMCP、WebAgent、TinyVue、TinyEngine、GenUI 、TinyRobot、AI Extension等相关技术与实战体验进行创作。征文活动:cid:link_1参与&领奖进群提前锁定福利+免费领取课程资料(见海报下方)仅有效提问、issue、PR、投稿可参与活动,灌水等无意义内容将取消活动资格本次活动解释权归 OpenTiny 团队和 AtomGit 平台所有
-
1.1 问题说明在基于UniApp开发鸿蒙元服务时,开发者无法直接将项目运行到鸿蒙模拟器进行调试。必须通过HBuilderX将项目编译运行到鸿蒙真机设备,然后将生成的编译产物手动迁移至鸿蒙ACEF(ArkUI Compiler & Engine Framework)项目的指定目录,才能完成模拟器运行与调试。此流程复杂且容易出错,增加了开发者的学习成本和调试难度。1.2 原因分析· 核心原因:HBuilderX工具链暂未集成鸿蒙X86模拟器支持当前版本的HBuilderX在编译UniApp项目至鸿蒙平台时,其内置的打包与运行逻辑主要针对ARM架构的真机设备进行优化和适配。由于未包含对鸿蒙官方X86模拟器运行环境的直接支持,导致“运行到模拟器”的选项缺失或无法成功执行。· 衍生问题:双工具链职责分离此限制迫使开发流程必须在两个工具间切换:使用 HBuilderX 完成面向鸿蒙真机的业务代码编译,生成标准的编译产物;再使用 DevEco Studio 作为鸿蒙原生工程的容器和签名工具,将上述产物导入并打包成可在真机上运行的HAP文件。· 结果:调试链路断裂最直接的“编码->模拟器调试”快速验证链路被打断,开发者必须依赖实体真机进行每一步调试,效率降低,且入门门槛提高。1.3 解决思路· 正视工具链限制,确立迂回流程明确接受当前HBuilderX不支持直连模拟器的现状,将“真机编译+产物迁移”确立为标准的手动工作流。· 建立目录映射与手动拷贝机制在鸿蒙ACEF项目中预设固定目录结构,通过清晰的步骤指引,将UniApp编译产物手动复制到对应位置,实现两者集成。· 明确双工具分工清晰界定HBuilderX与DevEco Studio的角色:HBuilderX负责Vue业务逻辑到鸿蒙格式的转换编译;DevEco Studio负责提供原生项目框架、进行签名打包和真机部署。1.4 解决方案HBuilderX中配置鸿蒙平台在manifest.json中启用“鸿蒙(HarmonyOS)”平台支持。配置应用基本信息(包名、版本号等)编译生成鸿蒙适配包并迁移在HBuilderX中,选择菜单栏的【发行】->【原生App-本地打包】->【生成本地打包App资源】。生成目录:项目根目录/unpackage/dist/dev/harmonyos。此目录下的文件即为需要迁移的产物。构建并运行在DevEco Studio顶部工具栏选择模拟器运行。1.5 总结· 问题与痛点: HBuilderX工具链暂不支持鸿蒙X86模拟器,导致无法直接调试;必须采用手动迁移编译产物的迂回方案,流程繁琐。· 技术要点: 明确“HBuilderX编译 -> 手动复制产物 -> DevEco Studio打包签名”的分工协作流程。核心操作是将/unpackage/dist/dev/harmonyos/下的内容复制到ACEF项目的ets/uni_modules/目录。· 实现效果: 通过此标准化手动流程,开发者可以成功将UniApp项目以鸿蒙元服务的形式运行在真机上,完成开发和调试工作。· 适用场景: 当前阶段所有使用UniApp开发鸿蒙元服务的项目。
-
npm run dev> app@0.0.0 dev> vitefile:///data/storage/el2/base/files/IDEProjects/demo/node_modules/rolldown/dist/shared/binding-eAM5CAoo.mjs:514 if (loadErrors.length > 0) throw new Error("Cannot find native binding. npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). Please try `npm i` again after removing both package-lock.json and node_modules directory.", { cause: loadErrors.reduce((err, cur) => { ^Error: Cannot find native binding. npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). Please try `npm i` again after removing both package-lock.json and node_modules directory. at file:///data/storage/el2/base/files/IDEProjects/demo/node_modules/rolldown/dist/shared/binding-eAM5CAoo.mjs:514:36 at file:///data/storage/el2/base/files/IDEProjects/demo/node_modules/rolldown/dist/shared/binding-eAM5CAoo.mjs:10:48 at file:///data/storage/el2/base/files/IDEProjects/demo/node_modules/rolldown/dist/shared/parse-cfElc8c8.mjs:41:46 at ModuleJob.run (node:internal/modules/esm/module_job:413:25) at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:660:26) at async CAC.<anonymous> (file:///data/storage/el2/base/files/IDEProjects/demo/node_modules/vite/dist/node/cli.js:570:27) { [cause]: Error: Error loading shared library /data/storage/el2/base/files/IDEProjects/demo/node_modules/@rolldown/binding-openharmony-arm64/rolldown-binding.openharmony-arm64.node: Permission denied at Module._extensions..node (node:internal/modules/cjs/loader:1920:18) at Module.load (node:internal/modules/cjs/loader:1481:32) at Module._load (node:internal/modules/cjs/loader:1300:12) at TracingChannel.traceSync (node:diagnostics_channel:328:14) ... 2 lines matching cause stack trace ... at require (node:internal/modules/helpers:152:16) at requireNative (file:///data/storage/el2/base/files/IDEProjects/demo/node_modules/rolldown/dist/shared/binding-eAM5CAoo.mjs:444:21) at file:///data/storage/el2/base/files/IDEProjects/demo/node_modules/rolldown/dist/shared/binding-eAM5CAoo.mjs:482:18 at file:///data/storage/el2/base/files/IDEProjects/demo/node_modules/rolldown/dist/shared/binding-eAM5CAoo.mjs:10:48 { code: 'ERR_DLOPEN_FAILED', cause: Error: Cannot find module './rolldown-binding.openharmony-arm64.node' Require stack: - /data/storage/el2/base/files/IDEProjects/demo/node_modules/rolldown/dist/shared/binding-eAM5CAoo.mjs at Module._resolveFilename (node:internal/modules/cjs/loader:1421:15) at defaultResolveImpl (node:internal/modules/cjs/loader:1059:19) at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1064:22) at Module._load (node:internal/modules/cjs/loader:1227:37) at TracingChannel.traceSync (node:diagnostics_channel:328:14) at wrapModuleLoad (node:internal/modules/cjs/loader:245:24) at Module.require (node:internal/modules/cjs/loader:1504:12) at require (node:internal/modules/helpers:152:16) at requireNative (file:///data/storage/el2/base/files/IDEProjects/demo/node_modules/rolldown/dist/shared/binding-eAM5CAoo.mjs:439:12) at file:///data/storage/el2/base/files/IDEProjects/demo/node_modules/rolldown/dist/shared/binding-eAM5CAoo.mjs:482:18 { code: 'MODULE_NOT_FOUND', requireStack: [ '/data/storage/el2/base/files/IDEProjects/demo/node_modules/rolldown/dist/shared/binding-eAM5CAoo.mjs' ] } }}Node.js v24.13.0
-
在 Vue 中,不能直接侦听响应式对象的属性值(如 obj.property),而需要使用返回该属性的 getter 函数(如 () => obj.property),这主要与 Vue 的响应式系统实现机制和 JavaScript 的限制有关。以下是具体原因:1. JavaScript 对象的限制在 JavaScript 中,直接传递一个属性引用(如 obj.property)时,你传递的是该属性的当前值,而不是对属性的引用。Vue 无法自动追踪这个值的变化,因为它只是一个静态值。例如:const obj = reactive({ count: 0 }); const value = obj.count; // 这里只是读取了当前值 0 // 后续 obj.count 变化时,value 仍然是 0,没有绑定关系 2. Vue 的响应式依赖收集Vue 的响应式系统(如 watch 或 computed)需要在数据变化时重新执行回调函数。为了实现这一点,它需要在读取数据时收集依赖。当你使用 getter 函数(如 () => obj.property)时,Vue 会在函数执行时(即读取 obj.property 时)捕获当前的响应式依赖,从而建立绑定关系。直接传递属性值无法触发这种依赖收集。3. Getter 函数的动态性Getter 函数(如 () => obj.property)是一个动态操作,每次执行时都会重新读取属性的当前值。Vue 可以通过拦截这个读取操作(通过 Proxy 或 Object.defineProperty)来追踪变化。例如:watch( () => obj.count, // 每次 obj.count 被读取时,Vue 会记录这个依赖 (newVal) => { console.log('count changed:', newVal); } ); 4. 深层嵌套属性的侦听如果直接侦听 obj.property,Vue 无法区分你是想侦听 property 本身还是 obj 的变化。而通过 getter 函数,可以明确指定侦听的目标:// 侦听 obj.property 的变化 watch(() => obj.property, (newVal) => { ... }); // 侦听整个 obj 的变化(包括新增/删除属性) watch(obj, (newObj) => { ... }, { deep: true }); 5. 与 computed 的设计一致性Vue 的 computed 属性也要求通过函数返回依赖值,这样可以在依赖变化时自动重新计算。watch 的 getter 函数设计与之保持一致,简化内部实现。替代方案:直接传递 ref如果是 ref 对象,可以直接传递 .value 的引用(因为 ref 本身是一个包装对象,Vue 内部通过 value 属性实现响应式):const count = ref(0); watch(count, (newVal) => { ... }); // 等价于 watch(() => count.value, ...) 但对于普通响应式对象(reactive),仍然需要 getter 函数。总结Vue 要求通过 getter 函数侦听属性值,是因为:JavaScript 无法直接传递属性引用。Getter 函数允许 Vue 在运行时捕获依赖。这种设计保持了响应式系统的统一性和可预测性。如果需要侦听嵌套属性,还可以使用字符串路径(如 watch(obj, 'property', ...),但 getter 函数是更推荐的方式。
-
在 Vue 中,不能直接侦听响应式对象的属性值(如 obj.property),而需要使用返回该属性的 getter 函数(如 () => obj.property),这主要与 Vue 的响应式系统实现机制和 JavaScript 的限制有关。以下是具体原因:1. JavaScript 对象的限制在 JavaScript 中,直接传递一个属性引用(如 obj.property)时,你传递的是该属性的当前值,而不是对属性的引用。Vue 无法自动追踪这个值的变化,因为它只是一个静态值。例如:const obj = reactive({ count: 0 }); const value = obj.count; // 这里只是读取了当前值 0 // 后续 obj.count 变化时,value 仍然是 0,没有绑定关系 2. Vue 的响应式依赖收集Vue 的响应式系统(如 watch 或 computed)需要在数据变化时重新执行回调函数。为了实现这一点,它需要在读取数据时收集依赖。当你使用 getter 函数(如 () => obj.property)时,Vue 会在函数执行时(即读取 obj.property 时)捕获当前的响应式依赖,从而建立绑定关系。直接传递属性值无法触发这种依赖收集。3. Getter 函数的动态性Getter 函数(如 () => obj.property)是一个动态操作,每次执行时都会重新读取属性的当前值。Vue 可以通过拦截这个读取操作(通过 Proxy 或 Object.defineProperty)来追踪变化。例如:watch( () => obj.count, // 每次 obj.count 被读取时,Vue 会记录这个依赖 (newVal) => { console.log('count changed:', newVal); } ); 4. 深层嵌套属性的侦听如果直接侦听 obj.property,Vue 无法区分你是想侦听 property 本身还是 obj 的变化。而通过 getter 函数,可以明确指定侦听的目标:// 侦听 obj.property 的变化 watch(() => obj.property, (newVal) => { ... }); // 侦听整个 obj 的变化(包括新增/删除属性) watch(obj, (newObj) => { ... }, { deep: true }); 5. 与 computed 的设计一致性Vue 的 computed 属性也要求通过函数返回依赖值,这样可以在依赖变化时自动重新计算。watch 的 getter 函数设计与之保持一致,简化内部实现。替代方案:直接传递 ref如果是 ref 对象,可以直接传递 .value 的引用(因为 ref 本身是一个包装对象,Vue 内部通过 value 属性实现响应式):const count = ref(0); watch(count, (newVal) => { ... }); // 等价于 watch(() => count.value, ...) 但对于普通响应式对象(reactive),仍然需要 getter 函数。总结Vue 要求通过 getter 函数侦听属性值,是因为:JavaScript 无法直接传递属性引用。Getter 函数允许 Vue 在运行时捕获依赖。这种设计保持了响应式系统的统一性和可预测性。如果需要侦听嵌套属性,还可以使用字符串路径(如 watch(obj, 'property', ...),但 getter 函数是更推荐的方式。
-
在 Vue 的事件修饰符中,系统按键修饰符(如 .ctrl、.alt、.shift、.meta)与常规按键修饰符(如 .enter、.esc)的行为不同,尤其是在与 keyup 事件配合使用时。这种差异源于浏览器原生事件的机制和 Vue 的封装逻辑。以下是详细解释和示例:🔍 核心区别1. 常规按键修饰符(如 .enter)行为:直接监听特定按键的 keyup 或 keydown 事件。示例:<input @keyup.enter="submitForm" /> 当用户按下 Enter 键并松开时,submitForm 会被触发。不依赖其他按键状态:无论是否同时按住 Ctrl、Shift 等,只要松开 Enter 就会触发。2. 系统按键修饰符(如 .ctrl)行为:监听 组合键 的 keyup 事件,且要求目标按键松开时,系统修饰键(如 Ctrl)必须仍处于按下状态。示例:<div @keyup.ctrl="handleCtrlRelease">松开 Ctrl 键</div> 不会触发:如果你单独松开 Ctrl 键(没有其他按键操作),handleCtrlRelease 不会被调用。会触发:如果你按住 Ctrl + A,然后松开 A(此时 Ctrl 仍按住),keyup.ctrl 会触发。💡 为什么这样设计?浏览器原生行为:系统修饰键(Ctrl、Alt 等)通常用于组合快捷键(如 Ctrl+C)。浏览器默认不会为单独按下/松开 Ctrl 触发 keyup 事件,除非它与其他键组合使用。Vue 的修饰符遵循了这一逻辑。避免误触:如果 keyup.ctrl 在单独松开 Ctrl 时触发,可能会导致意外行为(例如用户无意松开 Ctrl 时触发操作)。🛠️ 如何监听单独松开 Ctrl 键?如果需要检测 Ctrl 键的单独松开(不依赖其他键),可以使用原生 keydown/keyup 事件配合 event.key 或 event.ctrlKey:方法 1:直接监听 keyup 并检查 event.ctrlKey<div @keyup="handleKeyUp">监听所有按键松开</div> methods: { handleKeyUp(event) { if (event.key === 'Control') { console.log('Ctrl 键被松开'); } } } 方法 2:使用 @keyup.exact(精确匹配无修饰键)如果需要确保 Ctrl 是唯一被按下的键时才触发:<div @keyup.exact.ctrl="handleExactCtrl">仅当松开 Ctrl 且无其他键按下时触发</div> 📌 完整示例对比场景 1:监听 Ctrl + S 组合键松开<input @keyup.ctrl.s="saveData" placeholder="按 Ctrl+S 保存" /> 触发条件:按住 Ctrl + S,然后松开 S(Ctrl 仍按住)。场景 2:监听单独松开 Ctrl 键<div @keyup="checkCtrlRelease" tabindex="0">点击后松开 Ctrl</div> methods: { checkCtrlRelease(event) { if (event.key === 'Control') { console.log('Ctrl 键被单独松开'); } } } ⚠️ 注意事项tabindex 属性:非输入元素(如 <div>)需要添加 tabindex="0" 才能接收键盘事件。浏览器兼容性:event.key 的值(如 'Control')在所有浏览器中一致,但旧版浏览器可能用 keyCode(已废弃)。Vue 的 .exact 修饰符:可以精确控制触发条件,例如 @keyup.ctrl.exact 仅在松开 Ctrl 且无其他修饰键时触发。📝 总结修饰符类型示例触发条件常规按键@keyup.enter松开 Enter 键时触发,无关其他按键状态。系统按键@keyup.ctrl松开某个键时,Ctrl 必须仍处于按下状态(通常用于组合键)。精确匹配@keyup.exact.ctrl仅当松开 Ctrl 且无其他修饰键(如 Shift、Alt)时触发。原生事件检查@keyup + event.key通过 JavaScript 检查 event.key === 'Control' 来监听单独松开 Ctrl。根据需求,选择合适的方式:如果是组合键逻辑,用系统修饰符;如果是单独监听 Ctrl,用原生事件检查。
-
在 Vue 3 的 Composition API 中,ref 和 reactive 都可以用于创建响应式数据,但在你的例子中,使用 ref 而不是 reactive 来包装数组 numbers 主要有以下几个原因:1. ref 更通用,适合任意类型的数据ref 可以包装任意类型的数据(基本类型、对象、数组等),而 reactive 只能包装对象(包括数组,因为数组在 JS 中也是对象)。你的 numbers 是一个数组,虽然 reactive 也能处理数组,但 ref 的通用性更强,尤其是在不确定数据类型会如何变化时(比如未来可能从数组变成基本类型)。2. ref 的 .value 操作更明确ref 通过 .value 访问和修改数据,这种显式操作在复杂逻辑中更清晰,尤其是当你在多个地方使用 numbers 时,可以一眼看出哪些地方在修改响应式数据。例如:numbers.value.push(6); // 明确知道是在修改响应式数据 如果用 reactive,直接操作数组(如 numbers.push(6))可能会让代码的可读性下降(尤其是当 numbers 是从函数返回或解构出来时)。3. computed 的依赖追踪更直观在 computed 中,ref 的 .value 变化会被精确追踪。虽然 reactive 也能触发 computed 更新,但在某些嵌套对象或复杂场景下,ref 的依赖追踪更可靠。你的例子中,evenNumbers 依赖于 numbers.value,用 ref 可以确保 numbers.value 的任何变化(包括整个数组被替换)都能正确触发计算。4. 解构和传递更灵活ref 可以直接解构或传递,而不会丢失响应性(只要保留 .value 的引用)。例如:const { value: numbersValue } = numbers; // 解构后仍可通过 numbersValue 操作 如果用 reactive,解构后会丢失响应性(除非用 toRefs)。5. Vue 官方示例的惯例Vue 官方文档和示例中,基本类型或数组/对象可能变化时,更倾向于用 ref,因为它的行为更一致(总是通过 .value 访问)。reactive 更多用于明确是对象且结构稳定的场景(如一个包含多个属性的配置对象)。❓ 什么时候会用 reactive?如果 numbers 是一个对象,且你需要直接操作它的属性(而不是替换整个对象),reactive 可能更合适:const state = reactive({ numbers: [1, 2, 3, 4, 5], otherData: "foo" }); // 直接操作属性 state.numbers.push(6); // 无需 .value 📌 总结特性refreactive适用类型任意类型(基本类型、对象、数组)仅对象(包括数组)访问方式通过 .value直接访问属性解构/传递需保留 .value 引用需用 toRefs 避免丢失响应性替换整个数据直接赋值 numbers.value = [...]需特殊处理(如 Object.assign)代码清晰度显式 .value 更明确隐式响应式可能让人困惑你的例子中:numbers 是一个数组,且可能被整体替换(如 numbers.value = [10, 20]),用 ref 更安全。如果未来 numbers 的结构变得更复杂(比如变成一个对象 { list: [1, 2, 3], loading: false }),用 reactive 可能更合适。🛠️ 最佳实践建议:优先用 ref,除非你明确需要一个响应式对象且不需要替换整个对象。如果数据是对象且属性需要直接解构,用 reactive + toRefs:import { reactive, toRefs } from "vue"; const state = reactive({ numbers: [1, 2, 3] }); const { numbers } = toRefs(state); // 解构后仍保持响应性 在模板中,ref 会自动解套(无需 .value),所以两者在模板里的用法几乎一样。
-
在 Vue 中,如果组件有多个根元素(即多根节点组件),当父组件传递 class 或 style 时,Vue 不会自动将这些属性合并到所有根节点上,而是需要你显式指定哪个根元素接收这些属性。这是 Vue 3 的一个重要行为变化(Vue 2 不支持多根节点组件)。📌 问题场景假设有一个多根节点的组件:<!-- MyComponent.vue --> <template> <header>标题</header> <main>内容</main> <footer>页脚</footer> </template> 当父组件传递 class 时:<MyComponent class="parent-class" /> Vue 会发出警告:[Vue warn]: Component has multiple root nodes but received `class` attribute. The `class` will be discarded since the component doesn't have a single root element. 🔧 解决方案1. 使用 $attrs 手动绑定到特定根节点在 Vue 3 中,可以通过 $attrs 访问父组件传递的 class 和 style,然后手动绑定到某个根节点:<!-- MyComponent.vue --> <template> <header :class="$attrs.class">标题</header> <main>内容</main> <footer>页脚</footer> </template> 此时,parent-class 会被应用到 <header> 上。2. 使用 v-bind="$attrs" 批量绑定如果希望将所有父组件传递的属性(包括 class、style 等)绑定到某个根节点:<!-- MyComponent.vue --> <template> <div> <header v-bind="$attrs">标题</header> <main>内容</main> <footer>页脚</footer> </div> </template> 或者更灵活地选择目标节点:<template> <header>标题</header> <main v-bind="$attrs">内容</main> <footer>页脚</footer> </template> 3. 用 :class 动态合并类名如果需要合并父组件的 class 和组件自身的 class:<template> <header :class="['my-header', $attrs.class]">标题</header> <main>内容</main> </template> 4. 改用单根节点(推荐)如果逻辑允许,最佳实践是始终使用单根节点组件,避免多根节点的复杂性:<template> <div class="wrapper"> <header>标题</header> <main>内容</main> <footer>页脚</footer> </div> </template> 此时父组件的 class 会自动合并到根节点的 <div> 上。📝 关键点总结方案适用场景示例$attrs.class手动指定某个根节点接收 class<header :class="$attrs.class">v-bind="$attrs"批量绑定所有父组件属性到某个节点<main v-bind="$attrs">动态 :class合并父组件和自身的 class<header :class="['my-class', $attrs.class]">单根节点长期维护性最佳(推荐)用 <div> 包裹所有内容⚠️ 注意事项Vue 2 的限制:Vue 2 不支持多根节点组件,必须用单根节点。style 的处理:与 class 类似,父组件传递的 style 也需要通过 $attrs 显式绑定。Scoped CSS:如果组件启用了 scoped,父组件的 class 仍然可以作用到子组件的多根节点上(通过 $attrs 绑定后)。🛠️ 最终建议优先使用单根节点,避免多根节点的复杂性。如果必须多根节点,通过 $attrs 显式控制 class 和 style 的去向。在复杂组件中,可以结合 useAttrs()(Composition API)更灵活地处理属性:import { useAttrs } from 'vue'; export default { setup() { const attrs = useAttrs(); return { attrs }; } } <template> <header :class="attrs.class">标题</header> </template> 通过这种方式,你可以精确控制多根节点组件中父组件属性的应用位置。
-
在 Vue 的 v-for 中,key 的使用至关重要,但并非所有场景都需要显式指定 key。以下是详细的判断标准和最佳实践:📌 必须使用 key 的场景1. 列表项有动态变化(增删改、排序、过滤)原因:Vue 默认通过 key 跟踪每个节点的身份,从而高效地复用、重新排序或移除元素。如果没有 key,Vue 会使用“就地复用”策略,可能导致状态错乱(如输入框内容交换、动画异常等)。示例:<!-- 错误:无 key,交换顺序后输入框内容会错乱 --> <div v-for="(item, index) in list" :key="index"> <input v-model="item.value"> </div> <!-- 正确:用唯一 id 作为 key --> <div v-for="item in list" :key="item.id"> <input v-model="item.value"> </div> 2. 列表项包含有状态的组件(如 <input>、<select>、自定义组件)原因:组件内部状态(如输入值、选中项)需要与数据绑定。如果无 key,Vue 可能复用错误的组件实例,导致状态丢失或混乱。示例:<!-- 错误:无 key,切换选项后选中状态可能错误 --> <div v-for="item in options" :key="null"> <!-- 隐式使用 index 作为 key --> <CustomSelect v-model="selectedValue" :options="item.options" /> </div> <!-- 正确:用唯一 id 作为 key --> <div v-for="item in options" :key="item.id"> <CustomSelect v-model="selectedValue" :options="item.options" /> </div> 3. 需要动画或过渡效果(如 <transition-group>)原因:Vue 的过渡系统依赖 key 来区分新旧节点,从而正确触发进入/离开动画。示例:<transition-group name="fade" tag="ul"> <li v-for="item in list" :key="item.id">{{ item.text }}</li> </transition-group> ❌ 可以省略 key 的场景1. 静态列表(无动态变化)条件:列表内容固定,不会增删改、排序或过滤,且列表项是无状态的(如纯展示文本)。示例:<!-- 可省略 key(但建议仍用唯一值,避免潜在问题) --> <ul> <li v-for="item in ['苹果', '香蕉', '橙子']">{{ item }}</li> </ul> 2. 性能敏感且列表项完全相同(无状态)条件:列表项是纯展示的,且内容完全一致(如重复渲染相同组件)。此时 Vue 复用 DOM 能提升性能。示例:<!-- 渲染 100 个相同的占位符 --> <div v-for="i in 100" :key="null"> <!-- 显式禁用 key,强制复用 --> <PlaceholderComponent /> </div> 注意:这种场景极少见,且需谨慎使用,因为可能隐藏潜在问题。🔍 key 的最佳实践优先使用唯一标识符:如数据库 ID、UUID 等,而非数组索引(index)。<!-- 推荐 --> <div v-for="user in users" :key="user.id">{{ user.name }}</div> <!-- 不推荐(索引可能变化) --> <div v-for="(user, index) in users" :key="index">{{ user.name }}</div> 避免用随机数作为 key:随机数在每次渲染时会变化,导致 Vue 强制重新创建所有节点,性能极差。<!-- 错误:每次渲染 key 都不同 --> <div v-for="item in list" :key="Math.random()">{{ item.text }}</div> 在 <transition-group> 中必须用 key:否则动画会无法正常工作。📝 总结表场景是否需要 key推荐做法动态列表(增删改、排序)✅ 必须用唯一 ID(如 item.id)有状态组件(如 <input>)✅ 必须用唯一 ID,避免依赖 DOM 状态需要动画(<transition-group>)✅ 必须用唯一 ID静态列表(无变化)❌ 可省略可省略,但建议仍用唯一值性能敏感且列表项完全相同❌ 可省略显式设为 null 强制复用(谨慎使用)核心原则:只要列表可能变化或包含状态,就必须用 key;否则可能引发状态错乱或动画异常。 即使省略 key,Vue 也会隐式使用 index,但这通常不是最佳选择。
-
❌ 问题场景:表单输入错乱假设我们有一个列表,渲染两个输入框,用户可以在输入框中输入内容:<template> <div> <button @click="swapItems">交换列表顺序</button> <div v-for="(item, index) in list" :key="index"> <input :placeholder="item.placeholder"> </div> </div> </template> <script>export default { data() { return { list: [ { id: 1, placeholder: "请输入姓名" }, { id: 2, placeholder: "请输入年龄" } ] }; }, methods: { swapItems() { // 交换列表中两项的顺序 this.list.reverse(); } } }; </script> 操作步骤:用户在第一个输入框(“请输入姓名”)输入 "张三",在第二个输入框(“请输入年龄”)输入 "25"。点击“交换列表顺序”按钮,list 数组顺序被反转。Vue 默认会复用 DOM 元素(因为 key 用了 index,而索引 0 和 1 只是交换了位置)。问题表现:用户输入的内容 会跟着 DOM 元素移动,导致:原本输入 "张三" 的输入框(第一个)现在变成了 "25"(因为复用了第二个 <input>)。原本输入 "25" 的输入框现在变成了 "张三"。用户看到的输入内容突然交换了,但实际数据(list)并没有变,只是 DOM 被复用了。🔍 原因分析Vue 默认通过 key 来判断是否复用 DOM 元素:如果 key 是 index(如 :key="index"),交换数组顺序后,索引 0 和 1 只是换了位置,Vue 会认为这两个 <input> 可以直接复用,只是移动了位置。但 <input> 的当前值(用户输入的内容)是 DOM 的临时状态,Vue 不会自动帮你保存或同步它!结果就是:输入框的值跟着 DOM 元素移动,导致错乱。✅ 正确做法:唯一 key + 数据驱动方法 1:用唯一 id 作为 key,并避免依赖 DOM 状态<template> <div> <button @click="swapItems">交换列表顺序</button> <div v-for="item in list" :key="item.id"> <!-- 用 item.id 作为 key,且输入值绑定到数据 --> <input :placeholder="item.placeholder" v-model="item.value"> </div> <p>当前数据:{{ list }}</p> </div> </template> <script>export default { data() { return { list: [ { id: 1, placeholder: "请输入姓名", value: "" }, { id: 2, placeholder: "请输入年龄", value: "" } ] }; }, methods: { swapItems() { this.list.reverse(); // 交换顺序 } } }; </script> 关键改进:key 使用唯一标识(item.id):Vue 能准确知道每个列表项的身份,不会错误复用 DOM。输入值绑定到数据(v-model):用户输入的内容会实时同步到 list 的 value 字段,而不是依赖 DOM 的临时状态。交换顺序后:Vue 会根据 key 重新匹配 DOM 元素,但输入值已经保存在数据中,不会错乱。📌 总结问题场景(错误)正确做法用 index 作为 key用唯一 id 作为 key依赖 DOM 临时状态(如 <input> 的当前值)用 v-model 绑定到数据交换顺序后输入框内容错乱数据和 DOM 正确同步,无错乱核心原则:Vue 的默认复用策略要求列表渲染结果不依赖 DOM 的临时状态。如果列表项有交互(如表单输入),必须用 v-model 绑定数据,并用唯一 key 确保正确复用。这样就能避免“输入内容突然跑到其他输入框”的诡异问题了!
-
template是一个不可见的包装器元素,最后渲染的结果并不会包含这个 <template> 元素。<template v-if="ok"> <h1>Title</h1> <p>Paragraph 1</p> <p>Paragraph 2</p> </template> <template v-if="ok"> 改为 <div v-if="ok">,在最终渲染的 DOM 结构中,效果看起来可能是一样的(即三个元素都会根据 ok 的值显示或隐藏),但存在一些关键区别:1. DOM 结构差异<template>:作为不可见的包装器,Vue 在渲染时不会创建额外的 <template> DOM 元素。最终渲染结果直接是:<h1>Title</h1> <p>Paragraph 1</p> <p>Paragraph 2</p> (如果 ok 为 true)<div>:会多出一个 <div> 包装元素,渲染结果为:<div> <h1>Title</h1> <p>Paragraph 1</p> <p>Paragraph 2</p> </div> (如果 ok 为 true)2. 样式和布局影响如果外层样式或布局依赖于 DOM 结构(例如 CSS 选择器 div > h1),使用 <div> 可能会意外影响样式或布局。<template> 完全不会影响现有样式,因为它不存在于 DOM 中。3. 语义化<template> 更适合纯逻辑分组,不引入多余的语义。<div> 有语义含义(表示一个块级容器),可能不适合所有场景。4. 性能<template> 略微更高效,因为 Vue 会跳过它的实际 DOM 创建。何时用 <div>?如果确实需要一个包装元素(例如为了应用共同的样式或事件监听),或者需要兼容不支持 <template> 的旧版本 Vue,可以用 <div>。总结特性<template v-if><div v-if>渲染额外 DOM 元素❌ 无✅ 有影响样式/布局❌ 不会✅ 可能语义化✅ 更纯净⚠️ 有语义适用场景逻辑分组需要容器时建议:如果只是单纯切换多个元素的显示/隐藏,优先使用 <template v-if>,避免不必要的 DOM 节点。
-
Vue开发,你们现在一般用哪个UI库
推荐直播
-
华为云码道Skill实战与极速交付,智能开发全链路实战2026/07/22 周三 19:00-21:00
王一男-华为云码道产品规划专家;李炎-华为云码道产品专家;姜浩-华为云HCDG核心组成员
直播深度解读华为云码道6月产品新特性,从Skill市场安装专家技能,带你零距离体验从需求,开发,审查,重构全链路闭环的开发过程。从零构建并交付一个完整项目,让您体验从代码提交到服务上线的“极速”之旅。
回顾中 -
聚开发者之力,创具身新未来2026/07/23 周四 15:00-17:00
张豪杰/程文/王军/刘新春/黄钦开 /张晓天
本次华为云具身智能开发平台CloudRobo培训面向具身智能开发者,带您全流程体验机器人本体R2C小时级接入、环境重建与轨迹生成仿真数据生产、PB级数据管理、数据评测、模型训推、强化学习和Benchmark一键评测等功能,并体验业界主流具身模型应用。
回顾中
热门标签