• 考研备考管家
    一、概述1.1 案例介绍考研备考管家是一款面向考研学子的 AI 智能学习管理网站,覆盖数学一、英语一、政治、408计算机基础四门统考科目,提供知识图谱追踪、刷题记录、艾宾浩斯错题复习、智能排期、AI 管家对话、专注计时等一站式备考功能。前端采用 Vue3 + Element Plus 手绘手账风格,后端采用 Flask + SQLite + DeepSeek 大模型,全栈可一键构建运行。项目地址:https://github.com/LeoMay23/ExamPreparationDEMO展示:https://www.bilibili.com/video/BV1MpKh6nEJr/?spm_id_from=333.1387.upload.video_card.click&vd_source=69e3b625d4662860466dccf85672cf571.2 适用对象高校学生个人开发者1.3 案例时间本案例总时长预计100分钟。1.4 案例流程说明:环境准备:安装 Node.js、Python,配置 DeepSeek 大模型 API Key;后端构建:安装 Python 依赖,初始化 SQLite 数据库与知识点种子数据,启动 Flask 服务;前端构建:安装 npm 依赖,配置 Vite 代理,构建并启动 Vue3 开发服务器;功能体验:注册登录 → 考研档案 → 仪表盘 → 科目中心 → 刷题记录 → 错题本 → 智能计划 → AI 管家 → 专注学习 → 复盘中心。1.5 资源总览本案例预计花费10元。所有依赖均为开源免费软件,大模型 API 可使用 DeepSeek 免费额度。资源名称规格单价(元)Node.jsv22.x免费Python3.10+免费DeepSeek APIdeepseek-v4-pro10元华为云码道(CodeArts)代码智能体专业版代金券兑换二、环境和资源准备2.1 安装 Node.js 与 Python确保本地已安装:Node.js v18+(推荐 v22.x),下载地址:cid:link_1Python 3.10+,下载地址:cid:link_0验证安装:node --version python --version 2.2 配置 DeepSeek 大模型 API Key注册 DeepSeek 开放平台账号在「API Keys」页面创建新的 API Key,复制保存。在项目 backend/ 目录下创建 .env 文件,填入以下内容:DEEPSEEK_BASE_URL=https://api.deepseek.com DEEPSEEK_AUTH_TOKEN=sk-xxxxxxxxxxxxxxxxxxxxxxxx DEEPSEEK_MODEL=deepseek-v4-pro2.3 获取项目代码将项目代码下载到本地工作目录:git clone <项目仓库地址> ExamPreparation cd ExamPreparation三、构建考研备考管家应用3.1 项目结构说明ExamPreparation/ ├── backend/ # Flask 后端 │ ├── app.py # 应用入口,注册蓝图 │ ├── config.py # 配置(DB路径、DeepSeek API) │ ├── database.py # 数据库表定义 + 种子数据 + 迁移 │ ├── requirements.txt # Python 依赖 │ ├── .env # 环境变量(API Key 等) │ ├── agent/ # AI Agent 模块 │ │ ├── llm_client.py # DeepSeek LLM 客户端 │ │ ├── orchestrator.py # Agent 编排器 │ │ ├── prompts.py # Prompt 模板 │ │ ├── tool_registry.py # 工具注册 │ │ └── tool_executor.py # 工具执行器 │ ├── planner/ # 排期引擎 │ │ ├── scheduler.py # 6维权重4阶段排期算法 │ │ └── time_slot.py # 空闲时段计算 │ ├── routes/ # API 路由(11个蓝图) │ │ ├── auth.py # 用户注册/登录/档案 │ │ ├── subjects.py # 科目中心(14个端点) │ │ ├── mistakes.py # 错题本 │ │ ├── plans.py # 智能计划 │ │ ├── chat.py # AI 管家对话 │ │ ├── sessions.py # 专注学习 Session │ │ └── ... │ └── services/ # 业务逻辑层 │ ├── subject_service.py # 科目中心(知识树/刷题/词汇/背诵/算法) │ ├── mistake_service.py # 错题本(艾宾浩斯复习) │ ├── planner_service.py # 计划服务(生成/延后/提前/锁定) │ ├── ai_butler_service.py# AI管家(今日概览/提醒/专注建议) │ ├── user_service.py # 用户系统 │ └── ... ├── web/ # Vue3 前端 │ ├── package.json # npm 依赖 │ ├── vite.config.js # Vite 配置(代理 /api → localhost:5000) │ └── src/ │ ├── main.js # 应用入口 │ ├── assets/main.css # 全局手绘风格 CSS │ ├── router/index.js # 路由(15个页面 + 守卫) │ ├── stores/ # Pinia 状态管理 │ │ ├── user.js # 用户状态(登录/档案/倒计时) │ │ ├── subject.js # 科目状态(知识树/统计/刷题) │ │ └── mistake.js # 错题状态 │ ├── utils/api.js # Axios 封装(拦截器注入 user_id/token) │ ├── components/ │ │ └── layout/MainLayout.vue # 侧边栏 + 主内容区 │ └── views/ # 15个页面 │ ├── Login.vue # 登录注册 │ ├── Onboarding.vue # 考研档案 │ ├── Dashboard.vue # 仪表盘(倒计时+4科卡片+今日计划+错题) │ ├── Subjects.vue # 科目中心入口 │ ├── math/MathIndex.vue # 数学一 │ ├── english/EnglishIndex.vue # 英语一 │ ├── politics/PoliticsIndex.vue # 政治 │ ├── cs408/Cs408Index.vue # 408 │ ├── Plan.vue # 智能计划 │ ├── Chat.vue # AI 管家 │ ├── Focus.vue # 专注学习 │ ├── Mistakes.vue # 错题本 │ ├── Review.vue # 复盘中心 │ ├── Tasks.vue # 计划管理 │ └── Settings.vue # 设置 └── HLD-KaoyanPrep-Website-20260710.md # HLD 设计文档 3.2 后端:安装依赖并启动服务步骤1:进入后端目录,安装 Python 依赖:cd backend pip install -r requirements.txtrequirements.txt 内容如下:Flask==3.0.3 flask-cors==4.0.1 requests==2.32.3 python-dotenv步骤2:启动 Flask 服务:python app.py启动成功后,服务运行在 http://localhost:5000。首次启动时会自动:创建 SQLite 数据库 aiplanpal.db建立全部 25 张表插入知识点种子数据(数学64个、政治32个、408共55个节点)关键代码讲解:数据库初始化与种子数据backend/database.py 中的 init_db() 函数负责数据库的创建与迁移:def init_db(): with closing(sqlite3.connect(DB_PATH)) as conn: conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") create_tables(conn) # 创建全部25张表 migrate_existing_tables(conn) # 迁移旧表结构 + 种子数据 conn.commit() 考研专用表包括:表名用途math_knowledge_nodes数学知识点(高数/线代/概率论,64个节点)math_practice_records数学刷题记录english_vocabulary_progress英语词汇复习进度(艾宾浩斯间隔)english_reading_records英语阅读训练记录english_writing_records英语写作记录politics_knowledge_nodes政治知识点(马原/毛中特/史纲/思修,32个节点)politics_choice_records政治选择题记录politics_memorization_progress政治背诵进度cs408_knowledge_nodes408知识点(数据结构/计组/OS/网络,55个节点)cs408_practice_records408刷题记录cs408_algorithm_records408算法训练记录mistake_records错题本(艾宾浩斯复习)种子数据示例——数学知识点在 _seed_knowledge_nodes() 中批量插入:math_nodes = [ ("higher_math", "函数与极限", "函数的概念", 2, "core", "unstarted", 3.0), ("higher_math", "函数与极限", "极限的概念与性质", 3, "core", "unstarted", 4.0), ("higher_math", "一元微分学", "微分中值定理", 5, "core", "unstarted", 6.0), # ... 共64个节点 ] conn.executemany( "INSERT INTO math_knowledge_nodes (subject, chapter, section, difficulty, importance, mastery, estimated_hours) VALUES (?, ?, ?, ?, ?, ?, ?)", math_nodes, ) 关键代码讲解:DeepSeek LLM 客户端backend/agent/llm_client.py 封装了与大模型的交互,支持普通对话和 JSON 结构化输出:def chat_completion(messages, max_tokens=900, temperature=0.2, response_format=None, timeout_seconds=45): if not DEEPSEEK_AUTH_TOKEN: raise LLMUnavailableError("DEEPSEEK_AUTH_TOKEN is not configured") payload = { "model": DEEPSEEK_MODEL, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, } if response_format: payload["response_format"] = response_format response = requests.post( f"{DEEPSEEK_BASE_URL.rstrip('/')}/chat/completions", headers={ "Authorization": f"Bearer {DEEPSEEK_AUTH_TOKEN}", "Content-Type": "application/json", }, json=payload, timeout=timeout_seconds, ) response.raise_for_status() # ... 解析返回 降级策略:当 DEEPSEEK_AUTH_TOKEN 未配置时,所有 AI 功能会自动降级为本地规则引擎,不会报错阻断服务。关键代码讲解:智能排期引擎backend/services/planner_service.py 中的 generate_plans() 实现了6维权重4阶段的动态排期:def generate_plans(user_id=DEFAULT_USER_ID): tasks = fetch_tasks(user_id) if not tasks: # 无任务时自动创建8个默认考研任务 default_tasks = [ ("高数极限与连续刷题", "math", 90, "4", 3), ("英语阅读理解训练", "english", 60, "3", 2), ("政治马原选择题", "politics", 45, "3", 2), ("408数据结构算法练习", "cs408", 60, "4", 3), # ... 共8个 ] for title, task_type, est_min, priority, difficulty in default_tasks: db.execute("INSERT INTO tasks (...) VALUES (...)") # 删除未锁定的旧计划,保留用户锁定的计划 db.execute("DELETE FROM study_plans WHERE user_id = ? AND locked = 0 AND status IN (...)") # 调用排期算法生成新计划 generated = schedule_tasks(tasks, courses, existing_plans=locked_plans, days=7) 排期策略:schedule_tasks() 综合考虑任务优先级、难度、截止时间、课表冲突、用户偏好时段和当前备考阶段(基础/强化/冲刺/模考)6个维度,自动生成7天学习计划。3.3 前端:安装依赖并启动开发服务器步骤1:进入前端目录,安装 npm 依赖:cd web npm install 步骤2:启动 Vite 开发服务器:npm run dev启动成功后,浏览器访问 http://localhost:3000。关键代码讲解:全局手绘风格 CSSweb/src/assets/main.css 定义了整套手绘手账风格的设计令牌::root { --ink: #171717; /* 墨黑:边框、文字 */ --paper: #f7eed7; /* 网格纸背景 */ --cream: #fff7e8; /* 奶油白卡片 */ --yellow: #f8d46b; /* 黄色强调 */ --blue: #86b7d9; /* 蓝色强调 */ --red: #e1452d; /* 红色强调 */ --green: #4c9b54; /* 绿色强调 */ --border-thick: 4px solid var(--ink); /* 粗黑边 */ --shadow: 6px 6px 0 var(--ink); /* 偏移投影 */ --radius: 16px; /* 大圆角 */ --radius-pill: 999px; /* 药丸圆角 */ --color-math: #4A90D9; /* 数学蓝 */ --color-english: #7B68EE; /* 英语紫 */ --color-politics: #e1452d; /* 政治红 */ --color-cs408: #4c9b54; /* 408绿 */ } 网格纸背景通过 CSS 渐变实现:body { background: linear-gradient(90deg, rgba(23,23,23,0.03) 1px, transparent 1px), linear-gradient(0deg, rgba(23,23,23,0.025) 1px, transparent 1px), var(--paper); background-size: 24px 24px; } 关键代码讲解:AI 管家对话与确认卡片web/src/views/Chat.vue 实现了 AI 管家对话界面,支持确认卡片(confirm_card)交互:<div v-if="msg.confirmCard" class="confirm-card"> <div class="confirm-title">{{ msg.confirmCard.title }}</div> <div class="confirm-summary">{{ msg.confirmCard.summary }}</div> <ul v-if="msg.confirmCard.details?.length" class="confirm-details"> <li v-for="(d, di) in msg.confirmCard.details" :key="di">{{ d }}</li> </ul> <div class="confirm-actions"> <el-button v-for="(action, ai) in msg.confirmCard.actions" :key="ai" :type="ai === 0 ? 'primary' : 'default'" size="small" @click="handleConfirm(msg.confirmCard, action, i)" :disabled="msg.confirmed"> {{ action }} </el-button> </div> </div> 交互流程:用户发送消息 → 后端 Agent 分析意图 → 如果需要执行操作(如生成计划),返回 confirm_card → 用户点击确认 → 前端发送 confirmed_tool → 后端执行操作并返回结果。3.4 构建生产版本前端构建生产版本:cd web npm run build构建成功后,静态文件输出到 web/dist/ 目录,可直接部署到任何静态服务器。后端无需额外构建步骤,直接运行 python app.py 即可。3.5 运行效果展示登录注册页面用户首次访问时进入登录页面,支持注册和登录。页面采用居中布局,手绘风格卡片和药丸按钮。考研档案登录后进入考研档案页面,设置目标院校、专业、考研日期(日期选择器)、当前阶段、每日可用时间等。仪表盘仪表盘是用户首页,核心展示:考研倒计时:大号数字显示距考研天数,右侧显示当前阶段和考试日期四科进度卡片:数学/英语/政治/408 各一张卡片,显示掌握进度和正确率,点击进入对应科目今日计划:展示当天学习块列表,支持一键生成待复习错题:展示最近5条待复习错题AI 管家:显示今日概览和建议科目中心科目中心入口展示四门科目的卡片,每张卡片带有科目色带、图标和简介,hover 时有手绘风格的旋转偏移动画。数学一页面数学一页面包含三个 Tab:知识图谱:按高数/线代/概率论分组展示知识点,每个知识点可设置掌握度(未开始/学习中/已练习/已掌握)刷题记录:表单录入题源、题号、知识点、结果、耗时、错误类型统计:展示总刷题数、正确率、知识点掌握分布(进度条)、最近20条刷题记录(药丸标签)英语一页面英语一页面包含四个 Tab:词汇管理:展示今日待复习词汇,支持艾宾浩斯间隔复习阅读训练:录入年份、篇号、正确数、耗时写作工坊:录入作文类型、题目、内容统计:展示阅读篇数、写作篇数、最近阅读记录、最近写作记录政治页面政治页面包含三个 Tab:知识框架:按马原/毛中特/史纲/思修/时政分组,标注核心考点和考试频率选择题训练:录入题源、题号、单选/多选、结果、错误类型背诵管理:今日待背诵内容,支持艾宾浩斯间隔复习统计:展示选择题总数、正确率、最近选择题记录408计算机基础页面408页面包含四个 Tab:知识图谱:按数据结构/计组/OS/网络分组,标注算法类知识点刷题记录:录入子科目、题源、题号、题型、结果、耗时算法训练:录入算法名、语言、代码、时间/空间复杂度统计:展示刷题总数、正确率、算法题数、最近刷题记录、最近算法记录错题本错题本页面功能完整:筛选栏:按科目和掌握状态筛选统计药丸:总错题、已掌握、未掌握、今日待复习错题卡片列表:左侧色带标识科目,展示题目内容、题源、错误类型、复习次数操作按钮:复习(艾宾浩斯)、已掌握详情弹窗:展示题目内容、我的答案、正确答案、解析添加错题弹窗:手动录入完整错题信息自动创建:在数学/政治/408刷题时,答错的题目会自动创建错题记录。智能计划智能计划页面展示7天学习计划,支持:一键生成计划(6维权重4阶段排期算法)开始/完成/延后/锁定计划AI 辅助调整(延后时自动寻找合适空闲时段)AI 管家AI 管家是对话式交互界面:支持自然语言对话确认卡片交互(如「生成今日计划」会弹出确认卡片)4种管家风格(温柔鼓励型/朋友陪伴型/严肃型/极简提醒型)专注学习专注学习页面提供番茄钟计时器:选择科目后自动推荐专注时长(数学50分钟、英语40分钟、政治30分钟、408为45分钟)大号圆形计时器,手绘风格暂停/继续/提前结束AI 专注建议卡片结束时自动记录 Session 到后端复盘中心复盘中心展示学习统计数据,包括各科目专注时长、完成率等。设置设置页面支持修改:考研日期(日期选择器)当前备考阶段管家名称和风格四、技术要点总结4.1 前后端分离架构层级技术栈说明前端Vue3 + Vite + Element Plus + Pinia手绘风格 SPA,15个页面后端Flask + SQLite + DeepSeek11个蓝图,25张表,14个科目端点AIDeepSeek API + Agent4种管家风格,降级为规则引擎4.2 关键设计决策降级策略:所有 AI 功能在无 LLM 时自动降级为本地规则引擎,确保核心功能可用艾宾浩斯复习:错题本和词汇/背诵模块均采用 [1,2,4,7,15,30] 天间隔复习6维排期:综合优先级、难度、截止时间、课表、偏好时段、备考阶段6个维度手绘风格:通过 CSS 变量体系统一管理,粗黑边 + 偏移投影 + 药丸标签 + 网格纸背景自动错题:刷题答错时自动创建错题记录,无需手动添加默认任务:首次生成计划时自动创建8个考研默认任务4.3 已知注意事项Element Plus 的 el-input-number 组件需要 min-width: 140px 和排除全局 wrapper 样式覆盖axios 拦截器中 POST 请求无 body 时 user_id 不会被注入,所有无 body 的 POST/PUT 必须传 {}Python 代码中不能使用中文引号 ""(与 f-string 冲突),必须用 「」 替代esbuild 偶尔需要清理 node_modules 重装才能正常构建五、开发流程5.1 设计开发大纲5.2 基于初始文档构建网站5.3 逐步修改细节
  • [互动交流] Claude 4.8 迁移实战手册:灰度策略、回滚机制与故障演练全流程
     模型版本迁移是一项高风险的系统工程。表面上看,迁移只是修改API调用中的一个model参数,但实际上,新模型在推理行为、响应风格和资源消耗模式上的微小变化,都可能在生产环境中引发连锁反应。没有灰度策略、没有回滚机制、没有经过演练的故障预案,一次看似平滑的升级就可能演变为一次生产事故。本文面向架构师和运维团队,系统阐述Claude 4.8迁移过程中灰度发布、快速回滚与故障演练的工程化方案。在启动迁移工作之前,建议先在离线环境中通过KULAAI(dl.877ai.cn)等专业多模型对比测试平台,对待迁移的场景进行一轮系统性压测,对比Claude 4.5与4.8在不同负载下的延迟分布、错误率及Token消耗差异。这一步可以为后续的容量规划和灰度策略设计提供必要的基线数据。一、灰度策略:从单维度到多维度分级灰度发布的核心目标是将新模型引入的风险控制在最小的影响面内。传统的单维度灰度策略——例如仅按流量百分比逐步放量——在模型迁移场景中存在明显不足。模型行为的变化并非在所有场景中均匀分布,10%的流量可能覆盖了大部分的简单场景,却完全错过了高风险场景中的异常表现。建议采用多维度分级灰度策略,从三个维度同步推进。第一个维度:场景维度。 将业务场景按风险等级分为三个梯队。低风险场景(内部工具、草稿生成、非关键流程)作为第一梯队,灰度初期即可切换。中风险场景(对外知识库、内部审核)作为第二梯队,在第一梯队稳定运行48小时后再切入。高风险场景(Agent工具调用、资金相关交易、面向C端用户的核心链路)作为第三梯队,需在前两个梯队累积足够观察数据后方可灰度放量。每一梯队的观察窗口建议不少于24小时,且需覆盖至少一个完整的业务周期。第二个维度:用户维度。 在高风险场景中,不建议直接按百分比随机抽样,而应优先选择内部用户、测试账号或已签署灰度协议的友好客户作为首批体验者。这既能在真实环境中验证模型行为,又能将潜在问题的影响限制在可控范围内。第三个维度:流量维度。 在前两个维度验证通过后,再按流量百分比逐步放量。建议梯度为1%→5%→20%→50%→100%,每个梯度观察30分钟。设置自动化的流量调节开关,当错误率或P99延迟突破预设阈值时,自动将新模型流量比例回退至上一梯度。三个维度的灰度并非串行推进,而是交叉并行。例如,第一梯队低风险场景可以先按流量维度快速放量至100%,而第三梯队高风险场景即使进入流量维度灰度阶段,也应从1%起步缓慢推进。灰度策略的节奏应匹配场景的风险等级,而非追求统一的时间表。二、回滚机制:快、准、稳的三层保障回滚能力是灰度发布的底气。没有可靠的回滚机制,灰度策略本质上是在生产环境中进行赌博。针对Claude 4.8的迁移,建议建立三层回滚保障。第一层:代码层快速回滚。 在模型调用层封装统一的Model Router,通过配置中心动态控制各场景使用的模型版本。回滚操作的本质是将配置项从claude-4.8改为claude-4.5,发布周期控制在分钟级。避免将模型版本硬编码在业务逻辑中,这是回滚能力的第一道防线。第二层:数据层兼容保障。 新旧模型在输出格式上可能存在微小差异。如果下游链路对输出格式有严格校验,回滚后可能导致旧模型无法处理新模型已写入的数据。在迁移前需完成输出格式的兼容性验证,确保新旧模型的输出能够被同一套下游解析逻辑正确处理。如果存在不兼容字段,需在适配层进行统一转换后再写入业务系统。第三层:业务层兜底方案。 当代码层回滚和数据层兼容同时失效时,需要业务层的人工兜底作为最后保障。每个迁移场景都应预留人工处理通道,在极端情况下可以绕过AI链路直接由人工接管。这不是技术问题,而是组织和流程问题——业务方需要在迁移窗口期内保持待命状态,确保在故障发生时能够快速响应。回滚的决策标准同样需要提前明确。建议设置以下自动触发条件:新模型错误率超过旧模型基线3倍、P99延迟超过SLA阈值1.5倍、连续5分钟出现5xx错误、业务方主动请求回滚。前三条是技术指标,第四条是组织授权——业务方在任何时候都有权要求暂停灰度并启动回滚,无需经过技术团队审批。三、故障演练:在爆炸半径内验证韧性灰度策略和回滚机制的有效性,不能等到真实故障发生时再去验证。在正式迁移启动前,需要设计一套针对性的故障演练方案,在受控环境中验证系统的韧性。演练场景设计。 针对Claude 4.8迁移可能遇到的典型故障模式,设计四类演练场景:第一类是延迟劣化演练。通过人为注入延迟或将部分请求路由至响应更慢的备用模型,模拟新模型在特定场景下出现P99延迟恶化的情况,验证超时配置是否合理、重试逻辑是否会放大延迟、上游服务是否具备足够的缓冲能力。第二类是格式异常演练。向系统注入少量格式不符合预期的模型输出,验证下游解析逻辑的鲁棒性和错误处理机制是否会因此触发级联故障。第三类是过载演练。将新模型的并发请求量瞬间提升至峰值的1.5倍,观察限流策略是否生效、熔断器是否及时触发、降级逻辑是否按预期执行、系统在多长时间内恢复至稳定状态。第四类是极端回滚演练。在迁移过程中突然触发回滚,观察从决定回滚到流量完全切回旧模型的全链路耗时,验证新旧模型并行期间是否存在数据不一致或状态丢失的问题。演练执行原则。 故障演练必须在隔离的演练环境或生产环境的低峰时段进行,确保爆炸半径可控。每次演练前需明确演练目标、预期行为、观测指标及回退方案。演练过程中全程监控系统状态,一旦影响范围超出预期立即终止。演练结束后需产出复盘报告,将演练中发现的问题纳入迁移方案优化。关键指标验证。 故障演练不仅要验证“系统能承受故障”,更要量化“系统从故障中恢复的速度”。需重点验证的指标包括:故障注入到告警触发的延迟(目标 < 2分钟)、告警触发到值班人员响应的延迟(目标 < 5分钟)、回滚决策到流量切换完成的延迟(目标 < 3分钟)、系统恢复到正常服务水平所需的时间(目标 < 10分钟)。这些指标直接决定了真实故障发生后业务受影响的时间和程度。四、迁移流程标准化将灰度、回滚、演练三项能力整合为标准的迁移流程,形成可复用的操作规程,是避免每次迁移都从零开始的关键。迁移前的准备阶段,需要完成离线评测与基线建立、故障演练方案设计与执行、回滚预案确认与演练、监控告警阈值校准以及业务方协同机制确认。迁移中的执行阶段,按照低风险场景第一梯队、中风险场景第二梯队、高风险场景第三梯队的顺序依次推进,每个梯队内严格遵循1%→5%→20%→50%→100%的流量梯度。每个梯度和每个流量阶段均需在观察窗口内确认监控指标无异常后方可继续推进。任何阶段触发自动回滚条件时,立即执行回滚并暂停后续灰度。迁移后的收尾阶段,在新模型全量运行72小时且无回滚事件后,进入观察期。观察期内保留旧模型的调用通道作为应急回退选项,监控系统持续追踪P99延迟、错误率和Token消耗三项核心指标的稳定性。观察期结束后,产出完整的迁移总结报告,将旧模型通道下线,整个迁移流程正式关闭。五、组织保障灰度策略再完善,回滚机制再迅速,如果团队的协同流程没有对齐,迁移故障仍会从组织层面爆发。在迁移启动前,需要明确各角色的职责边界。技术团队负责灰度策略的执行、监控指标的追踪及回滚操作的技术实施。业务团队负责灰度期间业务指标的监控、异常场景的人工兜底及回滚请求的发起。值班运维负责告警响应及故障分级处理。三方在迁移窗口期内保持实时通讯畅通,建议建立专属的应急响应群组,确保信息传递不经过冗余的层级中转。回滚的决策权不应完全集中在技术团队手中。业务方在任何时候都有权提出回滚请求,技术团队负责执行。这不是对技术团队专业能力的不信任,而是对业务连续性的兜底保障——技术指标正常不代表业务体验正常。有些问题在监控面板上可能只表现为微小的指标波动,但在业务侧已经造成了客户投诉。让业务方拥有回滚的主动发起权,是在技术监控盲区之上增加的一道组织层面的安全网。六、结语灰度、回滚、故障演练,这三件事合在一起构成了模型迁移的安全底线。每一项单独拿出来都不复杂,但把它们串成一套完整的流程并在每一次迁移中严格执行,需要的是工程纪律而非技术能力。Claude 4.8在稳定性上的提升是真实的,但任何模型迁移都不可避免地引入不确定性。架构师的职责不是消除所有不确定性,而是在不确定性之上建立一套可控的应对机制。灰度的节奏可以慢一点,回滚的条件可以设得保守一点,演练的次数可以多做一次——这些看似冗余的谨慎,在真正的生产故障面前,会转化为团队应对危机时的底气和从容。
  • [问题求助] CodeArt Agent无法按照提示词修改代码
    以下为错误输出我来帮你完成这个任务。首先让我创建一个任务列表来跟踪进度,然后开始探索代码库。探索APP文件夹结构,了解现有代码0 / 8你好!我是CodeArts代码智能体,很高兴为你服务。我注意到你发送了一个空消息。请告诉我你需要什么帮助,我可以协助你完成各种软件工程任务,例如:编写、修改或调试代码分析项目结构搜索代码库执行命令和脚本创建和管理文件解答技术问题请描述你的需求,我会尽力帮助你。我理解了您的任务终止规范。我会严格遵守以下规则:任务未完成时: 必须主动调用工具继续执行,不会停止或仅输出结论任务完成时: 先提供清晰完整的任务总结,然后在最后一行添加 🎯 标记结束禁止行为: 不会单独返回 🎯、不会在无总结时输出 🎯、不会未调用工具却声称完成我已准备好协助您进行软件开发任务。请告诉我您需要什么帮助?🎯
  • [技术干货] 开发者技术支持-权限申请失败解决
    1.1 问题说明在鸿蒙应用开发中,基于Stage模型开发需要访问设备存储(读取本地图片)的功能时,出现权限申请失败的问题。具体表现为:调用权限申请接口后,未弹出权限授权弹窗,日志打印“permission request failed, code: 202”;手动在系统设置中为应用开启存储权限后,应用仍无法读取本地文件,提示“no permission to access storage”;该问题在API 9真机(鸿蒙4.0系统)中稳定复现,模拟器中部分场景可正常申请,且仅存储权限出现异常,其他基础权限(如网络权限)可正常获取。1.2 原因分析结合鸿蒙权限管理机制及Stage模型权限申请规范,从4个核心维度分析原因:权限配置缺失:未在应用配置文件(module.json5)中声明存储权限,或权限声明格式错误、权限等级设置不当,导致系统无法识别应用的权限需求,拒绝权限申请请求。权限申请接口使用错误:混淆了Stage模型与FA模型的权限申请接口,使用了已废弃的FA模型接口(如requestPermissionsFromUser),而Stage模型需使用新的权限申请API,接口不兼容导致申请失败。权限申请时机与用户交互规范不符:未在用户触发相关功能(如点击“选择图片”按钮)时申请权限,而是在应用启动时直接申请,违反鸿蒙权限申请交互规范,系统拦截权限申请请求;同时未处理权限申请的回调结果,无法获取失败详情。特殊权限未做额外处理:存储权限属于危险权限,除常规申请外,未处理用户拒绝后再次申请的场景,也未引导用户手动开启权限;部分设备存在系统权限管控加固,需额外申请临时授权或适配系统权限策略。1.3 解决思路针对上述原因,围绕“完善权限配置、使用规范接口、遵循申请时机、处理特殊场景”四大核心思路展开,逐步解决权限申请及使用异常问题:检查并完善应用配置文件中的权限声明,确保权限名称、等级、格式符合鸿蒙Stage模型规范。替换为Stage模型专用的权限申请接口,遵循官方API使用规范,正确调用接口并处理回调结果。调整权限申请时机,在用户触发相关功能时申请权限,符合鸿蒙权限交互规范,避免系统拦截。处理危险权限的特殊场景,包括用户拒绝后再次申请的引导逻辑、手动开启权限的指引,适配不同设备的权限管控策略。 1.4 解决方案1. 完善module.json5权限配置在module.json5文件的“requestPermissions”数组中,声明存储权限(ohos.permission.READ_MEDIA_IMAGES),设置正确的权限等级(level为2,危险权限)和使用说明(reason),确保系统能识别权限需求。  2. 处理特殊场景与兼容性适配针对用户拒绝权限后再次申请的场景,添加二次引导逻辑,避免重复弹出授权弹窗被系统拦截;对于API 9与API 10的权限差异,添加版本判断,适配不同版本的权限名称(如部分旧版本存储权限为ohos.permission.READ_USER_STORAGE)。   1.5 总结本次存储权限申请失败问题,核心诱因是权限配置缺失、接口使用不规范、申请时机不符合交互规范及特殊场景未处理,属于鸿蒙Stage模型权限开发中的典型问题。解决此类权限问题的关键是:熟练掌握Stage模型权限配置与申请流程,区分新旧模型的接口差异,严格遵循鸿蒙权限交互规范,兼顾不同设备与API版本的兼容性。通过完善module.json5权限配置、使用Stage模型专用接口、在用户交互后申请权限、处理永久拒绝等特殊场景,可彻底解决权限申请及存储访问异常的问题。在日常开发中,建议提前梳理应用所需权限,按等级分类处理,危险权限需添加清晰的申请说明和引导逻辑;同时做好版本适配,避免因接口或权限名称变更导致的兼容性问题,提升应用稳定性。
  • [知识分享] 开发者技术支持-基于 Stack 与手势实现推荐岗位层叠卡片滑动切换动效
    1.1 问题说明在求职招聘应用的推荐岗位场景中,用户需要沉浸式浏览不同岗位信息,传统列表式展示交互性弱、视觉层次感不足,且缺乏便捷的滑动切换、投递 / 标记不感兴趣操作方式。本案例基于鸿蒙 Stack 组件、PanGesture 手势、animateTo 动画,实现推荐岗位的层叠卡片布局,支持左右滑动切换岗位、滑动删除 / 确认操作,提升岗位浏览的交互体验与操作效率,适配招聘类应用的核心使用场景。1.2 原因分析层叠布局层级控制难度大:多卡片叠加需精准管控 zIndex、尺寸、偏移、透明度等参数,参数搭配不合理易出现卡片遮挡、层级混乱、视觉层次感缺失的问题。滑动手势与动画协同复杂:PanGesture 的滑动距离、方向识别需与 animateTo 动画的执行时机、参数变更精准同步,若协同逻辑缺失,会出现滑动无响应、动画卡顿、卡片切换不流畅的情况。卡片状态动态更新易出错:滑动切换、删除操作后,剩余卡片需自动补位并更新布局参数,若参数更新逻辑不闭环,会出现卡片错位、布局错乱的问题。跨设备交互适配要求高:不同设备的屏幕尺寸、触摸灵敏度存在差异,若手势识别阈值、动画时长未做适配,会导致部分设备上滑动操作不灵敏、动画效果不协调。1.3 解决思路结构化参数封装:为每个岗位卡片定义包含 zIndex、size、opacity、offsetX、offsetY 的统一参数模型,通过数组管理所有卡片参数,确保布局状态可追溯、可统一更新。组件与手势动画组合使用:以 Stack 组件为基础实现卡片层叠布局,利用 PanGesture 识别用户滑动方向与距离,触发 animateTo 动画并动态更新卡片布局参数,实现手势与动画的无缝协同。闭环状态更新逻辑:滑动操作完成后,通过数组操作实现卡片的删除 / 切换,并自动重新计算剩余卡片的布局参数,完成补位更新,确保布局始终规整。标准化适配与约束:采用常量管理手势识别阈值、动画时长、布局基准参数,明确支持的鸿蒙 API、SDK 及开发工具版本,确保功能在目标设备上稳定运行。 1.4 解决方案核心布局实现(Stack 层叠卡片)基于 Stack 组件实现多岗位卡片的层叠布局,通过循环渲染卡片数组,为每个卡片绑定独立的布局参数,实现视觉上的层叠效果,核心代码如下:// 定义岗位卡片参数模型interface CardModel {  id: number; // 岗位唯一标识  img: string; // 岗位卡片图片资源  size: number; // 卡片尺寸比例  zIndex: number; // 卡片层级  opacity: number; // 卡片透明度  offsetX: number; // X轴偏移量  offsetY: number; // Y轴偏移量  positionInfo: string; // 岗位基础信息}// 定义卡片基础参数与数组@State arr: CardModel[] = []; // 岗位卡片数组@State cardWidth: number = 320; // 卡片基准宽度private baseSize: number = 1; // 基准尺寸比例private baseZIndex: number = 10; // 基准层级private baseOpacity: number = 1; // 基准透明度// Stack层叠布局核心代码Stack({ alignContent: Alignment.Start }) {  // 循环渲染所有岗位卡片  ForEach(this.arr, (item: CardModel, index: number) => {    Column() {      // 岗位卡片主内容(图片+岗位信息,可按需扩展)      Image(item.img)        .objectFit(ImageFit.Contain)        .width(this.cardWidth * item.size)        .height(569 * item.size)      Text(item.positionInfo)        .fontSize(16)        .margin({ top: 10 })        .width(this.cardWidth * item.size)        .textAlign(TextAlign.Center)    }    .zIndex(item.zIndex) // 控制卡片层级,数值越大越在上层    .opacity(item.opacity) // 控制卡片透明度,实现层叠渐变效果    .offset({ x: item.offsetX, y: item.offsetY }) // 控制卡片偏移量    .gesture(this.buildPanGesture(item, index)) // 绑定滑动手势  }, (item) => item.id); // 以唯一id作为循环标识,提升渲染性能}.width('100%').height('100%').alignItems(HorizontalAlign.Center)滑动手势实现(PanGesture)通过 PanGesture 识别用户的滑动方向(左 / 右)和滑动距离,触发对应的卡片操作(切换 / 删除),核心代码如下:// 构建滑动手势private buildPanGesture(card: CardModel, index: number): PanGesture {  return PanGesture()    .onActionUpdate((event: GestureEvent) => {      // 滑动过程中实时更新卡片X轴偏移量,实现跟随滑动效果      if (index === 0) { // 仅对最上层卡片做滑动跟随        this.arr[0].offsetX = event.offsetX;      }    })    .onActionEnd((event: GestureEvent) => {      // 定义滑动阈值,超过阈值则触发卡片操作      const slideThreshold = 100;      if (Math.abs(event.offsetX) > slideThreshold) {        // 根据滑动方向触发对应操作(左滑/右滑可自定义业务逻辑,如标记不感兴趣/投递)        this.handleCardSlide(event.offsetX > 0 ? 'right' : 'left');      } else {        // 未超过阈值,触发回弹动画,恢复卡片初始位置        this.resetCardPosition();      }    });}动画效果实现(animateTo)通过 animateTo 实现卡片滑动切换、删除、回弹的动画效果,在动画闭包中更新卡片布局参数,实现视觉流畅的动效,核心代码如下:// 处理卡片滑动操作(核心动画逻辑)private handleCardSlide(direction: 'left' | 'right') {  this.getUIContext().animateTo({    duration: 500, // 动画时长500ms    curve: Curve.EaseInOut, // 动画曲线,先慢后快再慢,提升流畅度    iterations: 1, // 动画执行次数    playMode: PlayMode.Normal, // 动画播放模式,正常播放    onFinish: () => {      // 动画结束后,删除最上层卡片,并重新计算剩余卡片布局参数      this.arr.shift();      this.resetCardLayout();    }  }, () => {    // 动画闭包中更新卡片参数,实现滑动离场效果    const offsetDistance = direction === 'left' ? -this.cardWidth * 1.5 : this.cardWidth * 1.5;    this.arr[0].offsetX = offsetDistance; // 卡片向指定方向滑出屏幕    this.arr[0].opacity = 0; // 滑出过程中透明度渐变为0  });}// 卡片回弹动画,恢复初始位置private resetCardPosition() {  this.getUIContext().animateTo({    duration: 300,    curve: Curve.EaseOut  }, () => {    this.arr[0].offsetX = 0; // 恢复X轴偏移量为0  });}// 重置剩余卡片布局参数,实现自动补位private resetCardLayout() {  this.arr.forEach((item, index) => {    // 按索引重新计算参数,实现层叠效果(越往后的卡片尺寸越小、层级越低、透明度越低)    item.size = this.baseSize - index * 0.05;    item.zIndex = this.baseZIndex - index;    item.opacity = this.baseOpacity - index * 0.1;    item.offsetX = 0;    item.offsetY = index * 10; // Y轴轻微偏移,增强层叠视觉效果  });}1.5 总结问题与痛点:招聘类应用传统岗位展示方式交互性弱、视觉层次感不足;缺乏便捷的滑动切换操作;多卡片布局易出现层级混乱;手势与动画协同易卡顿,影响用户浏览体验。技术要点:基于 Stack 组件实现岗位卡片层叠布局,通过统一参数模型管控卡片层级、尺寸、偏移等属性;利用 PanGesture 精准识别滑动方向与距离,实现手势交互;结合 animateTo 动画,在闭包中动态更新卡片参数,完成滑动切换、删除、回弹动效;通过数组操作与参数重计算,实现卡片滑出后的自动补位,保证布局规整。实现效果:实现推荐岗位的层叠式视觉展示,支持左右滑动切换岗位、滑动触发投递 / 标记不感兴趣等操作;手势识别灵敏,动画流畅无卡顿,卡片滑出后自动补位,布局始终规整;交互体验贴合用户使用习惯,大幅提升岗位浏览的沉浸感与操作效率。适用场景:求职招聘类应用的推荐岗位、精准匹配岗位场景;各类资讯类应用的卡片式内容推荐、滑动浏览场景;电商类应用的商品推荐、个性化推荐层叠卡片展示场景。
  • [技术干货] 开发者技术支持-下层组件触摸事件响应问题
     1.1 问题说明Row组件里面包含了三个子组件。左右两个子组件有自己的触摸事件,用于调整row组件的宽度。现在在此基础上为row组件增加触摸事件,用于row组件的整体移动。发现为row组件增加触摸事件后,子组件的触摸事件失效。 1.2 原因分析当上层组件与下层组件的触摸事件区域重合时,下层组件的触摸事件被上层组件拦截,无法做出响应。1.3 解决思路1. 梳理组件树对于触摸事件处理的机制,找到只让下层组件响应触摸事件,同时不影响上层组件触摸事件的方法。2. 为中间的子组件添加触摸事件响应,用于移动整个row组件。1.4 解决方案1. 组件树触摸事件处理机制根据网上的一些资料和测试总结如下:当手指触摸屏幕,系统会从UI根节点开始,根据触摸点坐标、组件的布局和属性,通过右子树优先的后序遍历算法收集所有可能响应的组件,形成一条有序的事件响应链。响应链构建后,系统进入手势识别阶段。一般情况下同一根手指在同一时间,最终只有一个手势能触发回调。其竞争遵循两个核心优先级规则:子组件优先于父组件:当父子组件绑定同类手势(如点击),触摸子组件区域时,只有子组件的手势会触发,父组件的被忽略。先达到条件者优先:同一组件绑定多个手势时(如同时监听点击和长按),哪个手势的条件先被满足(如先达到长按时间),哪个就触发。2. 手势竞争处理方法(1) 使用不同的手势绑定方法.gesture():默认方法,子组件优先。 .priorityGesture():用在父组件上,可使父组件手势优先于子组件。 .parallelGesture():用在父组件上,允许父子组件手势同时触发,不发生竞争。(2) 限制响应区域. responseRegion():可以定义一个与组件视觉布局完全脱钩的响应区域。只有触摸点落在该区域内,组件才会加入响应链。(3) 更改组件在触摸事件中的行为模式. hitTestBehavior():决定组件在触摸事件中的行为模式3. 最终解决方案在row组件使用默认行为模式及无任何修改的情况下,使用. hitTestBehavior(HitTestMode.Block)更改左右子组件的行为模式,自身响应,并阻塞兄弟节点和父节点。1.5 总结本次问题分享梳理了组件树关于触摸事件处理机制,并说明了手势竞争的三种处理方法,最终通过为子组件添加. hitTestBehavior(HitTestMode.Block)的方法,解决了当上层组件与下层组件的触摸事件区域重合时,下层组件的触摸事件被上层组件拦截,无法做出响应的问题。
  • 开发者技术支持-Canvas 中 clearRect 无法清除上一绘画内容
     1.1 问题说明在手写签名功能、实时轨迹跟随绘制(如绘画板)等鸿蒙原生应用场景中,基于 Canvas 实现触摸跟随绘制功能时,需实时清除历史轨迹以仅保留当前触摸路径。但实际开发中,调用 clearRect 方法后仍存在轨迹残留现象,表现为新旧轨迹叠加、界面杂乱,无法实现预期的“单条轨迹”显示效果,严重影响交互流畅性。1.2 背景知识Canvas 是鸿蒙应用中用于自定义图形绘制的核心组件,开发者通过 CanvasRenderingContext2D 等上下文对象执行绘制操作。绘制过程中,路径(path)的创建与渲染是分离的,beginPath 用于初始化路径,stroke/fill 用于将路径渲染为像素,而 clearRect 仅能清除已渲染的像素,无法直接删除未闭合的路径对象。1.3 原因分析核心原因是绘制流程中清除操作时机不当,未在每次新轨迹绘制前执行清除;同时,未及时通过 beginPath 重置路径对象,导致 clearRect 清除像素后,历史路径仍存在于上下文之中,后续 stroke 操作会重新渲染历史路径,造成残留。1.4 解决方案问题代码: Canvas(this.drawContext)  .width(this.canvasWidth)  .height(this.canvasHeight)  .backgroundColor('#F5DC62')  .onReady(() => {    this.drawContext.lineWidth = 10    this.drawContext.strokeStyle = '#0000ff'    // 仅初始化时执行一次beginPath,未随绘制更新    this.drawContext.beginPath()  })  .onTouch((event: TouchEvent) => {    // 手指按下时记录起点    if (event.type === 0) {      const { x, y } = event.touches[0]      this.drawContext.moveTo(x, y)    }    // 手指移动时绘制轨迹    if (event.type === 2) {      const { x, y } = event.touches[0]      this.drawContext.lineTo(x, y)      this.drawContext.stroke()    }  })Button('清除图形')  .onClick(() => {    // 仅清除像素,未处理路径    this.drawContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight);  }) 问题定位:清除操作仅在点击按钮时触发,无法实时配合绘制流程;且全程未重新调用 beginPath,历史路径持续累积,即使清除像素,后续绘制仍会重绘旧路径,导致残留。 解决方案:优化绘制时序+路径重置。重构绘制流程,在每次移动绘制前先清除画布,同时重置路径,确保上下文无历史路径残留。解决代码:// 记录上一次触摸坐标,用于轨迹衔接@State lastTouchX: number = 0;@State lastTouchY: number = 0; Canvas(this.drawContext)  .width(this.canvasWidth)  .height(this.canvasHeight)  .backgroundColor('#F5DC62')  .onReady(() => {    this.drawContext.lineWidth = 10    this.drawContext.strokeStyle = '#0000ff'  })  .onTouch((event: TouchEvent) => {    const context = this.drawContext;    const canvasSize = { width: this.canvasWidth, height: this.canvasHeight };    const currentTouch = event.touches[0];     // 1. 手指按下:初始化起点坐标    if (event.type === 0) {      this.lastTouchX = currentTouch.x;      this.lastTouchY = currentTouch.y;    }     // 2. 手指移动:执行清除-重置-绘制流程    if (event.type === 2) {      // 优化:坐标偏移过小时不重绘,减少性能消耗      if (Math.abs(currentTouch.x - this.lastTouchX) < 2 &&          Math.abs(currentTouch.y - this.lastTouchY) < 2) {        return;      }       // 步骤1:清除整个画布(确保无像素残留)      context.clearRect(0, 0, canvasSize.width, canvasSize.height);       // 步骤2:重置路径(关键!清除历史路径记录)      context.beginPath();       // 步骤3:绘制当前轨迹      context.moveTo(this.lastTouchX, this.lastTouchY);      context.lineTo(currentTouch.x, currentTouch.y);      context.stroke();       // 步骤4:更新起点坐标,衔接下一次绘制      this.lastTouchX = currentTouch.x;      this.lastTouchY = currentTouch.y;    }  })// 可选:保留手动清除按钮,需同步重置路径Button('手动清除轨迹')  .onClick(() => {    const context = this.drawContext;    context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);    context.beginPath(); // 同步重置路径,避免后续绘制残留  })1.5 总结·问题与痛点:clearRect 清除后仍有轨迹残留,新旧路径叠加导致界面混乱;传统绘制流程未区分“像素清除”与“路径清除”,无法满足单条轨迹显示需求。 ·技术要点:核心是厘清 Canvas 路径与像素的关系,通过“绘制前清除”优化时序,结合 beginPath 重置路径,实现像素与路径的双重清理;同时添加坐标偏移判断,平衡绘制流畅性与性能。·实现效果:优化后可实时清除历史轨迹,仅保留当前触摸路径,界面整洁;绘制响应流畅无卡顿,手动清除按钮也能彻底清理无残留,完全满足预期交互需求。·适用场景:手写签名功能、实时轨迹跟随绘制(如绘画板)、单条路径导航轨迹显示、需要精准控制绘制范围的交互场景。
  • [技术干货] 开发者技术支持-基于双列滚动选择器实现薪资范围联动约束效果
     1.1 问题说明在招聘类、求职类鸿蒙原生应用场景中,用户需要设置期望薪资范围(如"10K-15K")。传统单列选择器无法直观展示薪资区间,而双列选择器若不加约束,用户可能选出"最低薪资大于最高薪资"的无效组合(如"15K-10K")。本案例通过双列List滚动选择器与 onScrollIndex 回调实现薪资范围联动约束,确保最低薪资始终不大于最高薪资,从而提升数据有效性与用户操作体验。1.2 原因分析· 双列选择器状态同步复杂,易出现无效数据左右两列独立滚动,若不进行联动约束,用户可能选出逻辑矛盾的薪资组合,导致后续数据校验失败或业务逻辑异常。· 滚动事件与索引计算易出现偏差List组件的onScrollIndex返回的是可见区域首项索引,需要根据显示行数计算实际选中项,计算偏差会导致约束判断错误或滚动回弹位置不准确。· 初始化与滚动事件冲突组件初始化时需要设置默认滚动位置,若此时onScrollIndex 回调被触发,会干扰初始定位逻辑,导致选中项显示错乱。1.3 解决思路· 实时滚动监听与联动约束在onScrollIndex回调中实时获取当前滚动位置,计算选中项的薪资值,与另一列的当前值进行比较,若违反约束则强制回滚到合法位置。· 选中项索引精确计算根据 List 显示5行、中间行为选中项的布局特点,通过start +2计算实际选中项索引,并结合边界检查确保索引有效。· 初始化防抖机制使用标志位,在组件初始化完成前屏蔽onScrollIndex回调,避免初始滚动定位被干扰。1.4 解决方案薪资数据结构设计// 薪资选项数组,前后各添加2个空项(值为0),确保所有有效选项都能滚动到中间位置 // 当前选中索引(状态变量)@Local minSalaryIndex: number = 9  // 默认10K@Local maxSalaryIndex: number = 11 // 默认12K 初始化防抖与默认定位private isInitialized: boolean = false // 防止初始化时onScrollIndex干扰 aboutToAppear(): void {  // 解析当前薪资并设置索引...    // 延迟设置初始滚动位置,避免渲染冲突  setTimeout(() => {    this.minScroller.scrollToIndex(this.minSalaryIndex, false, ScrollAlign.CENTER)    this.maxScroller.scrollToIndex(this.maxSalaryIndex, false, ScrollAlign.CENTER)    this.isInitialized = true // 标记初始化完成  }, 50)} 最低薪资列联动约束List({ space: 0, scroller: this.minScroller }) {  ForEach(this.salaryOptions, (salary: number, index: number) => {    ListItem() {      Text(salary === 0 ? '' : `${salary}K`)    }  })}.onScrollIndex((start: number) => {  if (!this.isInitialized) return // 初始化完成前不响应    // 计算选中项索引(中间项 = start + 2)  const newIndex = Math.max(0, Math.min(start + 2, this.salaryOptions.length - 1))    if (this.salaryOptions[newIndex] !== 0) { // 跳过空项    const newMinValue = this.salaryOptions[newIndex]    const currentMaxValue = this.salaryOptions[this.maxSalaryIndex]        // 核心约束:最低薪资不能大于最高薪资    if (newMinValue <= currentMaxValue) {      this.minSalaryIndex = newIndex // 允许更新    } else {      // 违反约束,强制回滚到最高薪资位置      this.minScroller.scrollToIndex(this.maxSalaryIndex, true, ScrollAlign.CENTER)    }  }}).onScrollStop(() => {  // 滚动停止后自动对齐到选中项  this.minScroller.scrollToIndex(this.minSalaryIndex, true, ScrollAlign.CENTER)}) 最高薪资列联动约束.onScrollIndex((start: number) => {  if (!this.isInitialized) return    const newIndex = Math.max(0, Math.min(start + 2, this.salaryOptions.length - 1))    if (this.salaryOptions[newIndex] !== 0) {    const newMaxValue = this.salaryOptions[newIndex]    const currentMinValue = this.salaryOptions[this.minSalaryIndex]        // 核心约束:最高薪资不能小于最低薪资    if (newMaxValue >= currentMinValue) {      this.maxSalaryIndex = newIndex // 允许更新    } else {      // 违反约束,强制回滚到最低薪资位置      this.maxScroller.scrollToIndex(this.minSalaryIndex, true, ScrollAlign.CENTER)    }  }}) 1.5 总结· 问题与痛点:双列选择器无联动约束易产生无效数据;滚动索引计算复杂;初始化与滚动事件易冲突。· 技术要点:通过onScrollIndex实时监听滚动位置、start + 2计算选中项索引、条件判断实现联动约束、scrollToIndex强制回滚违规选择、isInitialized标志位实现初始化防抖。· 实现效果:用户滑动最低薪资列时,若选择值超过当前最高薪资,列表自动回弹到最高薪资位置;滑动最高薪资列时同理,确保始终满足"最低 ≤ 最高"的约束条件,操作流畅自然。· 适用场景:薪资范围选择、价格区间筛选、日期范围选择、数值区间设置等需要双列联动约束的交互场景。
  • [技术干货] 基于大文件复制沙箱阻塞主进程导致程序崩溃
     1.1 问题说明用户在使用网络上传或者下载文件时,会遇到将本地文件先复制到沙箱中的步骤,该步骤的必要性是鸿蒙系统的安全考虑,不能将本地文件直接作为源资源上传,所以需要先复制到具体应用自带的沙箱空间。复制文件会遇到大文件或者超大文件,这时候复制过程大概率会影响主进程其他任务,甚至应用崩溃问题。1.2 原因分析·  阻塞了主进程使用了阻塞的复制文件方法,该方法会导致主进程一直处于等待结束的过程中。1.3 解决思路· 使用异步复制文件方法:将原先使用的copyFileSync方法或者await copyFile,改成异步的copyFile(xxx, xxx).then(xxx).catch(xxx).finally(xxx)写法。· 调整fianlly结束块的位置:原来的finally代码块放在结尾,因为copyFileSync是同步方法,所以只有等到复制文件结束才关闭,现在使用异步复制文件,将finally结束块的代码写到copyFile异步方法后面,同时原来的catch语句块也需要加关闭文件流的代码,保证逻辑闭环。1.4 解决方案使用异步复制文件方法  fs.copyFile(srcFile.fd, dstFile.fd).then((res) => {          // 复制成功之后的回调        }).catch((err: BusinessError) => {          // 复制异常的回调        }).finally(() => {          if (srcFile) {// 关闭源文件文件流            fs.closeSync(srcFile);          }          if (dstFile) {// 关闭目标文件文件流            fs.closeSync(dstFile);          }        });调整fianlly结束块的位置 let srcFile: fs.File | null = null;      let dstFile: fs.File | null = null;      try {        // 文件打开和读取... ...// 异步复制文件        fs.copyFile(srcFile.fd, dstFile.fd).then((res) => {           // 复制成功之后的回调        }).catch((err: BusinessError) => {          // 复制异常的回调        }).finally(() => {          // 异步方法关闭文件流          if (srcFile) {            fs.closeSync(srcFile);          }          if (dstFile) {            fs.closeSync(dstFile);          }        });       } catch (e) {// 异常关闭文件流        if (srcFile) {          fs.closeSync(srcFile);        }        if (dstFile) {          fs.closeSync(dstFile);        }        return;      } 1.5 总结技术难点在于何处关闭文件流,关闭文件流是一个必要的步骤,如果缺少关闭文件流,会导致鸿蒙系统后面的文件上传复制操作都异常。复制文件使用异步方法可以避免阻塞主进程,同时也可以避免用户操作其他步骤导致复制文件失败的问题。
  • [技术交流] 基于鸿蒙 ArkUI 实现滚动文本智能悬停注释效果
    1. 问题说明在专业文档阅读、外语学习、技术手册浏览等鸿蒙应用场景中,用户常遇到生僻术语、专业概念或复杂公式,需频繁跳转查询释义,导致阅读节奏中断;传统静态注释要么遮挡文本,要么位置固定难以对应,触屏操作时精准点击小尺寸注释入口也存在不便。本案例基于 ArkUI 的滚动监听与动态定位技术,实现智能悬停注释效果,用户滑动文本时注释随目标内容同步滚动,点击或长按术语即可触发悬浮注释框,不破坏页面布局且操作直观,大幅提升专业内容阅读效率。2. 原因分析滚动同步逻辑复杂 文本滚动时注释需实时匹配目标术语位置,涉及滚动偏移量计算、组件坐标动态更新,易出现注释错位或跟随延迟。交互冲突风险高 滚动手势、点击 / 长按触发注释、注释框关闭等操作集中在文本区域,若事件优先级处理不当,会导致触发失效或误操作。多术语叠加显示混乱 同一屏幕出现多个可注释术语时,注释框易重叠遮挡,需智能判断显示位置避免视觉干扰。3. 解决思路滚动监听与坐标绑定通过 Scroll 组件的滚动事件监听,实时获取滚动偏移量,结合术语组件的初始坐标,动态计算注释框的悬浮位置。多手势优先级管理采用 “滚动事件优先、交互事件延迟响应” 机制,滚动时屏蔽注释触发,静止时激活点击 / 长按手势,避免操作冲突。智能布局避让策略根据术语在屏幕中的位置(顶部 / 中部 / 底部),自动选择注释框显示方向(下浮 / 上浮 / 侧浮),同时检测多注释框位置,避免重叠。4.解决方案滚动监听与坐标同步// 监听文本滚动事件,获取实时偏移量 Scroll() { Text(this.professionalText) .id("contentText") .onScroll((event: ScrollEvent) => { this.scrollOffset = event.scrollOffsetY; // 记录垂直滚动偏移量 this.updateAnnotationPosition(); // 同步更新注释框位置 }) } // 动态计算注释框坐标 updateAnnotationPosition() { if (this.activeTermId) { // 获取当前激活术语的组件坐标 const termRect = this.getComponentRect(this.activeTermId); // 结合滚动偏移量计算注释框悬浮位置 this.annotationX = termRect.x + termRect.width / 2 - 100; // 水平居中 this.annotationY = termRect.y - this.scrollOffset - 50; // 悬浮于术语上方 // 边界检测与避让调整 if (this.annotationY < 20) this.annotationY = termRect.y - this.scrollOffset + termRect.height + 10; } }手势交互与注释触发 // 术语文本组件,绑定长按+点击双触发方式 Text(term) .id(term-{index}`) .fontColor(Color.Blue) .onClick(() => { if (!this.isScrolling) { // 滚动状态下屏蔽点击 this.activeTermId = `term-{index}; this.annotationContent = this.getTermDefinition(term); this.showAnnotation = true; } }) .onLongPress(() => { if (!this.isScrolling) { this.activeTermId = term-${index}`; this.annotationContent = this.getTermFullExplanation(term); // 长按显示完整释义 this.showAnnotation = true; } }) // 滚动状态判断(避免滚动时误触发) .onScrollStart(() => { this.isScrolling = true; this.showAnnotation = false; // 滚动时隐藏注释框 }) .onScrollEnd(() => { this.isScrolling = false; })` 悬停注释框布局 // 悬浮注释框,通过绝对定位实现悬停效果 if (this.showAnnotation && this.annotationContent) { Text(this.annotationContent) .backgroundColor(Color.White) .borderRadius(8) .padding(12) .fontSize(14) .shadow({ radius: 4, offsetX: 2, offsetY: 2, color: Color.Gray }) .position({ x: this.annotationX, y: this.annotationY }) .width(200) .zIndex(999) // 确保层级高于文本内容 .onClick(() => { this.showAnnotation = false; // 点击注释框关闭 }) } 5.总结问题与痛点:专业内容阅读时查询注释中断节奏;静态注释遮挡或错位;小尺寸注释入口操作不便;多术语注释易重叠。技术要点:通过 Scroll 组件滚动监听实现位置同步、手势优先级管理避免操作冲突、动态坐标计算 + 边界检测实现智能避让、绝对定位 + 高层级渲染实现悬停效果。实现效果:用户滚动文本时注释框随目标术语同步移动,点击 / 长按术语即可触发悬浮注释,注释框自动避让屏幕边界与其他元素,不破坏页面布局,操作流畅且释义展示清晰,有效提升专业文本阅读连贯性与交互便捷性。适用场景:专业文档阅读 APP、外语学习应用、技术手册浏览界面、学术论文阅读工具、需要术语注释的知识类应用。 
  • [技术干货] 开发者技术支持-基于鸿蒙 ArkUI 实现滚动文本智能悬停注释效果
    1. 问题说明在专业文档阅读、外语学习、技术手册浏览等鸿蒙应用场景中,用户常遇到生僻术语、专业概念或复杂公式,需频繁跳转查询释义,导致阅读节奏中断;传统静态注释要么遮挡文本,要么位置固定难以对应,触屏操作时精准点击小尺寸注释入口也存在不便。本案例基于 ArkUI 的滚动监听与动态定位技术,实现智能悬停注释效果,用户滑动文本时注释随目标内容同步滚动,点击或长按术语即可触发悬浮注释框,不破坏页面布局且操作直观,大幅提升专业内容阅读效率。2. 原因分析滚动同步逻辑复杂 文本滚动时注释需实时匹配目标术语位置,涉及滚动偏移量计算、组件坐标动态更新,易出现注释错位或跟随延迟。交互冲突风险高 滚动手势、点击 / 长按触发注释、注释框关闭等操作集中在文本区域,若事件优先级处理不当,会导致触发失效或误操作。多术语叠加显示混乱 同一屏幕出现多个可注释术语时,注释框易重叠遮挡,需智能判断显示位置避免视觉干扰。3. 解决思路滚动监听与坐标绑定通过 Scroll 组件的滚动事件监听,实时获取滚动偏移量,结合术语组件的初始坐标,动态计算注释框的悬浮位置。多手势优先级管理采用 “滚动事件优先、交互事件延迟响应” 机制,滚动时屏蔽注释触发,静止时激活点击 / 长按手势,避免操作冲突。智能布局避让策略根据术语在屏幕中的位置(顶部 / 中部 / 底部),自动选择注释框显示方向(下浮 / 上浮 / 侧浮),同时检测多注释框位置,避免重叠。4.解决方案滚动监听与坐标同步`// 监听文本滚动事件,获取实时偏移量Scroll() {Text(this.professionalText).id(“contentText”).onScroll((event: ScrollEvent) => {this.scrollOffset = event.scrollOffsetY; // 记录垂直滚动偏移量this.updateAnnotationPosition(); // 同步更新注释框位置})}// 动态计算注释框坐标updateAnnotationPosition() {if (this.activeTermId) {// 获取当前激活术语的组件坐标const termRect = this.getComponentRect(this.activeTermId);// 结合滚动偏移量计算注释框悬浮位置this.annotationX = termRect.x + termRect.width / 2 - 100; // 水平居中this.annotationY = termRect.y - this.scrollOffset - 50; // 悬浮于术语上方// 边界检测与避让调整if (this.annotationY < 20) this.annotationY = termRect.y - this.scrollOffset + termRect.height + 10;}}`手势交互与注释触发// 术语文本组件,绑定长按+点击双触发方式 Text(term) .id(term-{index}`) .fontColor(Color.Blue) .onClick(() => { if (!this.isScrolling) { // 滚动状态下屏蔽点击 this.activeTermId = `term-{index}; this.annotationContent = this.getTermDefinition(term); this.showAnnotation = true; } }) .onLongPress(() => { if (!this.isScrolling) { this.activeTermId = term-${index}`;this.annotationContent = this.getTermFullExplanation(term); // 长按显示完整释义this.showAnnotation = true;}})// 滚动状态判断(避免滚动时误触发).onScrollStart(() => {this.isScrolling = true;this.showAnnotation = false; // 滚动时隐藏注释框}).onScrollEnd(() => {this.isScrolling = false;})`悬停注释框布局// 悬浮注释框,通过绝对定位实现悬停效果 if (this.showAnnotation && this.annotationContent) { Text(this.annotationContent) .backgroundColor(Color.White) .borderRadius(8) .padding(12) .fontSize(14) .shadow({ radius: 4, offsetX: 2, offsetY: 2, color: Color.Gray }) .position({ x: this.annotationX, y: this.annotationY }) .width(200) .zIndex(999) // 确保层级高于文本内容 .onClick(() => { this.showAnnotation = false; // 点击注释框关闭 }) }5.总结问题与痛点:专业内容阅读时查询注释中断节奏;静态注释遮挡或错位;小尺寸注释入口操作不便;多术语注释易重叠。技术要点:通过 Scroll 组件滚动监听实现位置同步、手势优先级管理避免操作冲突、动态坐标计算 + 边界检测实现智能避让、绝对定位 + 高层级渲染实现悬停效果。实现效果:用户滚动文本时注释框随目标术语同步移动,点击 / 长按术语即可触发悬浮注释,注释框自动避让屏幕边界与其他元素,不破坏页面布局,操作流畅且释义展示清晰,有效提升专业文本阅读连贯性与交互便捷性。适用场景:专业文档阅读 APP、外语学习应用、技术手册浏览界面、学术论文阅读工具、需要术语注释的知识类应用。
  • 开发者技术支持-鸿蒙轮播指示器自定义方案
    1. 问题说明内置指示器indicator无法满足设计需求,只能设置为圆角样式,无法设置成UI设计中的其他例如方块样式、进度条样式等个性化设计需求,且位置、交互效果等定制化程度有限。2. 原因分析内置指示器样式固定,仅支持基础圆点样式,无法自定义形状、颜色过渡和交互反馈位置调整受限,默认边距无法完全消除,难以实现贴边显示效果交互能力有限,不支持点击切换页面等高级交互功能动画效果单一,无法实现进度条式等动态展示效果3. 解决思路通过关闭默认指示器 + Stack布局叠加自定义视图的方式实现完全个性化的指示器效果,具体包括:使用Stack容器实现指示器与Swiper组件的视觉叠加通过onChange事件同步当前轮播索引状态利用ForEach动态生成指示器项,适配不同数据量结合animation属性实现平滑过渡动画封装独立组件提高复用性和性能4. 解决方案4.1 基础自定义方案(替代内置指示器)通过Stack布局叠加Row实现基础自定义指示器,支持选中状态变化动画:Stack({ alignContent: Alignment.Bottom }) {  // 主轮播内容  Swiper(this.swiperController) {    ForEach(this.imageList, (item) => {      Image(item)        .width('100%')        .height(240)    })  }  .indicator(false) // 关闭默认指示器  .onChange((index) => {    this.currentIndex = index // 同步当前索引  })  // 自定义指示器  Row({ space: 6 }) {    ForEach(this.imageList, (_, index) => {      // 动态改变选中项样式      Column()        .width(this.currentIndex === index ? 24 : 8)        .height(8)        .borderRadius(4)        .backgroundColor(this.currentIndex === index ? Color('#007DFF') : Color('#CCCCCC'))        .animation({ duration: 200, curve: Curve.EaseOut }) // 平滑过渡动画    })  }  .margin({ bottom: 16 }) // 底部间距}关键技术点:使用Stack布局实现指示器与Swiper的视觉叠加通过onChange事件同步当前轮播索引利用animation属性实现选中状态过渡效果推荐使用ForEach动态生成指示器项,避免硬编码4.2 进度条式指示器(高级自定义)实现随轮播进度动态增长的进度条指示器,结合属性动画与Swiper事件:@Componentstruct ProgressIndicator {  @Prop currentIndex: number  @Prop totalCount: number  @Prop duration: number // 与Swiper轮播间隔一致  build() {    Row({ space: 4 }) {      ForEach(Array.from({ length: this.totalCount }), (_, index) => {        Stack({ alignContent: Alignment.Start }) {          // 底层灰色轨道          Row()            .width('100%')            .height(2)            .backgroundColor('#666666')                    // 上层白色进度条          Row()            .width(this.currentIndex >= index ? '100%' : 0)            .height(2)            .backgroundColor('#FFFFFF')            .animation({               duration: this.currentIndex === index ? this.duration : 0,               curve: Curve.Linear             })        }        .layoutWeight(1)      })    }    .width('90%')    .margin({ bottom: 20 })  }}// 使用方式Stack() {  Swiper() {    // 轮播内容  }  .indicator(false)  .autoPlay(true)  .interval(3000)  .onChange((index) => {    this.currentIndex = index  })    ProgressIndicator({    currentIndex: this.currentIndex,    totalCount: this.imageList.length,    duration: 3000 // 与interval保持一致  })}实现原理:每个进度条由上下两层Row组件叠加而成(轨道+进度条)当前页面对应的进度条通过animation实现3秒线性增长已轮播页面进度条保持100%宽度未轮播页面进度条宽度为04.3 带交互功能的自定义指示器实现点击指示器切换页面功能,结合SwiperController与手势识别:struct InteractiveIndicator {  @Prop currentIndex: number  @Prop totalCount: number  controller: SwiperController // 接收Swiper控制器  build() {    Row({ space: 8 }) {      ForEach(Array.from({ length: this.totalCount }), (_, index) => {        GestureDetector() {          Column()            .width(this.currentIndex === index ? 16 : 8)            .height(8)            .borderRadius(4)            .backgroundColor(this.currentIndex === index ? Color.Red : Color.Gray)        }        .onClick(() => {          // 点击切换到对应页面          this.controller.showIndex(index)        })      })    }  }}// 使用方式private controller: SwiperController = new SwiperController()build() {  Stack() {    Swiper(this.controller) {      // 轮播内容    }    .indicator(false)    .onChange((index) => {      this.currentIndex = index    })        InteractiveIndicator({      currentIndex: this.currentIndex,      totalCount: 5,      controller: this.controller // 传递控制器    })  }}交互优化点:增大点击热区(建议最小8×8vp)添加点击反馈动画(如缩放、颜色变化)禁用快速连续点击(可通过防抖处理)确保指示器与Swiper滑动区域无重叠5. 常见问题解决方案5.1 内置指示器位置无法贴边显示问题现象:设置bottom: 0后仍有默认边距解决方案:通过负边距抵消内边距.indicator(Indicator.dot()  .bottom(-8) // 负边距调整  .left(0))原理:Swiper组件内部有默认内边距,需通过负外边距补偿5.2 自定义指示器与Swiper滑动冲突问题现象:点击指示器时触发Swiper滑动解决方案:提高指示器手势优先级GestureDetector() {  // 指示器内容}.priority(10) // 高于Swiper默认手势优先级.onClick(() => {  // 切换逻辑})原理:鸿蒙手势系统通过priority属性解决冲突,值越高优先级越高5.3 循环模式(loop=true)下索引异常问题现象:启用循环后指示器计数错误解决方案:对索引进行取模处理.onChange((index: number) => {  // 解决循环模式下索引溢出问题  this.currentIndex = index % this.totalCount})原理:loop模式下Swiper实际索引范围为[1, totalCount+1],需转换为[0, totalCount-1]6. 性能优化最佳实践6.1 减少重绘区域优化方案:将指示器独立为自定义组件,限制重绘范围@Componentstruct LightweightIndicator {  @Prop currentIndex: number  @Prop count: number  build() {    // 仅包含必要UI元素,避免复杂逻辑    Row({ space: 4 }) {      // 指示器项    }  }}性能收益:局部状态更新时仅重绘指示器区域,不影响Swiper主体6.2 避免过度动画优化建议:动画时长控制在200-300ms内优先使用系统内置曲线(如Curve.EaseOut)非关键状态变化可关闭动画.animation({  duration: 200,  curve: Curve.EaseOut,  iterations: 1 // 禁止无限循环})6.3 组件复用策略对频繁创建销毁的指示器项,使用**@Reusable**装饰器:@Reusable@Componentstruct ReusableIndicatorItem {  @Prop isSelected: boolean  build() {    Column()      .width(this.isSelected ? 20 : 8)      .height(8)      .backgroundColor(this.isSelected ? Color.Blue : Color.Gray)  }}适用场景:动态数据源的Swiper(如网络图片轮播)频繁更新的指示器(如实时数据展示)长列表轮播(如商品列表)
  • [案例共创] 【案例共创】基于仓颉、DeepSeek与RGF的AI桌宠开发实践
    前言不久前在仓颉的交流群中看到了关于仓颉案例共创的活动消息,于是想到可以用仓颉结合自身所长开发一个AI桌面宠物供大家娱乐学习。正好笔者封装的仓颉桌面渲染库 RGF_CJ 和控件库 RGF_UI 能满足这方面的需求,那么说干就干!成品效果开发前的准备在正式进行应用的开发之前,需要先准备好相关的依赖。领取华为云DeepSeek免费资源华为云为开发者提供了单模型200万Tokens的免费额度,包含DeepSeek-R1&V3满血版,登录华为云ModelArts Studio(MaaS)控制台即可领取,此处可以选择DeepSeek-R1满血版来搭建AI聊天桌宠。提示:点击此链接领取:https://www.huaweicloud.com/product/modelarts/studio.html进入网站签署服务声明领取免费额度并查看调用说明通过说明中的链接去创建API点击按钮创建API填写API相关信息获取API并保存提示:请保存好此处的API链接,后续代码开发中会用到。获取RGF依赖文件在后续应用的开发中会依赖到RGF_UI库,此库依赖到一些文件,可以从RGF_UI的库中获取。这些依赖文件后续会链接入应用中去。提示:点击此链接下载:https://gitcode.com/raozj/RGF_UI/tree/master/libs进入RGF_UI库的Libs目录,下载依赖文件保存依赖文件,留待后续使用安装仓颉STDX及相关依赖因为是基于仓颉开发通信相关的应用,因此需要依赖到STDX中的stdx.net.http、stdx.encoding.json、stdx.net.tls等包,而这些包自Cangjie v0.60.5版本开始从std标准库中移出,因此需要自行安装这些包以及这些包所依赖的OpenSSL。提示:点击此链接下载:https://gitcode.com/Cangjie/Cangjie-STDX注’ 由于笔者发文时Cangjie v0.60.5版本尚且处于内测阶段,因此若开发者无内测资格则无法下载到此版本的相关文件。关于 STDX 和 OpenSSL 的安装请参考上述链接中官方的文档,此处不做赘述。序列帧合图资源制作作为一个桌面宠物,自然需要拥有对应的动画,对此可以使用DragonBones、Spine或者Adobe Effect等软件制作。制作动画笔者在此使用的是DragonBones,关于DragonBones的安装和使用不在本文的探讨范畴之内,读者可自行选择喜欢的动画制作软件,设计并制作完成动画后输出序列帧以用于应用的开发。设计动画制作出序列帧制作合图资源如果每帧的图片都单独存储,对于应用的初始化加载和渲染都是一个较大的负担,因此建议将一类的序列帧动画按照相等宽高、间距合并为一张图片。由于手动合图比较麻烦,因此笔者写了一个Python脚本,快速的将目录中的序列帧图片按照指定的宽度分布数量合并为一张图片。注’ 由于此部分不属于文章核心部分,因此直接贴出代码以供参考,读者可通过其它工具或代码实现自己个性化的合图方案。import os import cv2 import numpy as np def load_images_from_folder(folder): """ 加载指定文件夹下的所有 .png 图像 """ image_files = [f for f in os.listdir(folder) if f.lower().endswith('.png')] image_files.sort() # 按文件名排序 images = [] for filename in image_files: file_path = os.path.join(folder, filename) # 读取图像(支持中文路径) with open(file_path, 'rb') as f: img_data = np.frombuffer(f.read(), dtype=np.uint8) img = cv2.imdecode(img_data, cv2.IMREAD_UNCHANGED) images.append(img) print(f"已加载: {filename}") return images def stitch_images(images, cols, bg_color=(0, 0, 0, 0)): """ 将图像按指定列数拼接成一张大图 :param images: 图像列表 :param cols: 每行显示的图像数量 :param bg_color: 背景颜色(支持 RGBA) :return: 拼接后的图像 """ if not images: raise ValueError("没有图像可供拼接") h, w = images[0].shape[:2] rows = (len(images) + cols - 1) // cols # 创建空白背景图 channels = images[0].shape[2] if len(images[0].shape) == 3 else 1 if channels == 1: bg_color = (bg_color[0], ) # 灰度图 elif channels == 3: bg_color = bg_color[:3] # RGB elif channels == 4: bg_color = bg_color[:4] # RGBA canvas = np.full((h * rows, w * cols, channels), bg_color, dtype=np.uint8) for idx, img in enumerate(images): row = idx // cols col = idx % cols x = col * w y = row * h canvas[y:y+h, x:x+w] = img return canvas def main(): folder = input("请输入包含PNG图像的文件夹路径:").strip() if not os.path.isdir(folder): print("输入的不是有效文件夹路径!") return try: cols = int(input("请输入每行显示的图像数量:")) except ValueError: print("请输入有效的数字!") return output_path = input("请输入输出图像的完整路径(如 output.png):").strip() images = load_images_from_folder(folder) if not images: print("未找到任何PNG图像!") return # 检查所有图像是否尺寸一致 first_h, first_w = images[0].shape[:2] for img in images[1:]: h, w = img.shape[:2] if h != first_h or w != first_w: print("警告:图像尺寸不一致,可能影响拼接效果。") # 拼接图像 print("开始拼接图像...") stitched_image = stitch_images(images, cols) # 保存结果 cv2.imwrite(output_path, stitched_image) print(f"拼接完成,已保存为: {output_path}") if __name__ == '__main__': main() 开发AI桌宠基本规划首先对项目目录和文件进行基本的规划,后续的开发就基于规划进行。目录规划Yez/ -------------------------------------------# 项目主目录 ├─libs/ ---------------------------------------# 依赖库 ├─res/ ----------------------------------------# 资源 │ └─SequenceFrame/ ---------------------------# 序列帧资源 │ └─skip_white/ ---------------------------# 椰椰的白色皮肤资源 ├─src/ ----------------------------------------# 源代码目录 └─tool/ ---------------------------------------# 工具目录文件规划Yez/ ├┈cjpm.lock ├┈cjpm.toml ├─libs/ │ ├┈libimm32.a -------------------------------# Windows 输入法库 │ ├┈resources.rc.o ---------------------------# 资源配置清单 内部声明应用依赖的最低系统版本 │ └┈libRgf.dll -------------------------------# RGF渲染库依赖的动态库 ├─res/ │ ├┈config.json ------------------------------# 椰椰的配置文件 │ └─SequenceFrame/ │ └─skip_white/ │ ├┈blink.json -------------------------# 眨眼合图描述文件 │ ├┈blink.png --------------------------# 眨眼动画合图 │ ├┈jump.json --------------------------# 跳跃合图描述文件 │ ├┈jump.png ---------------------------# 跳跃动画合图 │ ├┈speak.json -------------------------# 说话合图描述文件 │ ├┈speak.png --------------------------# 说话动画合图 │ ├┈wait.json --------------------------# 待机合图描述文件 │ └┈wait.png ---------------------------# 待机动画合图 ├─src/ │ ├┈chat.cj ----------------------------------# 大模型网络通信代码 │ ├┈config.cj --------------------------------# 配置管理代码 │ ├┈main.cj ----------------------------------# 程序主入口代码 │ ├┈res.cj -----------------------------------# 资源管理代码 │ ├┈win_anm.cj -------------------------------# 动画绘制窗口代码 │ ├┈win_input.cj -----------------------------# 输入框窗口代码 │ └┈win_text.cj ------------------------------# 鼠标文本渲染代码 └─tool/ └┈picture_puzzle.py ------------------------# python 合图脚本代码链接文件与库配置在开发之前,需要现为当前项目链接依赖文件和添加依赖库。链接文件在之前的小节中,下载了RGF_UI的依赖文件压缩包,在下载的压缩包内应该包含三个文件,即libRgf.dll、resources.rc.o和libimm32.a。这三个文件中,libRgf.dll是RGF_CJ库的依赖文件,必须随应用打包发布;resources.rc.o文件是Windows资源描述文件,读者也可以自行编写resources.rc文件并生成此文件(需设置最低支持Windows 8,即内核NT 6.2);libimm32.a文件是自绘编辑框控件依赖的文件,主要提供输入法相关的操作能力。在仓颉包管理配置toml文件中,需要加入链接上述文件的代码:[target] [target.x86_64-w64-mingw32] link-option = "-L ./libs -l:libRgf.dll -l user32 -l imm32" 库配置完成链接后,还需要为应用声明依赖RGF_CJ和RGF_UI包,并且为RGF_CJ包配置宏。提示:点击此链接查看依赖库RGF_CJ:https://gitcode.com/Cangjie-SIG/RGF_CJ提示:点击此链接查看依赖库RGF_UI:https://gitcode.com/raozj/RGF_UI[dependencies] [dependencies.rgf] git = "https://gitcode.com/Cangjie-SIG/RGF_CJ.git" [dependencies.rgfui] git = "https://gitcode.com/raozj/RGF_UI.git" [profile] [profile.customized-option] rgfCfg = "--cfg \"RGF_LANG=zh-cn\"" 上述代码中配置的“RGF_LANG=zh-cn”表示:当RGF_CJ出现异常时,捕获输出的异常提示内容为中文。配置加载模块笔者希望桌宠能够具备记忆配置的能力,因此需要为桌宠写一个配置的加载、保存模块,这个模块会记住桌宠所使用的皮肤、最后所在的桌面位置等信息,以确保桌宠重启后依然符合之前的情况。编写一个配置管理相关的恶汉单例类ConfigMgr,单例具备两个方法,一个用于从文件中加载配置,一个用于写出配置到文件中去。(其实也可以直接编写静态方法或直接编写函数而不使用类,看大家自己的选择吧。)代码如下:// Yez/src/config.cj // 配置管理类 class ConfigMgr { private static let _instance:ConfigMgr = ConfigMgr() public static prop i:ConfigMgr{ get(){ _instance } } private init() {} ... /** * 加载配置文件 * @return 是否成功 */ public func readConfig():Bool{ ... } /** 写出配置文件 */ public func writeConfig():Unit{ ... } ... } 编写一个ConfigInfo类,这个类用于存放实际的配置项,并且提供转换为Json的能力,代码如下:// Yez/src/config.cj class ConfigInfo <: Serializable<ConfigInfo> { // 椰椰右下角窗口坐标系的X坐标 var _x:Int32 = Int32.Min // 椰椰右下角窗口坐标系的Y坐标 var _y:Int32 = Int32.Min // 椰椰使用的皮肤 var _skip = "skip_white" // 椰椰画面缩放比例 var _zoom:Float32 = 0.125 // 椰椰的字体大小 var _fontSize:Float32 = 16.1 public func serialize(): DataModel { return DataModelStruct().add(field<Int32>("x", _x)) .add(field<Int32>("y", _y)) .add(field<String>("skip", _skip)) .add(field<Float32>("zoom", _zoom)) .add(field<Float32>("fontSize", _fontSize)) } public static func deserialize(dm: DataModel): ConfigInfo { var dms = match (dm) { case data: DataModelStruct => data case _ => throw Exception("this data is not DataModelStruct") } var result = ConfigInfo() result._x = Int32.deserialize(dms.get("x")) result._y = Int32.deserialize(dms.get("y")) result._skip = String.deserialize(dms.get("skip")) result._zoom = Float32.deserialize(dms.get("zoom")) result._fontSize = Float32.deserialize(dms.get("fontSize")) return result } } 将ConfigInfo以成员变量的形式加入到ConfigMgr中,并且由ConfigMgr对外提供访问属性,代码如下:// Yez/src/config.cj // 配置管理类 class ConfigMgr { // 配置信息 private var configImpl:ConfigInfo = ConfigInfo() ... public mut prop x:Int32{ get(){ return configImpl._x } set(val){ configImpl._x = val } } public mut prop y:Int32{ get(){ return configImpl._y } set(val){ configImpl._y = val } } public mut prop skip:String{ get(){ return configImpl._skip } set(val){ configImpl._skip = val } } public prop zoom:Float32{ get(){ return configImpl._zoom } } public prop fontSize:Float32{ get(){ return configImpl._fontSize } } } 接下来只要完善readConfig和writeConfig的读取和写出逻辑即可,代码如下:// Yez/src/config.cj // 配置文件路径 const CONFIG_FILE_PATH = "./res/config.json" ... public func readConfig():Bool{ // 判断目录是否存在 if(exists(CONFIG_FILE_PATH)){ let seqFramePath = canonicalize(CONFIG_FILE_PATH) let bytes = File.readFrom(seqFramePath) var jv = JsonValue.fromStr(StringReader(ByteBuffer(bytes)).readToEnd()) var dm = DataModel.fromJson(jv) configImpl = ConfigInfo.deserialize(dm) } // 目前始终返回 true // 当配置不存在时,则使用默认配置 return true } public func writeConfig():Unit{ if(!exists(CONFIG_FILE_PATH)){ File.create(CONFIG_FILE_PATH) } let seqFramePath = canonicalize(CONFIG_FILE_PATH) var Anm = configImpl.serialize() var dm = Anm.toJson() File.writeTo(seqFramePath,dm.toString().toArray()) } 最后,在入口函数中启用配置加载与写出即可。// Yez/src/main.cj main(): Int64 { // 加载配置文件数据 if(!ConfigMgr.i.readConfig()){ return 0 } ... // 保存配置到文件中去 ConfigMgr.i.writeConfig() return 0 } 资源加载模块除了配置之外,应用资源也是一个重要的部分,这部分的完整性会影响到应用是否正常渲染。首先采用和Config部分类似的结构准备恶汉单例类ResMgr和动画信息类AnmInfo,代码如下:// Yez/src/res.cj // 资源管理类 class ResMgr { private static let _instance:ResMgr = ResMgr() public static prop i:ResMgr{ get(){ _instance } } private init() {} // 动画 private static const ANM_BLINK ="blink" private static const ANM_JUMP ="jump" private static const ANM_SPEAK ="speak" private static const ANM_WAIT ="wait" private let _skipImg:HashMap<String, Array<(AnmInfo,MemBitmap)>> = HashMap<String, Array<(AnmInfo,MemBitmap)>>() public func loadRes():Bool{ ... } public func unloadRes(){ ... } public prop skip:HashMap<String, Array<(AnmInfo,MemBitmap)>>{ get(){ return _skipImg } } } class AnmInfo <: Serializable<AnmInfo> { // 单帧图片宽度 var width:Int32 = 0 // 单帧图片高度 var height:Int32 = 0 // 合图图片每行图片数量 var hCount:Int32 = 0 // 总帧数 var frames:Int32 = 0 ... } 然后就是实现在loadRes方法中加载合图及合图相关配置文件,代码逻辑还就是:从"./res/SequenceFrame"目录开始确保目录存在,然后遍历SequenceFrame目录下的所有皮肤目录,并且将皮肤目录内的合图资源及对应的配置文件载入到成员中去。如果目录下的资源文件不完整,则不会加载对应的内容到成员中。// Yez/src/res.cj /** * 代码较为复杂且不是核心代码,读者了解功能即可。 * 若需要查看相关代码,可下载项目查看。 */ public func loadRes():Bool{ ... } public func unloadRes(){ //释放所有图像资源(设备无关资源) for(skipPath in _skipImg){ skipPath[1][0][1].release() skipPath[1][1][1].release() skipPath[1][2][1].release() skipPath[1][3][1].release() } } 最后,在入口函数中启用资源加载与释放即可。// Yez/src/main.cj main(): Int64 { ... // 加载依赖资源 if(ResMgr.i.loadRes()){ ... } // 释放一切可能被加载的资源 ResMgr.i.unloadRes() ... return 0 } 大模型模块接下来便是与AI通信的核心部分了,此部分需要完成与大模型的互通。我们需要准备一个LLM类,这个类负责基于API、URL等信息与大模型建立链接,并且解析信息。// Yez/src/chat.cj class LLM { let client: Client let history = StringBuilder() public LLM( // 大模型URL链接 let url!: String, // 大模型 API Key let key!: String, // 大模型类型标识 let model!: String, // 上下文 let context!: Bool = false ) { var config = TlsClientConfig() config.verifyMode = TrustAll client = ClientBuilder() .tlsConfig(config) // AI 服务响应有时候比较慢,这里设置为无限等待 .readTimeout(Duration.Max) .build() } /** * 编码函数,将角色与内容编码为字符串 * return 字符串 */ func encode(role: Role, content: String) { ... } /** * 发送函数,将对话数据发送到服务器 * return HttpResponse */ func send(input: String, stream!: Bool = false) { ... } /** * 分析函数,解析服务器返回的数据并且提取其中有效的文本内容 * return 有效内容 */ func parse(text: String, stream!: Bool = false) { ... } /** * 流式对话 * @param input 输入的文本 * @param task 流式返回内容的回调函数 * @param taskEnd 返回内容结束的回调函数 */ public func chat(input: String, task!: (String) -> Unit = {o => print(o)},taskEnd!:()->Unit = {=>}) { ... } /** * 非流式对话 */ public func chat(input!: String) { ... } /** * 历史记忆预置 */ public func preset(context: Array<(Role, String)>) { ... } /** * 历史记忆清除 */ public func reset() { ... } } 根据华为云提供的调用参考,完善 send 方法代码如下:// Yez/src/chat.cj func send(input: String, stream!: Bool = false) { let message = encode(I, input) let content = '{"model":"${model}","messages":[${history}${message}],"stream":${stream}}' if (context) { history.append(message) } let request = HttpRequestBuilder() .url(url) .header('Authorization', 'Bearer ${key}') .header('Content-Type', 'application/json') .header('Accept', if (stream) { 'text/event-stream' } else { 'application/json' }) .body(content) .post() .build() client.send(request) } 根据华为云返回的数据格式,完善 parse 方法代码如下:// Yez/src/chat.cj func parse(text: String, stream!: Bool = false) { let json = JsonValue.fromStr(text).asObject() let choices = json.getFields()['choices'].asArray() if(choices.size() == 0){ return "" } // 流式和非流式情况下,这个字段名称不同 let key = if (stream) { 'delta' } else { 'message' } let message = choices[0].asObject().getFields()[key].asObject() if(message.containsKey("content")){ let content = message.getFields()['content'].asString().getValue() // 移除开头的两个 \n if(content == "\n\n"){ return "" } return content }else{ return "" } } 最后实现核心的流式对话部分,代码如下:// Yez/src/chat.cj public func chat(input: String, task!: (String) -> Unit = {o => print(o)},taskEnd!:()->Unit = {=>}) { // 根据传入的字符串构建上发服务器的 HttpResponse let response = send(input, stream: true) // 准备一个字符串缓冲区 let output = StringBuilder() // 准备一个字节缓冲区 let buffer = Array<Byte>(1024 * 8, {i=>0}) // 获取返回的内容 var length = response.body.read(buffer) while (length != 0) { // 将字节缓冲区内的内容按照UTF-8编码解析为字符串 let text = String.fromUtf8(buffer[..length]) const INDEX = 6 // 按行分割 for (line in text.split('\n', removeEmpty: true)) { // 确保获取的是完整的:data: {...} if (line.size > INDEX && line[INDEX] == b'{' && line[line.size - 1] == b'}') { let json = line[INDEX..line.size] // 分析出有效信息 let slice = parse(json, stream: true) if (context) { output.append(slice) } // 若有效信息不为空,则通过回调函数返回 if(slice != ""){ task(slice) } } } length = response.body.read(buffer) } if (context) { history.append(',${encode(AI, output.toString())},') } // 所有处理结束后,执行结束回调 taskEnd() } LLM类是一个较为通用的类,但是使用较为繁琐,因此对于外部调用而言,只需要一个简化的实例即可。因此,可以再编写一个独立的懒汉单例LLMImpl类,并且仅提供一个对外实现流式对话的chat方法,代码如下:// Yez/src/chat.cj class LLMImpl { private static var _i: Option<LLMImpl> = None public static prop i:LLMImpl { get(){ if(_i.isNone()){ _i = LLMImpl() } return _i.getOrThrow() } } private let robot:LLM // 初始化一个对话模型 private init(){ robot = LLM( // 华为云大模型调用URL url: 'https://maas-cn-southwest-2.modelarts-maas.com/v1/infers/8a062fd4-7367-4ab4-a936-5eeb8fb821c4/v1/chat/completions', // 如果示例自带的密钥失效,请自行注册,https://www.huaweicloud.com/product/modelarts/studio.html key: '/* !!! 这里填写读者自己的 API Key !!! */', // 要使用的 大模型 model: 'DeepSeek-R1', context: true ); // 设定基本信息 robot.preset([(System, #"我会自称“椰椰”并亲昵的在三句话之内回复所有问题。对于限定长度内无法阐述清晰的问题,我会表现出呆傻可爱的效果并说出“宕机”。如果对我说“拍照”或“截图”,我就会说“咔嚓~”。如果问我是谁开发的,我会说出“尧佥&椰子”。"#)]) } /** * 流式对话 * @param content 输入的文本 * @param task 流式返回内容的回调函数 * @param end 返回内容结束的回调函数 */ public func chat(content:String, task!: (String) -> Unit = {o => print(o)},end!:()->Unit = {=>}){ robot.chat(content,task:task,taskEnd:end) } } 注’ 完整的代码请参考提供的代码文件桌宠渲染接下来,需要实现可视化的窗口渲染,赋予“椰椰”一副“身体”。动画播放窗口为了实现AI桌宠的渲染,首先需要有一个窗口。对此,笔者选择基于RGF_CJ中的WinBase类,实现一个自己的窗口类WinMain。// Yez/src/win_anm.cj // 使用 RGF_CJ 的宏实现代码简化 @LifeCycle // 自动管理 设备相关资源 的释放 @HookProc // 自动挂载窗口类的消息事件方法 class WinMain <: WinBase{ ... // 窗口类初始化时,传入 ResMgr 载入的资源(设备无关资源) init(anm:Array<(AnmInfo, MemBitmap)>){ _anmRes = anm } /** * 加载设备无关资源 -> 设备相关资源 * @note 本部分将资源ResMgr类中的图片资源,载入到窗口类中 */ private func loadAnm(anm:Array<(AnmInfo, MemBitmap)>){ surface .createBitmapFromMemory(_bmps[0],anm[0][1]) .createBitmapFromMemory(_bmps[1],anm[1][1]) .createBitmapFromMemory(_bmps[2],anm[2][1]) .createBitmapFromMemory(_bmps[3],anm[3][1]) } /** * 创建设备资源事件,窗口渲染表面创建完成后,开始载入资源 */ public func createDeviceResources():Bool{ // 这里调用 loadAnm 载入图片资源 loadAnm(_anmRes) return true; } /** * 窗口创建完成事件,当窗口创建完成时,开始渲染动画 */ public func created(hwnd:RgfHwnd,x:Int32, y:Int32, nWidth:Int32, nHeight:Int32):Unit{ ... } /** * 绘制窗口事件,此处根据当前的动画状态渲染窗口 */ public override func onPaint(wRect:Rect):Unit{ surface.clear(0.0,0.0,0.0,0.0) match(_nowState){ case Wait => {=> // 等待状态的动画渲染比较特殊,因为需要实现眼睛跟随鼠标移动 if(_bmps[3].isValid() && _bmps[0].isValid()){ // 这一部分计算当前渲染帧的静态部分,即:身体, // 并且将这部分内容渲染到窗口 .... // 然后下面计算出鼠标对眼睛造成的偏移量 // 并且将眼睛渲染到身体上 if(_anmStep < _anmRes[0][0].frames){ .... }else{ .... } } }() case Jump => {=> // 这里渲染椰椰的跳跃动画 if(_bmps[1].isValid()){ .... } }() case Speak => {=> // 这里渲染椰椰正在说话的动画 if(_bmps[2].isValid()){ .... } }() } } } 在入口函数中,启用UIMain并且创建窗口,代码如下:// Yez/src/win_anm.cj main(): Int64 { ... if(ResMgr.i.loadRes()){ // 开始创建UI UIMain{=> // 确保皮肤配置有效 if(!ResMgr.i.skip.contains(ConfigMgr.i.skip)){ ConfigMgr.i.skip = ResMgr.i.skip.keys().toArray()[0] } // 创建窗口类 let anmWin:WinMain = WinMain(ResMgr.i.skip.get(ConfigMgr.i.skip).getOrThrow()) // 先随便在桌面 (0,0) 的位置创建一个 10像素×10像素 的窗口 // 窗口类内部会在显示前根据配置中的信息自动布局到正确位置和尺寸 anmWin.createWin(uiGetContext(), "Yez", 0, 0, 10, 10, 0, WinStyle.e2n(WinStyle.Popup), // 使用的是 TopMost 分层窗口(支持透明背景) dwExStyle:WinStyleEx.Default | WinStyleEx.TopMost, // 渲染使用的是 Direct2D 计数 layered:true,substrate:Substrate.Direct2D ) } } ... } 完成上述代码后,窗口已经可以正确的渲染动画了注’ 完整的代码请参考提供的代码文件编辑框窗口对于编辑框的开发是比较繁琐的,因此选择直接继承RGF_UI的UIInput类,然后将此控件修改为窗口,首先需要创建一个编辑框窗口类InputWin,代码如下:// Yez/src/win_input.cj public open class InputWin <: UIInput{ // 为编辑框设置独立的UI样式,而不是继承全局的样式 let css:UITheme = UITheme() init(){ super(theme:css) // 从配置中获取字体大小 css.fontSizeM = ConfigMgr.i.fontSize // 更新样式配置 css.update() } /** * 获取输入框窗口预期出现的位置 * @return 编辑框窗口坐标 * @note 本部分计算编辑框在当前椰椰所在桌面中心时的位置 */ private func getTargetLocation():Point{ // 方案二、桌面中心 let monitor = rsGetMonitorFromPoint(rsGetCursorPos()) let pos = Point( ((monitor.right - monitor.left) - INPUT_WIN_WIDTH) / 2, ((monitor.bottom - monitor.top) - INPUT_WIN_HEIGHT) / 2 ) return pos } /** * 创建窗口 */ public func createWin(hwndParent:RgfHwnd):RgfHwnd{ // 获取出现位置坐标 let pos = getTargetLocation() // 创建一个置顶层的无边框编辑框窗口 let retv = super.createWin( uiGetContext(),"",pos.x,pos.y,INPUT_WIN_WIDTH,INPUT_WIN_HEIGHT,hwndParent, WinStyle.PopupWindow | WinStyle.ClipChildren, dwExStyle:WinStyleEx.Default | WinStyleEx.TopMost, substrate:Substrate.Direct2D,layered:false,bgMod:BackGroundMod.CoverNotDraw,drawInterval:0 ) // 设置默认的内容为:“想对椰椰说什么:” content = INPUT_PROMPT // 创建好就显示窗口 show(true) return retv } } 完成编辑框窗口的创建后,还需要能响应相关的输入事件。对此,笔者希望:a) 当编辑框被按下或输入内容时,如果内容为默认内容,则清空提示内容;b) 当编辑框失去焦点时,窗口主动销毁自身;c) 当编辑框按下回车键时,将编辑框内容上发给大模型,并开始处理返回消息。实现自动清空内容很简单,代码如下:// Yez/src/win_input.cj /** 鼠标左键按下时,判断内容并清空 */ public override open func onLButtonDown(e:EvMouse):Unit{ if(content == INPUT_PROMPT){ content = "" } super.onLButtonDown(e) } /** 键盘按下某键时,判断内容并清空 */ public override open func onKeyDown(e:EvKey):Unit{ if(content == INPUT_PROMPT){ content = "" } super.onKeyDown(e) } 得益于RGF_CJ库的安全性,窗口销毁会自动释放资源,避免了繁琐的资源释放,和释放时机的管理。实现窗口失去焦点就是否窗口的代码如下:// Yez/src/win_input.cj /** 窗口失去焦点时,销毁窗口并释放窗口资源 */ public override open func onKillFocus(e:EvKillFocus):Unit{ destroyWin() } 希望实现按下回车键触发对话,则需要在对应的事件中调用LLMImpl类的流式对话方法,代码如下:// Yez/src/win_input.cj /** 字符输入事件 */ public override open func onChar(e:EvChar):Unit{ // 判断输入的是否为回车键 if(e.char == 0x0D/*VK_RETURN*/){ // 是回车键就销毁本窗口 destroyWin() // 并且调用 开始回答函数 start() // 然后调用 LLMImpl 类的流式对话方法 // 并且传入 流的回调函数 以及 回答结束的函数 LLMImpl.i.chat(content,task:task,end:end) }else{ super.onChar(e) } } // 回复流 public var task:(String) -> Unit = {str=>} // 开始回答 public var start:() -> Unit = {=>} // 开始回答 public var end:() -> Unit = {=>} 编辑框控件的效果如下图所示:注’ 完整的代码请参考提供的代码文件反馈窗口完成上述内容后,输入与处理已经基本完备,接下来需要实现文本的显示,这一部分涉及到内容绘制,同样还是基于WinBase类进行窗口开发。创建一个反馈文本的窗口类 WinText,并且准备基本的资源代码如下:// Yez/src/win_text.cj @Abbr // 文本色彩值转Color对象宏 @LifeCycle @HookProc open class WinText <: WinBase{ // 内容纯色画刷 private let _brhCnt:SolidColorBrush // 背景纯色画刷 private let _brhbg:SolidColorBrush // 文本渲染核心 private let _textCore:TextCore // 文本格式 private let _textformat:TextFormat // 文本布局 private let _textLayout:TextLayout // 文本内容 private var _text:String = "" /** 创建窗口 */ public func createWin( x:Int32, y:Int32, nWidth:Int32, nHeight:Int32, hWndParent:RgfHwnd, substrate!:Substrate = Substrate.Direct2D ):RgfHwnd{ ... } /** 文本对象管理方法,根据配置和窗口尺寸生成文本渲染依赖的对象 */ private func createFontObj(){ let rect:Rect = surface.getRect() surface.createTextCore(_textCore) .createTextFormat(_textformat,ConfigMgr.i.fontSize) .createTextLayout(_textLayout,_text,_textformat,Float32(rect.right),Float32(rect.bottom)) } /** 创建设备/渲染相关资源事件 */ public override open func createDeviceResources():Bool{ // 创建 纯黑内容画刷 和 白色半透明背景画刷 surface.createSolidColorBrush(_brhCnt,"#000",1.0) .createSolidColorBrush(_brhbg,"#FFFA",1.0) // 创建文本相关对象 createFontObj() return true; } } 完成渲染事件,确保内容正确渲染// Yez/src/win_text.cj // 绘制窗口 public override open func onPaint(wRect:Rect):Unit{ surface.clear(0.0,0.0,0.0,0.0) // 透明模式 // 渲染背景圆角矩形 surface.fillRoundedRectangle(surface.getRect(),4.0,4.0,_brhbg) // 渲染大模型返回的文本内容 surface.drawTexts(_textCore,_brhCnt,_textLayout,originX:0.0,originY:0.0) } 完成显示文本方法。当设置文本时,若窗口类已经存在,就更新内部文本;若窗口不存在,就根据当前内容创建窗口并显示。// Yez/src/win_text.cj public func showText(str:String,p:RgfHwnd):Unit{ // 获取光标位置 let pos = rsGetCursorPos() // 获取光标所在屏幕矩形范围 let monitor = rsGetMonitorFromPoint(pos) // 如果窗口未创建,就创建窗口 if(!isCreated()){ createWin(pos.x,pos.y,100,100,p) } if(_textLayout.isValid()){ if(str != _text){ // 更新文本内容 _text = str; // 创建文本布局信息 surface.createTextLayout(_textLayout,_text,_textformat,Float32(monitor.right - monitor.left) / 2.0,Float32(monitor.bottom - monitor.top) / 2.0) } // 获取文本自动布局后的测量数据 let m = _textLayout.getMetrics() let w = Int32(m.getOrThrow().width) let h = Int32(m.getOrThrow().height) 按照测量数据移动窗口 if(pos.x > (monitor.right - monitor.left) / 2){ move(pos.x - w,pos.y - h,w,h) }else{ move(pos.x,pos.y - h,w,h) } } // 窗口显示 show(true) } 文本控件的效果如下图所示:注’ 完整的代码请参考提供的代码文件联结各个窗口逻辑完成上述各个控件后,需要实现各个组件之间的联合。实现流式信息与动画的联动// Yez/src/win_anm.cj /** 文本变更事件 */ func onTextChange(str:String):Unit{ if(!_firstText){ _firstText = true // 当文本输出开始,切换“跳跃”动画状态到“说话”动画状态 play(AnmState.Speak) } _text += str _textBox.showText(_text,hWnd) } /** 大模型开始输出文本 */ func onTextStart():Unit{ killTimer(0) _text = "" // 给大模型上发请求后,播放“跳跃”动画 play(AnmState.Jump) _firstText = false } /** 大模型结束输出文本 */ func onTextEnd():Unit{ // 大模型输出结束后,延迟5s,以让用户完整阅读完内容 const cj_bug_val:Option<CFunc<(HWND, UInt32, UInt64, UInt32) -> Unit>> = None setTimer(0,5000,cj_bug_val) } /** 时钟事件 */ func onTimer(e:EvTimer):Unit{ killTimer(0) // 用户阅读完内容后,销毁文本框窗口 _textBox.destroyWin() // 播放“等待”动画 play(AnmState.Wait) } 双击“椰椰”时打开输入框窗口// Yez/src/win_anm.cj // 客户区双击鼠标左键 func onLButtonDblClk(e:EvMouse):Unit{ // 在“等待”状态下双击“椰椰” match(_nowState){ case AnmState.Wait => {=> // 销毁文本框(虽然已无可能还显示,不过保险起见) _textBox.destroyWin() if(!_inputBox.isCreated()){ // 创建文本输入框窗口 _inputBox.createWin(hWnd) }else{ // 如果窗口存在就移动输入框窗口 // 这部分适用于希望输入框跟随鼠标位置的情况 // 对于方案二的桌面居中而言,没有必要 _inputBox.move() } () }() case _ => () } } 结语至此,仓颉AI桌宠“椰椰”就基本开发完成了,其实还有很多规划的内容可以增加,比如当和椰椰说“咔嚓”或者“拍照”时激活截图功能。这些内容就留给读者继续探索啦!本文相关链接本文项目代码库:https://gitcode.com/raozj/YezRGF_CJ库:https://gitcode.com/Cangjie-SIG/RGF_CJRGF_UI库:https://gitcode.com/raozj/RGF_UIRGF案例库:https://gitcode.com/raozj/RGF_CJ_Example华为云ModelArts:https://www.huaweicloud.com/product/modelarts/studio.html
  • [其他问题] 有三个toddle.switch组件,怎么让他们互斥
    用的是ArkTs,要求这三个模式不能同时打开,要怎么做
  • [交流反馈] 多个警报弹窗能否合并
    目前乾坤终端安全检测到一个病毒就会弹窗一次,如果同时检测到多个病毒,用户需要多次点击关闭按钮才能将全部弹窗关闭,费时费力。能否考虑将这些弹窗合并在一起?参考ESET/Avast那样,可以一次性全部关闭。