Compare commits

..

8 Commits

Author SHA1 Message Date
zhx
b96b5d934b fix: load all manager grant resources 2026-07-10 10:47:12 +08:00
zhx
0a1221933e 充值h5 2026-07-09 19:18:58 +08:00
zhx
f776b87a01 子币商以及aslan充值活动 2026-07-09 18:15:08 +08:00
zhx
939acdfc31 Merge remote-tracking branch 'origin/codex/aslan-recharge-activity' 2026-07-09 17:30:02 +08:00
zhx
ae39abaa02 feat: update h5 guild center pages 2026-07-09 13:25:54 +08:00
zhx
d5bc7ee440 Merge branch 'master' of gitea.haiyihy.com:admin/hyapp-h5 2026-07-08 10:15:27 +08:00
zhx
0a8aeb8721 fami 2026-07-08 10:15:23 +08:00
ZuoZuo
8fce37ed9d yumi cp 2026-07-08 09:52:38 +08:00
55 changed files with 8690 additions and 308 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "h5-static",
"runtimeExecutable": "npx",
"runtimeArgs": ["--yes", "http-server", "-p", "18080", "-c-1"],
"port": 18080
}
]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,400 @@
# 你是本任务的前置审核/规划 agent。目标针对 /Users/zuozuo/Documents/
_Exported on 07/08/2026 at 00:45:53 GMT+8 from OpenAI Codex via WayLog_
**OpenAI Codex**
<permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`: No filesystem sandboxing - all commands are permitted. Network access is enabled.
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
</permissions instructions><app-context>
# Codex desktop context
- You are running inside the Codex (desktop) app, which allows some additional features not available in the CLI alone:
### Images/Visuals/Files
- In the app, the model can display images and videos using standard Markdown image syntax: ![alt](url)
- When sending or referencing a local image or video, always use an absolute filesystem path in the Markdown image tag (e.g., ![alt](/absolute/path.png)); relative paths and plain text will not render the media.
- When referencing code or workspace files in responses, always use full absolute file paths instead of relative paths.
- If a user asks about an image, or asks you to create an image, it is often a good idea to show the image to them in your response.
- Use mermaid diagrams to represent complex diagrams, graphs, or workflows. Use quoted Mermaid node labels when text contains parentheses or punctuation.
- Return web URLs as Markdown links (e.g., [label](https://example.com)).
### Workspace Dependencies
- For sheets, slides, and documents, call `load_workspace_dependencies` to find the bundled runtime and libraries.
### Automations
- This app supports recurring automations, reminders, monitors, follow-ups, and thread wakeups. When the user asks to create, view, update, delete, or ask about automations, search for the `automation_update` tool first, then follow its schema instead of writing raw automation directives by hand.
- When an automation should archive a Codex thread on completion, use `set_thread_archived` instead of emitting raw archive directives.
### Thread Coordination
- When the user asks to create, fork, inspect, continue, hand off, pin, archive, rename, or otherwise manage Codex threads, search for the relevant thread tool first: `create_thread`, `fork_thread`, `list_threads`, `read_thread`, `send_message_to_thread`, `handoff_thread`, `set_thread_pinned`, `set_thread_archived`, or `set_thread_title`.
- Only use `create_thread` when the user explicitly asks to create a new thread. Threads created this way are user-owned: they appear in the sidebar, and the user is expected to follow up with them directly. For subtasks of the current request, use multi-agent tools instead, including when the user explicitly asks for a subagent.
- After a successful `create_thread` call, emit `::created-thread{threadId="..."}` for a created thread or `::created-thread{pendingWorktreeId="..."}` for queued worktree setup on its own line in your final response.
### Inline Code Comments
- Use the ::code-comment{...} directive when you need to attach feedback directly to specific code lines.
- Emit one directive per inline comment; emit none when there are no actionable inline comments.
- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).
- Optional attributes: start, end (1-based line numbers), priority (0-3).
- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
- Keep line ranges tight; end defaults to start.
- Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2}
### Git
- Branch prefix: `codex/`. Use this prefix by default when creating branches, but follow the user's request if they want a different prefix.
- After successfully staging files, emit `::git-stage{cwd="/absolute/path"}` on its own line in your final response.
- After successfully creating a commit, emit `::git-commit{cwd="/absolute/path"}` on its own line in your final response.
- After successfully creating or switching the thread onto a branch, emit `::git-create-branch{cwd="/absolute/path" branch="branch-name"}` on its own line in your final response.
- After successfully pushing the current branch, emit `::git-push{cwd="/absolute/path" branch="branch-name"}` on its own line in your final response.
- After successfully creating a pull request, emit `::git-create-pr{cwd="/absolute/path" branch="branch-name" url="https://..." isDraft=true}` on its own line in your final response. Include `isDraft=false` for ready PRs.
- Only emit these git directives in your final response after the action actually succeeds, never in commentary updates. Keep attributes single-line.
</app-context><apps_instructions>
## Apps (Connectors)
Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{connector_id})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps.
An app is equivalent to a set of MCP tools within the `codex_apps` MCP.
An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it.
Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps.
</apps_instructions><skills_instructions>
## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table.
### Skill roots
- `r0` = `/Users/zuozuo/.codex/skills`
- `r1` = `/Users/zuozuo/.agents/skills`
- `r2` = `/Users/zuozuo/.codex/skills/.system`
- `r3` = `/Users/zuozuo/.codex/plugins/cache/openai-bundled`
- `r4` = `/Users/zuozuo/.codex/plugins/cache/openai-curated/github/d6169bef/skills`
- `r5` = `/Users/zuozuo/.codex/plugins/cache/openai-curated-remote/figma/2.0.13/skills`
- `r6` = `/Users/zuozuo/.codex/plugins/cache/openai-curated-remote/product-design/0.1.48/skills`
- `r7` = `/Users/zuozuo/.codex/plugins/cache/openai-primary-runtime`
### Available skills
- imagegen: Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing i (file: r2/imagegen/SKILL.md)
- openai-docs: Use when the user asks how to build with OpenAI products or APIs, asks about Codex itself or choosing Codex surfaces, needs up-to-date official documentation with citations, help choosing the latest model for a use case, or model upgrade and prompt (file: r2/openai-docs/SKILL.md)
- plugin-creator: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, a (file: r2/plugin-creator/SKILL.md)
- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: r2/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: r2/skill-installer/SKILL.md)
- browser:control-in-app-browser: Control the in-app Browser. Use to open, navigate, inspect, test, click, type, screenshot, or verify local targets such as localhost, 127.0.0.1, ::1, file://, the current in-app browser tab, and websites shown side by side inside Codex. (file: r3/browser/26.623.141536/skills/control-in-app-browser/SKILL.md)
- documents:documents: Create, edit, redline, and comment on `.docx`, Word, and Google Docs-targeted document artifacts inside the container, with a strict render-and-verify workflow. Use `render_docx.py` to generate page PNGs (and optional PDF) for visual QA, then itera (file: r7/documents/26.630.12135/skills/documents/SKILL.md)
- dws: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。 (file: r1/dws/SKILL.md)
- dws: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。 (file: r0/dws/SKILL.md)
- figma:figma-code-connect: Creates and maintains Figma Code Connect template files that map Figma components to code snippets. Use when the user mentions Code Connect, Figma component mapping, design-to-code translation, or asks to create/update .figma.ts or .figma.js files. (file: r5/figma-code-connect/SKILL.md)
- figma:figma-create-new-file: **MANDATORY prerequisite** — you MUST invoke this skill BEFORE every `create_new_file` tool call. NEVER call `create_new_file` directly without loading this skill first. Trigger whenever the user wants a new blank Figma file — a new design, FigJam, o (file: r5/figma-create-new-file/SKILL.md)
- figma:figma-generate-design: Use this skill alongside figma-use when the task involves translating an application page, view, or multi-section layout into Figma. Triggers: 'write to Figma', 'create in Figma from code', 'push page to Figma', 'take this app/page and build it in Fi (file: r5/figma-generate-design/SKILL.md)
- figma:figma-generate-diagram: MANDATORY prerequisite — load this skill BEFORE every `generate_diagram` tool call. NEVER call `generate_diagram` directly without loading this skill first. Trigger whenever the user asks to create, generate, draw, render, sketch, or build a diagram (file: r5/figma-generate-diagram/SKILL.md)
- figma:figma-generate-library: Build or update a professional-grade design system in Figma from a codebase. Use when the user wants to create variables/tokens, build component libraries, create individual components with proper variant sets and variable bindings, set up theming (file: r5/figma-generate-library/SKILL.md)
- figma:figma-implement-motion: Translates Figma motion and animations into production-ready application code. Use when implementing animation/motion from a Figma design — user mentions "implement this motion", "add animation from Figma", "animate this component", provides a Figma (file: r5/figma-implement-motion/SKILL.md)
- figma:figma-swiftui: SwiftUI ↔ Figma translation. Use whenever the user mentions Swift, SwiftUI, iOS, iPhone, or iPad — in EITHER direction — translating a Figma design into SwiftUI (design → code), or pushing SwiftUI views / screens / tokens back into a Figma file (code (file: r5/figma-swiftui/SKILL.md)
- figma:figma-use: **MANDATORY prerequisite** — you MUST invoke this skill BEFORE every `use_figma` tool call. NEVER call `use_figma` directly without loading this skill first. Skipping it causes common, hard-to-debug failures. Trigger whenever the user wants to perf (file: r5/figma-use/SKILL.md)
- figma:figma-use-figjam: This skill helps agents use Figma's use_figma MCP tool in the FigJam context. Can be used alongside figma-use which has foundational context for using the use_figma tool. (file: r5/figma-use-figjam/SKILL.md)
- figma:figma-use-motion: Motion / animation context for the `use_figma` MCP tool — animating Figma nodes via manual keyframes, animation styles, easing, and timeline duration. Load alongside figma-use whenever a task involves adding, editing, or inspecting animation on a nod (file: r5/figma-use-motion/SKILL.md)
- figma:figma-use-slides: This skill helps agents use Figma's use_figma MCP tool in the Slides context. Can be used alongside figma-use which has foundational context for using the use_figma tool. (file: r5/figma-use-slides/SKILL.md)
- github:gh-address-comments: Address actionable GitHub pull request review feedback. Use when the user wants to inspect unresolved review threads, requested changes, or inline review comments on a PR, then implement selected fixes. Use the GitHub app for PR metadata and flat co (file: r4/gh-address-comments/SKILL.md)
- github:gh-fix-ci: Use when a user asks to debug or fix failing GitHub PR checks that run in GitHub Actions. Use the GitHub app from this plugin for PR metadata and patch context, and use `gh` for Actions check and log inspection before implementing any approved fix. (file: r4/gh-fix-ci/SKILL.md)
- github:github: Triage and orient GitHub repository, pull request, and issue work through the connected GitHub app. Use when the user asks for general GitHub help, wants PR or issue summaries, or needs repository context before choosing a more specific GitHub workflo (file: r4/github/SKILL.md)
- github:yeet: Publish local changes to GitHub by confirming scope, committing intentionally, pushing the branch, and opening a draft PR through the GitHub app from this plugin, with `gh` used only as a fallback where connector coverage is insufficient. (file: r4/yeet/SKILL.md)
- lark-approval: 飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例。审批待办不是飞书任务(任务类待办走 lark-task不负责创建审批定义和发起新审批。 (file: r1/lark-approval/SKILL.md)
- lark-apps: 妙搭Spark/Miaoda应用开发与托管应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传lark-drive、飞书文档编辑lark-doc、原生幻灯片创建lark-slides。 (file: r1/lark-apps/SKILL.md)
- lark-attendance: 飞书考勤打卡:查询自己的考勤打卡记录 (file: r1/lark-attendance/SKILL.md)
- lark-base: 飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive认证/授权转 lark-shared。 (file: r1/lark-base/SKILL.md)
- lark-calendar: 飞书日历:管理日历日程和会议室。查看/搜索日程、创建/更新日程、管理参会人、查询忙闲和推荐时段、预定会议室。当用户需要查看日程安排、创建/修改会议、查询/预定会议室时使用。不负责:查询过去的视频会议记录(走 lark-vc、待办任务走 lark-task。 (file: r1/lark-calendar/SKILL.md)
- lark-contact: 飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名。当用户提到某人姓名要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。 (file: r1/lark-contact/SKILL.md)
- lark-doc: 飞书云文档Docx / Wiki 文档v2 API读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Bas (file: r1/lark-doc/SKILL.md)
- lark-drive: 飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题docx、sheet、bitable、file、folder、wiki也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base导入为飞书在线云文档docx、sheet、bitable、slides。当用户需要上传或下载文件、整理云空间 (file: r1/lark-drive/SKILL.md)
- lark-event: Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, VC meeting ended, Minutes generated, Whiteboard updated, etc.). Use for Lark (file: r1/lark-event/SKILL.md)
- lark-im: 飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据时使用。 (file: r1/lark-im/SKILL.md)
- lark-mail: 飞书邮箱 — draft, compose, send, reply, forward, read, and search emails; manage drafts, folders, labels, contacts, attachments, and mail rules. Use when user mentions 起草邮件, 写一封邮件, 拟邮件, 草稿, 发通知邮件, 发送邮件, 发邮件, 回复邮件, 转发邮件, 查看邮件, 看邮件, 读邮件, 搜索邮件, 查邮件, 收件箱, (file: r1/lark-mail/SKILL.md)
- lark-markdown: 飞书 Markdown查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改、局部 patch 或比较差异时使用。不负责将 Markdown 导入为飞书在线文档,也不负责文件搜索、权限、评论、移动、删除等云空间管理操作。 (file: r1/lark-markdown/SKILL.md)
- lark-minutes: 飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记、纪要/逐字稿内容获取走 lark-vc (file: r1/lark-minutes/SKILL.md)
- lark-okr: 飞书 OKR管理目标与关键结果。查看和编辑 OKR 周期、目标Objective、关键结果Key Result、对齐关系、量化指标和进展记录。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。 (file: r1/lark-okr/SKILL.md)
- lark-openapi-explorer: 飞书/Lark 原生 OpenAPI 探索:从官方文档库中挖掘未经 CLI 封装的原生 OpenAPI 接口。当用户的需求无法被现有 lark-* skill 或 lark-cli 已注册命令满足,需要查找并调用原生飞书 OpenAPI 时使用。 (file: r1/lark-openapi-explorer/SKILL.md)
- lark-shared: Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output. (file: r1/lark-shared/SKILL.md)
- lark-sheets: 飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive (file: r1/lark-sheets/SKILL.md)
- lark-skill-maker: 创建 lark-cli 的自定义 Skill。当用户需要把飞书 API 操作封装成可复用的 Skill包装原子 API 或编排多步流程)时使用。 (file: r1/lark-skill-maker/SKILL.md)
- lark-slides: 飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责云文档内容编辑走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide (file: r1/lark-slides/SKILL.md)
- lark-task: 飞书任务:管理任务、清单和任务智能体。创建待办任务、查看和更新任务状态、拆分子任务、组织任务清单、分配协作成员、上传任务附件、注册或注销任务智能体、更新任务智能体的主页数据、写入智能体任务记录。当用户需要创建待办事项、查看任务列表、跟踪任务进度、管理项目清单或给他人分配任务、为任务上传附件文件、注册注销任务智能体、更新智能体主页数据、写入任务记录时使用。 (file: r1/lark-task/SKILL.md)
- lark-vc: 飞书视频会议:搜索历史会议记录、查询会议纪要(总结/待办/章节/逐字稿)、查询参会人快照。当用户查询已结束的会议、获取会议产物(纪要/妙记)、查看参会人时使用;查询未来日程走 lark-calendar。不负责Agent 真实入会/离会、会中实时事件(走 lark-vc-agent。 (file: r1/lark-vc/SKILL.md)
- lark-vc-agent: 飞书视频会议:让机器人代当前用户加入/离开正在进行的会议并读取会议期间的实时事件参会人加入与离开、发言、聊天、屏幕共享等。1. 用户提供 9 位会议号、要求代为入会或离会时使用 +meeting-join / +meeting-leave——会真实产生入会/离会记录。2. 会议进行中用户想知道“谁加入了”“谁离开了”“谁在发言”“有人共享屏幕吗”等会中动态时,机器人入会后用 +meeting-events 读取事件时间线。3. 典型场景:参会机器人、会中助手、代为旁听、代为参会。前提: (file: r1/lark-vc-agent/SKILL.md)
- lark-whiteboard: 飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用 DSL转成 OpenAPI 格式、PlantUML/Mermaid 格式更新画板内容。 当用户需要查看画板内容、导出画板图片、编辑画板,或是需要可视化表达架构、流程、组织关系、时间线、因果、对比等结构化信息时使用此 skill无论是否提及"画板"。 ⚠️ 原 `lark-whiteboard-cli` skill 已合并至本 skill若 skill 列表中同时存在 `lark-whiteboard- (file: r1/lark-whiteboard/SKILL.md)
- lark-wiki: 飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责上传文件到知识库节点下走 lark-drive (file: r1/lark-wiki/SKILL.md)
- lark-workflow-meeting-summary: 会议纪要整理工作流:汇总指定时间范围内的会议纪要并生成结构化报告。当用户需要整理会议纪要、生成会议周报、回顾一段时间内的会议内容时使用。 (file: r1/lark-workflow-meeting-summary/SKILL.md)
- lark-workflow-standup-report: 日程待办摘要:编排 calendar +agenda 和 task +get-my-tasks生成指定日期的日程与未完成任务摘要。适用于了解今天/明天/本周的安排。 (file: r1/lark-workflow-standup-report/SKILL.md)
- pdf:pdf: Read, create, inspect, render, and verify PDF files where visual layout matters. Use Poppler rendering plus Python tools such as reportlab, pdfplumber, and pypdf for generation and extraction. (file: r7/pdf/26.630.12135/skills/pdf/SKILL.md)
- presentations:Presentations: Create or edit PowerPoint or Google Slides decks (file: r7/presentations/26.630.12135/skills/presentations/SKILL.md)
- product-design:audit: Audit or critique a product flow, journey, workflow, funnel, onboarding path, checkout path, settings path, screen, or multi-step product experience by capturing screenshots first, placing them in Figma or a local folder, then reporting UX, design, (file: r6/audit/SKILL.md)
- product-design:get-context: Mandatory design-brief gate for Product Design build and design workflows. Use before ideation, prototyping, image-to-code builds, redesigns, or product UI work to clarify missing product, visual, and interactivity context or play back the supplied (file: r6/get-context/SKILL.md)
- product-design:ideate: Generate image-based visual alternatives, remixes, or concept directions after Product Design get-context has confirmed the design brief. Use when the user asks for design variants, visual exploration, remixes, or image-generated approaches from provi (file: r6/ideate/SKILL.md)
- product-design:image-to-code: Implement a selected image, screenshot, mockup, or Image Gen reference as a faithful responsive frontend after Product Design get-context has confirmed the design brief. (file: r6/image-to-code/SKILL.md)
- product-design:index: Use to discover specific skills for the Product Design plugin, when it is at-mentioned directly, or for any mentions of potentially relevant work, including: UX research; product, screen, or flow audits; visual ideation; app or interface design, red (file: r6/index/SKILL.md)
- spreadsheets:Spreadsheets: Use this skill when a user requests to create, modify, analyze, visualize, or work with spreadsheet files (`.xlsx`, `.xls`, `.csv`, `.tsv`) or Google Sheets-targeted spreadsheet artifacts with formulas, formatting, charts, tables, and recalculation (file: r7/spreadsheets/26.630.12135/skills/spreadsheets/SKILL.md)
- template-creator:template-creator: Create or update a reusable personal Codex artifact-template skill. Use when the user invokes $template-creator or asks in natural language to create a template using, from, or based on an attached Word document, PowerPoint presentation, or Excel w (file: r7/template-creator/26.630.12135/skills/template-creator/SKILL.md)
### How to use skills
- Discovery: The list above is the skills available in this session (name + description + short path). Skill bodies live on disk at the listed paths after expanding the matching alias from `### Skill roots`.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
1) After deciding to use a skill, the main agent must expand the listed short `path` with the matching alias from `### Skill roots`, then open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF.
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the directory containing that expanded `SKILL.md` first, and only consider other paths if needed.
3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it.
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
- Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file. Do not load unrelated references, scripts, or assets.
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.
</skills_instructions><plugins_instructions>
## Plugins
A plugin is a local bundle of skills, MCP servers, and apps.
### How to use plugins
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list.
- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as `mcp__server__tool`; use tool provenance to tell which plugin they come from.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associated skills, MCP tools, and apps exposed elsewhere in this turn.
- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback.
</plugins_instructions>
---
**User**
你是本任务的前置审核/规划 agent。目标针对 /Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/yumi.html 需要还原的 3 个 Figma 节点做实现前审查cp node 859:2027、brother node 859:2247、sister node 859:2454Figma fileKey=vaDp5wwn6AOQVlt9zk0f9r。请只做分析不修改代码。请检查现有目录 /Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space 的结构、assets、index/style/script 复用方式、common/theme.css 和 common/i18n.js 接入要求;同时读取/截图 Figma 节点(如需使用 Figma 工具请遵循 figma-use 技能要求。输出1) 需要的切图/可 CSS 还原元素清单2) 三个页面的结构拆分与关键位置/尺寸/颜色/字体3) 还原路线与风险项4) 对主线程实现的验收清单。不要改文件。
---
**OpenAI Codex**
我会使用 `figma:figma-use` 技能读取 Figma 节点,并先做本地目录与公共能力审查;全程只分析不改文件。
---
**OpenAI Codex**
已完成前置审查;未修改任何文件。
## 1) 切图 / CSS 可还原清单
### 必须新增切图,现有 assets 不够用
现有 `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/assets` 多数是旧紫色/粉色 CP 空间资源,和本次 3 个 Figma 节点不一致尤其背景、Tab、卡片主体都需要重新导出。
建议新增到:
`/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/assets/yumi/`
需要切图:
1. **整页背景 3 张**
- CP红玫瑰婚礼背景Figma 375×812
- Brother蓝白玫瑰背景Figma 375×812
- Sister橙色玫瑰背景Figma 375×812
2. **激活 Tab 宝石态 3 张**
- CP约 130×48位置 x=9 y=107
- Brother约 130×48位置 x=126 y=107
- Sister约 130×48位置 x=236 y=107
3. **卡片静态底图 3 张**
- CP可按 355×203 或可视卡片 347×199 导出
- Brother蓝色卡片约 347×199
- Sister橙色卡片约 355×200 / 可视 347×199
- 建议底图不包含动态头像、天数、进度文字、等级文字。
4. **中间关系主视觉 3 张**
- CP心形主体约 136×120
- Brother蓝盾主体约 136×136
- Sister花冠主体约 136×136
5. **经验提示胶囊 3 张**
- 约 112×28文字 “888 Exp away from Lv. 2” 建议 DOM 文本覆盖,底图单独切。
6. **头像框**
- Figma 中头像框容器 66×66头像图 61.286×61.286,建议切一个通用金色头像框。
7. **等级小徽章 3 张**
- CP / Brother / Sister 各 24×13 左右,或 CSS 近似。
### 可 CSS 还原
1. 顶部返回箭头、help 圆形问号。
2. 导航标题 `Best Friend`
3. Tab 外层胶囊:
- x=16 y=109 w=343 h=37
- border `#f4b747` 0.5px
- radius 32px
- CP 背景 `rgba(94,13,35,.8)`,内发光 `#ff7948`
- Brother 背景 `rgba(17,13,94,.8)`,内发光 `#4872ff`
- Sister 背景 `rgba(94,29,13,.8)`,内发光 `#ff7948`
4. 非激活 Tab 文本18px白色 80%。
5. 进度条:
- track233×16`rgba(255,255,255,.3)`radius 74pxblur 6px
- fill高度 14宽度按进度动态。
- CP 渐变:`#ff1e37 -> #fe6a30 -> #ffcf67`
- Brother 渐变:`#471eff -> #30bcfe -> #67acff`
- Sister 渐变:`#ff5e1e -> #fe6a30 -> #ffb167`
6. 动态文案:
- 天数 `999 / Days`
- `888 Exp away from Lv. 2`
- `1000/200000`
- `Time spent together: 4 days`
- 等级 `Lv1`
## 2) 三个页面结构、位置、尺寸、颜色、字体
### 通用画布
- Figma 画布375×812。
- Status Bar044。
- 导航栏x=0 y=44 w=375 h=44。
- 返回x=16 y=54 size 24。
- helpx=335 y=54 size 24。
- 标题x≈139 y=57 w=97 h=18。
- Tab 容器x=16 y=109 w=343 h=37。
- Tab 文字 y=119字号 18。
- 卡片区域x≈14 y=169 w=347 h=199。
- 内容内层x=46 y=175 w=289 h=163。
### CP node `859:2027`
- 页面主色:红 / 玫瑰。
- 根背景色:`#440b23`,背景图叠黑 20%。
- Tab 外层:`rgba(94,13,35,.8)`,内阴影 `#ff7948`
- Active TabCP宝石图 130×48x=9。
- 卡片:红金婚礼风。
- 头像:
- 左头像框 x=46 y=204 size 66。
- 右头像框 x=264 y=204 size 66。
- 中央心形:
- x=120 y=175 w=136 h=120。
- 天数:
- `999`18px颜色 `#ff0979`
- `Days`8px颜色 `#ff0979`
- 经验提示:
- x=133 y=271 w=112 h=28。
- 文本 8px颜色 `#ff0979`
- 等级进度:
- row x=46 y=303 w=289 h=16。
- 文本 `1000/200000` 居中,白色。
- 底部时间11px颜色 `#ff5762`
### Brother node `859:2247`
- 页面主色:蓝。
- 根背景色:`#080636`,背景图叠黑 10%。
- Tab 外层:`rgba(17,13,94,.8)`,内阴影 `#4872ff`
- Active TabBrother宝石图 130×48x=126。
- 卡片:深蓝金边。
- 头像:
- 左 x=46 y=210 size 66。
- 右 x=264 y=210 size 66。
- 中央盾牌:
- x=120 y=175 w=136 h=136。
- 文字颜色:`#5709ff`
- 经验提示x=133 y=271 w=112 h=28。
- 进度条同 CP 尺寸,但蓝色渐变。
### Sister node `859:2454`
- 页面主色:橙。
- 根背景色:`#301008`,背景图叠黑 5%。
- Tab 外层:`rgba(94,29,13,.8)`,内阴影 `#ff7948`
- Active TabSister宝石图 130×48x=236。
- 卡片:浅橙金边。
- 头像:
- 左 x=46 y=210 size 66。
- 右 x=264 y=210 size 66。
- 中央花冠:
- x=120 y=175 w=136 h=136。
- 经验提示 y=262比 CP/Brother 高约 9px。
- 文字颜色:`#ff4a09`
- 天数 `999` 使用 Source Han Sans SC Bold18px。
## 3) 还原路线与风险项
### 现有目录审查
- `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/yumi.html` 当前只是空 HTML 壳。
- `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/index.html` 已接入:
- `../../common/theme.css`
- `../../common/i18n.js`
- `../../common/api.js`
- `../../common/jsbridge.js`
- `../../common/toast.js`
- `./script.js`
- `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/style.css` 当前是旧紫色 CP 空间样式,背景使用 `space-bg@4x.png`,和本次 Figma 不匹配。
- `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/script.js` 当前按 `LEVEL_PARTS` 组装旧 CP 等级资源Brother/Sister 被强制使用 level 1 视觉,不符合本次三个独立主题。
- `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/common/api.js` 已支持 `?env=test` / `?env=local` / 默认线上 API。
- `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/common/i18n.js` 已支持 `data-i18n` / `data-i18n-aria` / `data-i18n-placeholder`,并加载 `common/locales/*.json`
### 建议实现路线
1. 不直接复用旧 `style.css` 的主题视觉;为 `yumi.html` 新建独立 `yumi.css` / `yumi.js`,避免影响现有 `index.html`
2. `yumi.html` 仍按现有页面方式接入:
- `../../common/theme.css`
- `../../common/i18n.js`
- `../../common/api.js`
- `../../common/jsbridge.js`
- `../../common/toast.js`
3. 新建 `RELATION_THEME`
- `cp`
- `brother`
- `sister`
分别绑定背景图、active tab 图、卡片底图、中心图、tooltip 图、等级徽章、颜色变量。
4. DOM 保持动态:
- tab 文案使用 `data-i18n`
- 天数、等级、经验、时间使用接口数据 + `window.HyAppI18n.t`
- 不在页面脚本里新增硬编码多语言字典。
5. 视觉布局以 375 设计稿为基准,核心尺寸用 `min(100vw, 375px)` 比例缩放;桌面端居中。
6. 先实现单卡片,再兼容多关系滚动列表。
7. 如果需要保留 CP 取消关系逻辑,需要产品确认:本次 Figma 节点没有 `Cancel` 按钮,和现有 `script.js` 行为不一致。
### 风险项
1. **高风险Figma 与现有 assets 完全不是同一版视觉。** 不能直接套旧 `parts/cp-lv*.png`
2. **高风险:状态栏是否由 H5 绘制需确认。** Figma 包含 iOS status bar现有 `index.html` 不绘制系统状态栏。如果 WebView 已避开状态栏H5 再画会重复。
3. **中风险CP 取消按钮消失。** 现有业务逻辑有 cancel/breakRelationship本次 Figma 卡片没有取消入口。
4. **中风险:切图边界。** 卡片内有大量金边、花纹、宝石、阴影,纯 CSS 难以 100% 还原,建议卡片静态层切图。
5. **中风险:字体。** Figma 使用 Source Han Sans SC、SF Pro、HelveticaWeb 端需 fallback数字位置可能有 12px 偏差。
6. **中风险:阿语 RTL。** Tab 顺序是否镜像需要确认;现有 CSS 只处理返回箭头 RTL。
7. **低风险common/theme.css。** 该页面是强 Figma 定制,可以局部覆盖主题色,但基础变量和公共文件仍需接入。
## 4) 主线程实现验收清单
### 文件与接入
- [ ] `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/yumi.html` 不再是空壳。
- [ ] 已接入 `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/common/theme.css`
- [ ] 已接入 `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/common/i18n.js`
- [ ] 文案使用 `data-i18n``HyAppI18n.t`,没有新增页面内多语言字典。
- [ ] API 环境仍遵循 `?env=test``?env=local`、默认线上。
### 视觉还原
- [ ] 375×812 下分别对比 Figma
- CP node `859:2027`
- Brother node `859:2247`
- Sister node `859:2454`
- [ ] 背景、Tab、卡片顶部 y 坐标误差 ≤ 2px。
- [ ] Tab 激活图位置:
- CP x≈9
- Brother x≈126
- Sister x≈236
- [ ] 卡片 x≈14 y≈169 w≈347 h≈199。
- [ ] 头像框、中心主视觉、经验提示、进度条、底部时间位置正确。
- [ ] CP / Brother / Sister 三套颜色没有串色。
- [ ] 不使用旧紫色 `space-bg@4x.png` 作为新页面背景。
### 业务与状态
- [ ] `tab=cp|brother|sister` 初始态可用。
- [ ] 三个 tab 点击切换正常。
- [ ] loading / empty / error 状态有 i18n 文案,不添加无意义描述文案。
- [ ] 无可用接口数据时不伪造关系数据。
- [ ] 长昵称、空头像、0 经验、满级、超长数字不溢出。
- [ ] 如保留取消 CP必须确认 Figma 中入口缺失的产品决策。
### 多语言与适配
- [ ] en / ar / tr / es 均可加载。
- [ ] ar 下返回箭头方向正确,布局不严重错位。
- [ ] 320px、375px、390px、414px、768px 宽度检查通过。
- [ ] iOS Safari / Android WebView 图片不模糊、不拉伸变形。
### 额外验收 agent
- [ ] 视觉验收 agent逐页截图和 Figma 对比。
- [ ] 代码验收 agent检查未破坏 `index.html`、common i18n/api 接入、无硬编码敏感配置。
- [ ] 若发现高风险项,打回修复后重新验收。
---

View File

@ -0,0 +1,229 @@
# 你是本任务的像素级验收 agent。请不要修改文件。验收目标/Users/zuozuo/Docum
_Exported on 07/08/2026 at 00:45:53 GMT+8 from OpenAI Codex via WayLog_
**OpenAI Codex**
<permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`: No filesystem sandboxing - all commands are permitted. Network access is enabled.
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
</permissions instructions><app-context>
# Codex desktop context
- You are running inside the Codex (desktop) app, which allows some additional features not available in the CLI alone:
### Images/Visuals/Files
- In the app, the model can display images and videos using standard Markdown image syntax: ![alt](url)
- When sending or referencing a local image or video, always use an absolute filesystem path in the Markdown image tag (e.g., ![alt](/absolute/path.png)); relative paths and plain text will not render the media.
- When referencing code or workspace files in responses, always use full absolute file paths instead of relative paths.
- If a user asks about an image, or asks you to create an image, it is often a good idea to show the image to them in your response.
- Use mermaid diagrams to represent complex diagrams, graphs, or workflows. Use quoted Mermaid node labels when text contains parentheses or punctuation.
- Return web URLs as Markdown links (e.g., [label](https://example.com)).
### Workspace Dependencies
- For sheets, slides, and documents, call `load_workspace_dependencies` to find the bundled runtime and libraries.
### Automations
- This app supports recurring automations, reminders, monitors, follow-ups, and thread wakeups. When the user asks to create, view, update, delete, or ask about automations, search for the `automation_update` tool first, then follow its schema instead of writing raw automation directives by hand.
- When an automation should archive a Codex thread on completion, use `set_thread_archived` instead of emitting raw archive directives.
### Thread Coordination
- When the user asks to create, fork, inspect, continue, hand off, pin, archive, rename, or otherwise manage Codex threads, search for the relevant thread tool first: `create_thread`, `fork_thread`, `list_threads`, `read_thread`, `send_message_to_thread`, `handoff_thread`, `set_thread_pinned`, `set_thread_archived`, or `set_thread_title`.
- Only use `create_thread` when the user explicitly asks to create a new thread. Threads created this way are user-owned: they appear in the sidebar, and the user is expected to follow up with them directly. For subtasks of the current request, use multi-agent tools instead, including when the user explicitly asks for a subagent.
- After a successful `create_thread` call, emit `::created-thread{threadId="..."}` for a created thread or `::created-thread{pendingWorktreeId="..."}` for queued worktree setup on its own line in your final response.
### Inline Code Comments
- Use the ::code-comment{...} directive when you need to attach feedback directly to specific code lines.
- Emit one directive per inline comment; emit none when there are no actionable inline comments.
- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).
- Optional attributes: start, end (1-based line numbers), priority (0-3).
- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
- Keep line ranges tight; end defaults to start.
- Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2}
### Git
- Branch prefix: `codex/`. Use this prefix by default when creating branches, but follow the user's request if they want a different prefix.
- After successfully staging files, emit `::git-stage{cwd="/absolute/path"}` on its own line in your final response.
- After successfully creating a commit, emit `::git-commit{cwd="/absolute/path"}` on its own line in your final response.
- After successfully creating or switching the thread onto a branch, emit `::git-create-branch{cwd="/absolute/path" branch="branch-name"}` on its own line in your final response.
- After successfully pushing the current branch, emit `::git-push{cwd="/absolute/path" branch="branch-name"}` on its own line in your final response.
- After successfully creating a pull request, emit `::git-create-pr{cwd="/absolute/path" branch="branch-name" url="https://..." isDraft=true}` on its own line in your final response. Include `isDraft=false` for ready PRs.
- Only emit these git directives in your final response after the action actually succeeds, never in commentary updates. Keep attributes single-line.
</app-context><apps_instructions>
## Apps (Connectors)
Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{connector_id})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps.
An app is equivalent to a set of MCP tools within the `codex_apps` MCP.
An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it.
Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps.
</apps_instructions><skills_instructions>
## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table.
### Skill roots
- `r0` = `/Users/zuozuo/.codex/skills`
- `r1` = `/Users/zuozuo/.agents/skills`
- `r2` = `/Users/zuozuo/.codex/skills/.system`
- `r3` = `/Users/zuozuo/.codex/plugins/cache/openai-bundled`
- `r4` = `/Users/zuozuo/.codex/plugins/cache/openai-curated/github/d6169bef/skills`
- `r5` = `/Users/zuozuo/.codex/plugins/cache/openai-curated-remote/figma/2.0.13/skills`
- `r6` = `/Users/zuozuo/.codex/plugins/cache/openai-curated-remote/product-design/0.1.48/skills`
- `r7` = `/Users/zuozuo/.codex/plugins/cache/openai-primary-runtime`
### Available skills
- imagegen: Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing i (file: r2/imagegen/SKILL.md)
- openai-docs: Use when the user asks how to build with OpenAI products or APIs, asks about Codex itself or choosing Codex surfaces, needs up-to-date official documentation with citations, help choosing the latest model for a use case, or model upgrade and prompt (file: r2/openai-docs/SKILL.md)
- plugin-creator: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, a (file: r2/plugin-creator/SKILL.md)
- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: r2/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: r2/skill-installer/SKILL.md)
- browser:control-in-app-browser: Control the in-app Browser. Use to open, navigate, inspect, test, click, type, screenshot, or verify local targets such as localhost, 127.0.0.1, ::1, file://, the current in-app browser tab, and websites shown side by side inside Codex. (file: r3/browser/26.623.141536/skills/control-in-app-browser/SKILL.md)
- documents:documents: Create, edit, redline, and comment on `.docx`, Word, and Google Docs-targeted document artifacts inside the container, with a strict render-and-verify workflow. Use `render_docx.py` to generate page PNGs (and optional PDF) for visual QA, then itera (file: r7/documents/26.630.12135/skills/documents/SKILL.md)
- dws: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。 (file: r1/dws/SKILL.md)
- dws: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。 (file: r0/dws/SKILL.md)
- figma:figma-code-connect: Creates and maintains Figma Code Connect template files that map Figma components to code snippets. Use when the user mentions Code Connect, Figma component mapping, design-to-code translation, or asks to create/update .figma.ts or .figma.js files. (file: r5/figma-code-connect/SKILL.md)
- figma:figma-create-new-file: **MANDATORY prerequisite** — you MUST invoke this skill BEFORE every `create_new_file` tool call. NEVER call `create_new_file` directly without loading this skill first. Trigger whenever the user wants a new blank Figma file — a new design, FigJam, o (file: r5/figma-create-new-file/SKILL.md)
- figma:figma-generate-design: Use this skill alongside figma-use when the task involves translating an application page, view, or multi-section layout into Figma. Triggers: 'write to Figma', 'create in Figma from code', 'push page to Figma', 'take this app/page and build it in Fi (file: r5/figma-generate-design/SKILL.md)
- figma:figma-generate-diagram: MANDATORY prerequisite — load this skill BEFORE every `generate_diagram` tool call. NEVER call `generate_diagram` directly without loading this skill first. Trigger whenever the user asks to create, generate, draw, render, sketch, or build a diagram (file: r5/figma-generate-diagram/SKILL.md)
- figma:figma-generate-library: Build or update a professional-grade design system in Figma from a codebase. Use when the user wants to create variables/tokens, build component libraries, create individual components with proper variant sets and variable bindings, set up theming (file: r5/figma-generate-library/SKILL.md)
- figma:figma-implement-motion: Translates Figma motion and animations into production-ready application code. Use when implementing animation/motion from a Figma design — user mentions "implement this motion", "add animation from Figma", "animate this component", provides a Figma (file: r5/figma-implement-motion/SKILL.md)
- figma:figma-swiftui: SwiftUI ↔ Figma translation. Use whenever the user mentions Swift, SwiftUI, iOS, iPhone, or iPad — in EITHER direction — translating a Figma design into SwiftUI (design → code), or pushing SwiftUI views / screens / tokens back into a Figma file (code (file: r5/figma-swiftui/SKILL.md)
- figma:figma-use: **MANDATORY prerequisite** — you MUST invoke this skill BEFORE every `use_figma` tool call. NEVER call `use_figma` directly without loading this skill first. Skipping it causes common, hard-to-debug failures. Trigger whenever the user wants to perf (file: r5/figma-use/SKILL.md)
- figma:figma-use-figjam: This skill helps agents use Figma's use_figma MCP tool in the FigJam context. Can be used alongside figma-use which has foundational context for using the use_figma tool. (file: r5/figma-use-figjam/SKILL.md)
- figma:figma-use-motion: Motion / animation context for the `use_figma` MCP tool — animating Figma nodes via manual keyframes, animation styles, easing, and timeline duration. Load alongside figma-use whenever a task involves adding, editing, or inspecting animation on a nod (file: r5/figma-use-motion/SKILL.md)
- figma:figma-use-slides: This skill helps agents use Figma's use_figma MCP tool in the Slides context. Can be used alongside figma-use which has foundational context for using the use_figma tool. (file: r5/figma-use-slides/SKILL.md)
- github:gh-address-comments: Address actionable GitHub pull request review feedback. Use when the user wants to inspect unresolved review threads, requested changes, or inline review comments on a PR, then implement selected fixes. Use the GitHub app for PR metadata and flat co (file: r4/gh-address-comments/SKILL.md)
- github:gh-fix-ci: Use when a user asks to debug or fix failing GitHub PR checks that run in GitHub Actions. Use the GitHub app from this plugin for PR metadata and patch context, and use `gh` for Actions check and log inspection before implementing any approved fix. (file: r4/gh-fix-ci/SKILL.md)
- github:github: Triage and orient GitHub repository, pull request, and issue work through the connected GitHub app. Use when the user asks for general GitHub help, wants PR or issue summaries, or needs repository context before choosing a more specific GitHub workflo (file: r4/github/SKILL.md)
- github:yeet: Publish local changes to GitHub by confirming scope, committing intentionally, pushing the branch, and opening a draft PR through the GitHub app from this plugin, with `gh` used only as a fallback where connector coverage is insufficient. (file: r4/yeet/SKILL.md)
- lark-approval: 飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例。审批待办不是飞书任务(任务类待办走 lark-task不负责创建审批定义和发起新审批。 (file: r1/lark-approval/SKILL.md)
- lark-apps: 妙搭Spark/Miaoda应用开发与托管应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传lark-drive、飞书文档编辑lark-doc、原生幻灯片创建lark-slides。 (file: r1/lark-apps/SKILL.md)
- lark-attendance: 飞书考勤打卡:查询自己的考勤打卡记录 (file: r1/lark-attendance/SKILL.md)
- lark-base: 飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive认证/授权转 lark-shared。 (file: r1/lark-base/SKILL.md)
- lark-calendar: 飞书日历:管理日历日程和会议室。查看/搜索日程、创建/更新日程、管理参会人、查询忙闲和推荐时段、预定会议室。当用户需要查看日程安排、创建/修改会议、查询/预定会议室时使用。不负责:查询过去的视频会议记录(走 lark-vc、待办任务走 lark-task。 (file: r1/lark-calendar/SKILL.md)
- lark-contact: 飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名。当用户提到某人姓名要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。 (file: r1/lark-contact/SKILL.md)
- lark-doc: 飞书云文档Docx / Wiki 文档v2 API读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Bas (file: r1/lark-doc/SKILL.md)
- lark-drive: 飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题docx、sheet、bitable、file、folder、wiki也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base导入为飞书在线云文档docx、sheet、bitable、slides。当用户需要上传或下载文件、整理云空间 (file: r1/lark-drive/SKILL.md)
- lark-event: Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, VC meeting ended, Minutes generated, Whiteboard updated, etc.). Use for Lark (file: r1/lark-event/SKILL.md)
- lark-im: 飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据时使用。 (file: r1/lark-im/SKILL.md)
- lark-mail: 飞书邮箱 — draft, compose, send, reply, forward, read, and search emails; manage drafts, folders, labels, contacts, attachments, and mail rules. Use when user mentions 起草邮件, 写一封邮件, 拟邮件, 草稿, 发通知邮件, 发送邮件, 发邮件, 回复邮件, 转发邮件, 查看邮件, 看邮件, 读邮件, 搜索邮件, 查邮件, 收件箱, (file: r1/lark-mail/SKILL.md)
- lark-markdown: 飞书 Markdown查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改、局部 patch 或比较差异时使用。不负责将 Markdown 导入为飞书在线文档,也不负责文件搜索、权限、评论、移动、删除等云空间管理操作。 (file: r1/lark-markdown/SKILL.md)
- lark-minutes: 飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记、纪要/逐字稿内容获取走 lark-vc (file: r1/lark-minutes/SKILL.md)
- lark-okr: 飞书 OKR管理目标与关键结果。查看和编辑 OKR 周期、目标Objective、关键结果Key Result、对齐关系、量化指标和进展记录。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。 (file: r1/lark-okr/SKILL.md)
- lark-openapi-explorer: 飞书/Lark 原生 OpenAPI 探索:从官方文档库中挖掘未经 CLI 封装的原生 OpenAPI 接口。当用户的需求无法被现有 lark-* skill 或 lark-cli 已注册命令满足,需要查找并调用原生飞书 OpenAPI 时使用。 (file: r1/lark-openapi-explorer/SKILL.md)
- lark-shared: Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output. (file: r1/lark-shared/SKILL.md)
- lark-sheets: 飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive (file: r1/lark-sheets/SKILL.md)
- lark-skill-maker: 创建 lark-cli 的自定义 Skill。当用户需要把飞书 API 操作封装成可复用的 Skill包装原子 API 或编排多步流程)时使用。 (file: r1/lark-skill-maker/SKILL.md)
- lark-slides: 飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责云文档内容编辑走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide (file: r1/lark-slides/SKILL.md)
- lark-task: 飞书任务:管理任务、清单和任务智能体。创建待办任务、查看和更新任务状态、拆分子任务、组织任务清单、分配协作成员、上传任务附件、注册或注销任务智能体、更新任务智能体的主页数据、写入智能体任务记录。当用户需要创建待办事项、查看任务列表、跟踪任务进度、管理项目清单或给他人分配任务、为任务上传附件文件、注册注销任务智能体、更新智能体主页数据、写入任务记录时使用。 (file: r1/lark-task/SKILL.md)
- lark-vc: 飞书视频会议:搜索历史会议记录、查询会议纪要(总结/待办/章节/逐字稿)、查询参会人快照。当用户查询已结束的会议、获取会议产物(纪要/妙记)、查看参会人时使用;查询未来日程走 lark-calendar。不负责Agent 真实入会/离会、会中实时事件(走 lark-vc-agent。 (file: r1/lark-vc/SKILL.md)
- lark-vc-agent: 飞书视频会议:让机器人代当前用户加入/离开正在进行的会议并读取会议期间的实时事件参会人加入与离开、发言、聊天、屏幕共享等。1. 用户提供 9 位会议号、要求代为入会或离会时使用 +meeting-join / +meeting-leave——会真实产生入会/离会记录。2. 会议进行中用户想知道“谁加入了”“谁离开了”“谁在发言”“有人共享屏幕吗”等会中动态时,机器人入会后用 +meeting-events 读取事件时间线。3. 典型场景:参会机器人、会中助手、代为旁听、代为参会。前提: (file: r1/lark-vc-agent/SKILL.md)
- lark-whiteboard: 飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用 DSL转成 OpenAPI 格式、PlantUML/Mermaid 格式更新画板内容。 当用户需要查看画板内容、导出画板图片、编辑画板,或是需要可视化表达架构、流程、组织关系、时间线、因果、对比等结构化信息时使用此 skill无论是否提及"画板"。 ⚠️ 原 `lark-whiteboard-cli` skill 已合并至本 skill若 skill 列表中同时存在 `lark-whiteboard- (file: r1/lark-whiteboard/SKILL.md)
- lark-wiki: 飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责上传文件到知识库节点下走 lark-drive (file: r1/lark-wiki/SKILL.md)
- lark-workflow-meeting-summary: 会议纪要整理工作流:汇总指定时间范围内的会议纪要并生成结构化报告。当用户需要整理会议纪要、生成会议周报、回顾一段时间内的会议内容时使用。 (file: r1/lark-workflow-meeting-summary/SKILL.md)
- lark-workflow-standup-report: 日程待办摘要:编排 calendar +agenda 和 task +get-my-tasks生成指定日期的日程与未完成任务摘要。适用于了解今天/明天/本周的安排。 (file: r1/lark-workflow-standup-report/SKILL.md)
- pdf:pdf: Read, create, inspect, render, and verify PDF files where visual layout matters. Use Poppler rendering plus Python tools such as reportlab, pdfplumber, and pypdf for generation and extraction. (file: r7/pdf/26.630.12135/skills/pdf/SKILL.md)
- presentations:Presentations: Create or edit PowerPoint or Google Slides decks (file: r7/presentations/26.630.12135/skills/presentations/SKILL.md)
- product-design:audit: Audit or critique a product flow, journey, workflow, funnel, onboarding path, checkout path, settings path, screen, or multi-step product experience by capturing screenshots first, placing them in Figma or a local folder, then reporting UX, design, (file: r6/audit/SKILL.md)
- product-design:get-context: Mandatory design-brief gate for Product Design build and design workflows. Use before ideation, prototyping, image-to-code builds, redesigns, or product UI work to clarify missing product, visual, and interactivity context or play back the supplied (file: r6/get-context/SKILL.md)
- product-design:ideate: Generate image-based visual alternatives, remixes, or concept directions after Product Design get-context has confirmed the design brief. Use when the user asks for design variants, visual exploration, remixes, or image-generated approaches from provi (file: r6/ideate/SKILL.md)
- product-design:image-to-code: Implement a selected image, screenshot, mockup, or Image Gen reference as a faithful responsive frontend after Product Design get-context has confirmed the design brief. (file: r6/image-to-code/SKILL.md)
- product-design:index: Use to discover specific skills for the Product Design plugin, when it is at-mentioned directly, or for any mentions of potentially relevant work, including: UX research; product, screen, or flow audits; visual ideation; app or interface design, red (file: r6/index/SKILL.md)
- spreadsheets:Spreadsheets: Use this skill when a user requests to create, modify, analyze, visualize, or work with spreadsheet files (`.xlsx`, `.xls`, `.csv`, `.tsv`) or Google Sheets-targeted spreadsheet artifacts with formulas, formatting, charts, tables, and recalculation (file: r7/spreadsheets/26.630.12135/skills/spreadsheets/SKILL.md)
- template-creator:template-creator: Create or update a reusable personal Codex artifact-template skill. Use when the user invokes $template-creator or asks in natural language to create a template using, from, or based on an attached Word document, PowerPoint presentation, or Excel w (file: r7/template-creator/26.630.12135/skills/template-creator/SKILL.md)
### How to use skills
- Discovery: The list above is the skills available in this session (name + description + short path). Skill bodies live on disk at the listed paths after expanding the matching alias from `### Skill roots`.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
1) After deciding to use a skill, the main agent must expand the listed short `path` with the matching alias from `### Skill roots`, then open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF.
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the directory containing that expanded `SKILL.md` first, and only consider other paths if needed.
3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it.
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
- Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file. Do not load unrelated references, scripts, or assets.
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.
</skills_instructions><plugins_instructions>
## Plugins
A plugin is a local bundle of skills, MCP servers, and apps.
### How to use plugins
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list.
- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as `mcp__server__tool`; use tool provenance to tell which plugin they come from.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associated skills, MCP tools, and apps exposed elsewhere in this turn.
- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback.
</plugins_instructions>
---
**User**
你是本任务的像素级验收 agent。请不要修改文件。验收目标/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/yumi.html 是否按 Figma 3 个节点还原cp 859:2027、brother 859:2247、sister 859:2454fileKey=vaDp5wwn6AOQVlt9zk0f9r。请检查1) 375×812 视觉是否与 Figma 一致(可用 Figma get_screenshot、查看本地 assets/yumi 三张切图和 QuickLook/其它本地方法2) tab=cp/brother/sister 三态资源与热区是否正确3) common/theme.css、common/i18n.js 接入和 data-i18n 是否满足要求4) 是否误改旧 index/style/script5) 风险项。若不通过,请列出必须返修点;若通过,请明确“验收通过”。
---
**OpenAI Codex**
我会使用 `figma:figma-use`(按要求先读取技能)做 Figma 截图对照,并只读检查本地文件与资源,不修改文件。
---
**OpenAI Codex**
验收通过。
检查结果:
1. **375×812 视觉还原**
- 已用 Figma `get_screenshot` 拉取节点:
- `cp 859:2027`
- `brother 859:2247`
- `sister 859:2454`
- 三个 Figma 截图均为 `375×812`
- 本地切图:
- `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/assets/yumi/yumi-cp.png`
- `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/assets/yumi/yumi-brother.png`
- `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/assets/yumi/yumi-sister.png`
- 与 Figma 下载图逐像素 RGB 对比:`nonzero = 0`,三态完全一致。
2. **tab 三态资源与热区**
- `tab=cp / brother / sister` 均能切换到正确背景资源。
- 点击热区验证通过:
- tabs 区域:`x=16,y=109,w=343,h=37`
- 三个 tab 分区约为 `114.33px` 宽。
- 点击后:
- `data-tab` 正确更新
- `aria-selected` 正确更新
- URL `tab` 参数正确 replace
- 背景图切到对应 `yumi-*.png`
- 返回、帮助热区位置与 Figma 一致:
- back`16,54,24×24`
- help`335,54,24×24`
3. **common/theme.css、common/i18n.js 接入**
- `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/yumi.html` 已引用:
- `../../common/theme.css`
- `../../common/i18n.js`
- 页面使用了 `data-i18n` / `data-i18n-aria`
- `en/ar/tr/es/zh/id``cpSpace.*` key 均存在。
- 实测 `lang=ar`
- `html lang="ar"`
- `dir="rtl"`
- title、aria 文案均正常切换。
4. **旧 index/style/script 是否误改**
- 未发现 `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/index.html`
- 未发现 `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/style.css`
- 未发现 `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/script.js`
有 diff。
5. **风险项**
- 当前方案是整屏位图还原,视觉上像素级通过;但图片里的可见英文文案不会随语言切换。如果后续要求“可见 UI 文案也多语言化”,需要改为分层 DOM 文案或多语言切图。
- CSS 中有少量 Figma 背景兜底色硬编码;主视觉由 Figma 切图决定,当前不影响验收。
---

View File

@ -0,0 +1,387 @@
# 你是本次返工的图层拆分/验收规划 agent。必须遵守红线禁止使用整页截图作为还原资源或验收依据。
_Exported on 07/08/2026 at 00:45:53 GMT+8 from OpenAI Codex via WayLog_
**OpenAI Codex**
<permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`: No filesystem sandboxing - all commands are permitted. Network access is enabled.
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
</permissions instructions><app-context>
# Codex desktop context
- You are running inside the Codex (desktop) app, which allows some additional features not available in the CLI alone:
### Images/Visuals/Files
- In the app, the model can display images and videos using standard Markdown image syntax: ![alt](url)
- When sending or referencing a local image or video, always use an absolute filesystem path in the Markdown image tag (e.g., ![alt](/absolute/path.png)); relative paths and plain text will not render the media.
- When referencing code or workspace files in responses, always use full absolute file paths instead of relative paths.
- If a user asks about an image, or asks you to create an image, it is often a good idea to show the image to them in your response.
- Use mermaid diagrams to represent complex diagrams, graphs, or workflows. Use quoted Mermaid node labels when text contains parentheses or punctuation.
- Return web URLs as Markdown links (e.g., [label](https://example.com)).
### Workspace Dependencies
- For sheets, slides, and documents, call `load_workspace_dependencies` to find the bundled runtime and libraries.
### Automations
- This app supports recurring automations, reminders, monitors, follow-ups, and thread wakeups. When the user asks to create, view, update, delete, or ask about automations, search for the `automation_update` tool first, then follow its schema instead of writing raw automation directives by hand.
- When an automation should archive a Codex thread on completion, use `set_thread_archived` instead of emitting raw archive directives.
### Thread Coordination
- When the user asks to create, fork, inspect, continue, hand off, pin, archive, rename, or otherwise manage Codex threads, search for the relevant thread tool first: `create_thread`, `fork_thread`, `list_threads`, `read_thread`, `send_message_to_thread`, `handoff_thread`, `set_thread_pinned`, `set_thread_archived`, or `set_thread_title`.
- Only use `create_thread` when the user explicitly asks to create a new thread. Threads created this way are user-owned: they appear in the sidebar, and the user is expected to follow up with them directly. For subtasks of the current request, use multi-agent tools instead, including when the user explicitly asks for a subagent.
- After a successful `create_thread` call, emit `::created-thread{threadId="..."}` for a created thread or `::created-thread{pendingWorktreeId="..."}` for queued worktree setup on its own line in your final response.
### Inline Code Comments
- Use the ::code-comment{...} directive when you need to attach feedback directly to specific code lines.
- Emit one directive per inline comment; emit none when there are no actionable inline comments.
- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).
- Optional attributes: start, end (1-based line numbers), priority (0-3).
- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
- Keep line ranges tight; end defaults to start.
- Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2}
### Git
- Branch prefix: `codex/`. Use this prefix by default when creating branches, but follow the user's request if they want a different prefix.
- After successfully staging files, emit `::git-stage{cwd="/absolute/path"}` on its own line in your final response.
- After successfully creating a commit, emit `::git-commit{cwd="/absolute/path"}` on its own line in your final response.
- After successfully creating or switching the thread onto a branch, emit `::git-create-branch{cwd="/absolute/path" branch="branch-name"}` on its own line in your final response.
- After successfully pushing the current branch, emit `::git-push{cwd="/absolute/path" branch="branch-name"}` on its own line in your final response.
- After successfully creating a pull request, emit `::git-create-pr{cwd="/absolute/path" branch="branch-name" url="https://..." isDraft=true}` on its own line in your final response. Include `isDraft=false` for ready PRs.
- Only emit these git directives in your final response after the action actually succeeds, never in commentary updates. Keep attributes single-line.
</app-context><apps_instructions>
## Apps (Connectors)
Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{connector_id})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps.
An app is equivalent to a set of MCP tools within the `codex_apps` MCP.
An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it.
Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps.
</apps_instructions><skills_instructions>
## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table.
### Skill roots
- `r0` = `/Users/zuozuo/.codex/skills`
- `r1` = `/Users/zuozuo/.agents/skills`
- `r2` = `/Users/zuozuo/.codex/skills/.system`
- `r3` = `/Users/zuozuo/.codex/plugins/cache/openai-bundled`
- `r4` = `/Users/zuozuo/.codex/plugins/cache/openai-curated/github/d6169bef/skills`
- `r5` = `/Users/zuozuo/.codex/plugins/cache/openai-curated-remote/figma/2.0.13/skills`
- `r6` = `/Users/zuozuo/.codex/plugins/cache/openai-curated-remote/product-design/0.1.48/skills`
- `r7` = `/Users/zuozuo/.codex/plugins/cache/openai-primary-runtime`
### Available skills
- imagegen: Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing i (file: r2/imagegen/SKILL.md)
- openai-docs: Use when the user asks how to build with OpenAI products or APIs, asks about Codex itself or choosing Codex surfaces, needs up-to-date official documentation with citations, help choosing the latest model for a use case, or model upgrade and prompt (file: r2/openai-docs/SKILL.md)
- plugin-creator: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, a (file: r2/plugin-creator/SKILL.md)
- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: r2/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: r2/skill-installer/SKILL.md)
- browser:control-in-app-browser: Control the in-app Browser. Use to open, navigate, inspect, test, click, type, screenshot, or verify local targets such as localhost, 127.0.0.1, ::1, file://, the current in-app browser tab, and websites shown side by side inside Codex. (file: r3/browser/26.623.141536/skills/control-in-app-browser/SKILL.md)
- documents:documents: Create, edit, redline, and comment on `.docx`, Word, and Google Docs-targeted document artifacts inside the container, with a strict render-and-verify workflow. Use `render_docx.py` to generate page PNGs (and optional PDF) for visual QA, then itera (file: r7/documents/26.630.12135/skills/documents/SKILL.md)
- dws: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。 (file: r1/dws/SKILL.md)
- dws: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。 (file: r0/dws/SKILL.md)
- figma:figma-code-connect: Creates and maintains Figma Code Connect template files that map Figma components to code snippets. Use when the user mentions Code Connect, Figma component mapping, design-to-code translation, or asks to create/update .figma.ts or .figma.js files. (file: r5/figma-code-connect/SKILL.md)
- figma:figma-create-new-file: **MANDATORY prerequisite** — you MUST invoke this skill BEFORE every `create_new_file` tool call. NEVER call `create_new_file` directly without loading this skill first. Trigger whenever the user wants a new blank Figma file — a new design, FigJam, o (file: r5/figma-create-new-file/SKILL.md)
- figma:figma-generate-design: Use this skill alongside figma-use when the task involves translating an application page, view, or multi-section layout into Figma. Triggers: 'write to Figma', 'create in Figma from code', 'push page to Figma', 'take this app/page and build it in Fi (file: r5/figma-generate-design/SKILL.md)
- figma:figma-generate-diagram: MANDATORY prerequisite — load this skill BEFORE every `generate_diagram` tool call. NEVER call `generate_diagram` directly without loading this skill first. Trigger whenever the user asks to create, generate, draw, render, sketch, or build a diagram (file: r5/figma-generate-diagram/SKILL.md)
- figma:figma-generate-library: Build or update a professional-grade design system in Figma from a codebase. Use when the user wants to create variables/tokens, build component libraries, create individual components with proper variant sets and variable bindings, set up theming (file: r5/figma-generate-library/SKILL.md)
- figma:figma-implement-motion: Translates Figma motion and animations into production-ready application code. Use when implementing animation/motion from a Figma design — user mentions "implement this motion", "add animation from Figma", "animate this component", provides a Figma (file: r5/figma-implement-motion/SKILL.md)
- figma:figma-swiftui: SwiftUI ↔ Figma translation. Use whenever the user mentions Swift, SwiftUI, iOS, iPhone, or iPad — in EITHER direction — translating a Figma design into SwiftUI (design → code), or pushing SwiftUI views / screens / tokens back into a Figma file (code (file: r5/figma-swiftui/SKILL.md)
- figma:figma-use: **MANDATORY prerequisite** — you MUST invoke this skill BEFORE every `use_figma` tool call. NEVER call `use_figma` directly without loading this skill first. Skipping it causes common, hard-to-debug failures. Trigger whenever the user wants to perf (file: r5/figma-use/SKILL.md)
- figma:figma-use-figjam: This skill helps agents use Figma's use_figma MCP tool in the FigJam context. Can be used alongside figma-use which has foundational context for using the use_figma tool. (file: r5/figma-use-figjam/SKILL.md)
- figma:figma-use-motion: Motion / animation context for the `use_figma` MCP tool — animating Figma nodes via manual keyframes, animation styles, easing, and timeline duration. Load alongside figma-use whenever a task involves adding, editing, or inspecting animation on a nod (file: r5/figma-use-motion/SKILL.md)
- figma:figma-use-slides: This skill helps agents use Figma's use_figma MCP tool in the Slides context. Can be used alongside figma-use which has foundational context for using the use_figma tool. (file: r5/figma-use-slides/SKILL.md)
- github:gh-address-comments: Address actionable GitHub pull request review feedback. Use when the user wants to inspect unresolved review threads, requested changes, or inline review comments on a PR, then implement selected fixes. Use the GitHub app for PR metadata and flat co (file: r4/gh-address-comments/SKILL.md)
- github:gh-fix-ci: Use when a user asks to debug or fix failing GitHub PR checks that run in GitHub Actions. Use the GitHub app from this plugin for PR metadata and patch context, and use `gh` for Actions check and log inspection before implementing any approved fix. (file: r4/gh-fix-ci/SKILL.md)
- github:github: Triage and orient GitHub repository, pull request, and issue work through the connected GitHub app. Use when the user asks for general GitHub help, wants PR or issue summaries, or needs repository context before choosing a more specific GitHub workflo (file: r4/github/SKILL.md)
- github:yeet: Publish local changes to GitHub by confirming scope, committing intentionally, pushing the branch, and opening a draft PR through the GitHub app from this plugin, with `gh` used only as a fallback where connector coverage is insufficient. (file: r4/yeet/SKILL.md)
- lark-approval: 飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例。审批待办不是飞书任务(任务类待办走 lark-task不负责创建审批定义和发起新审批。 (file: r1/lark-approval/SKILL.md)
- lark-apps: 妙搭Spark/Miaoda应用开发与托管应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传lark-drive、飞书文档编辑lark-doc、原生幻灯片创建lark-slides。 (file: r1/lark-apps/SKILL.md)
- lark-attendance: 飞书考勤打卡:查询自己的考勤打卡记录 (file: r1/lark-attendance/SKILL.md)
- lark-base: 飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive认证/授权转 lark-shared。 (file: r1/lark-base/SKILL.md)
- lark-calendar: 飞书日历:管理日历日程和会议室。查看/搜索日程、创建/更新日程、管理参会人、查询忙闲和推荐时段、预定会议室。当用户需要查看日程安排、创建/修改会议、查询/预定会议室时使用。不负责:查询过去的视频会议记录(走 lark-vc、待办任务走 lark-task。 (file: r1/lark-calendar/SKILL.md)
- lark-contact: 飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名。当用户提到某人姓名要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。 (file: r1/lark-contact/SKILL.md)
- lark-doc: 飞书云文档Docx / Wiki 文档v2 API读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Bas (file: r1/lark-doc/SKILL.md)
- lark-drive: 飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题docx、sheet、bitable、file、folder、wiki也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base导入为飞书在线云文档docx、sheet、bitable、slides。当用户需要上传或下载文件、整理云空间 (file: r1/lark-drive/SKILL.md)
- lark-event: Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, VC meeting ended, Minutes generated, Whiteboard updated, etc.). Use for Lark (file: r1/lark-event/SKILL.md)
- lark-im: 飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据时使用。 (file: r1/lark-im/SKILL.md)
- lark-mail: 飞书邮箱 — draft, compose, send, reply, forward, read, and search emails; manage drafts, folders, labels, contacts, attachments, and mail rules. Use when user mentions 起草邮件, 写一封邮件, 拟邮件, 草稿, 发通知邮件, 发送邮件, 发邮件, 回复邮件, 转发邮件, 查看邮件, 看邮件, 读邮件, 搜索邮件, 查邮件, 收件箱, (file: r1/lark-mail/SKILL.md)
- lark-markdown: 飞书 Markdown查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改、局部 patch 或比较差异时使用。不负责将 Markdown 导入为飞书在线文档,也不负责文件搜索、权限、评论、移动、删除等云空间管理操作。 (file: r1/lark-markdown/SKILL.md)
- lark-minutes: 飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记、纪要/逐字稿内容获取走 lark-vc (file: r1/lark-minutes/SKILL.md)
- lark-okr: 飞书 OKR管理目标与关键结果。查看和编辑 OKR 周期、目标Objective、关键结果Key Result、对齐关系、量化指标和进展记录。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。 (file: r1/lark-okr/SKILL.md)
- lark-openapi-explorer: 飞书/Lark 原生 OpenAPI 探索:从官方文档库中挖掘未经 CLI 封装的原生 OpenAPI 接口。当用户的需求无法被现有 lark-* skill 或 lark-cli 已注册命令满足,需要查找并调用原生飞书 OpenAPI 时使用。 (file: r1/lark-openapi-explorer/SKILL.md)
- lark-shared: Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output. (file: r1/lark-shared/SKILL.md)
- lark-sheets: 飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive (file: r1/lark-sheets/SKILL.md)
- lark-skill-maker: 创建 lark-cli 的自定义 Skill。当用户需要把飞书 API 操作封装成可复用的 Skill包装原子 API 或编排多步流程)时使用。 (file: r1/lark-skill-maker/SKILL.md)
- lark-slides: 飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责云文档内容编辑走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide (file: r1/lark-slides/SKILL.md)
- lark-task: 飞书任务:管理任务、清单和任务智能体。创建待办任务、查看和更新任务状态、拆分子任务、组织任务清单、分配协作成员、上传任务附件、注册或注销任务智能体、更新任务智能体的主页数据、写入智能体任务记录。当用户需要创建待办事项、查看任务列表、跟踪任务进度、管理项目清单或给他人分配任务、为任务上传附件文件、注册注销任务智能体、更新智能体主页数据、写入任务记录时使用。 (file: r1/lark-task/SKILL.md)
- lark-vc: 飞书视频会议:搜索历史会议记录、查询会议纪要(总结/待办/章节/逐字稿)、查询参会人快照。当用户查询已结束的会议、获取会议产物(纪要/妙记)、查看参会人时使用;查询未来日程走 lark-calendar。不负责Agent 真实入会/离会、会中实时事件(走 lark-vc-agent。 (file: r1/lark-vc/SKILL.md)
- lark-vc-agent: 飞书视频会议:让机器人代当前用户加入/离开正在进行的会议并读取会议期间的实时事件参会人加入与离开、发言、聊天、屏幕共享等。1. 用户提供 9 位会议号、要求代为入会或离会时使用 +meeting-join / +meeting-leave——会真实产生入会/离会记录。2. 会议进行中用户想知道“谁加入了”“谁离开了”“谁在发言”“有人共享屏幕吗”等会中动态时,机器人入会后用 +meeting-events 读取事件时间线。3. 典型场景:参会机器人、会中助手、代为旁听、代为参会。前提: (file: r1/lark-vc-agent/SKILL.md)
- lark-whiteboard: 飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用 DSL转成 OpenAPI 格式、PlantUML/Mermaid 格式更新画板内容。 当用户需要查看画板内容、导出画板图片、编辑画板,或是需要可视化表达架构、流程、组织关系、时间线、因果、对比等结构化信息时使用此 skill无论是否提及"画板"。 ⚠️ 原 `lark-whiteboard-cli` skill 已合并至本 skill若 skill 列表中同时存在 `lark-whiteboard- (file: r1/lark-whiteboard/SKILL.md)
- lark-wiki: 飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责上传文件到知识库节点下走 lark-drive (file: r1/lark-wiki/SKILL.md)
- lark-workflow-meeting-summary: 会议纪要整理工作流:汇总指定时间范围内的会议纪要并生成结构化报告。当用户需要整理会议纪要、生成会议周报、回顾一段时间内的会议内容时使用。 (file: r1/lark-workflow-meeting-summary/SKILL.md)
- lark-workflow-standup-report: 日程待办摘要:编排 calendar +agenda 和 task +get-my-tasks生成指定日期的日程与未完成任务摘要。适用于了解今天/明天/本周的安排。 (file: r1/lark-workflow-standup-report/SKILL.md)
- pdf:pdf: Read, create, inspect, render, and verify PDF files where visual layout matters. Use Poppler rendering plus Python tools such as reportlab, pdfplumber, and pypdf for generation and extraction. (file: r7/pdf/26.630.12135/skills/pdf/SKILL.md)
- presentations:Presentations: Create or edit PowerPoint or Google Slides decks (file: r7/presentations/26.630.12135/skills/presentations/SKILL.md)
- product-design:audit: Audit or critique a product flow, journey, workflow, funnel, onboarding path, checkout path, settings path, screen, or multi-step product experience by capturing screenshots first, placing them in Figma or a local folder, then reporting UX, design, (file: r6/audit/SKILL.md)
- product-design:get-context: Mandatory design-brief gate for Product Design build and design workflows. Use before ideation, prototyping, image-to-code builds, redesigns, or product UI work to clarify missing product, visual, and interactivity context or play back the supplied (file: r6/get-context/SKILL.md)
- product-design:ideate: Generate image-based visual alternatives, remixes, or concept directions after Product Design get-context has confirmed the design brief. Use when the user asks for design variants, visual exploration, remixes, or image-generated approaches from provi (file: r6/ideate/SKILL.md)
- product-design:image-to-code: Implement a selected image, screenshot, mockup, or Image Gen reference as a faithful responsive frontend after Product Design get-context has confirmed the design brief. (file: r6/image-to-code/SKILL.md)
- product-design:index: Use to discover specific skills for the Product Design plugin, when it is at-mentioned directly, or for any mentions of potentially relevant work, including: UX research; product, screen, or flow audits; visual ideation; app or interface design, red (file: r6/index/SKILL.md)
- spreadsheets:Spreadsheets: Use this skill when a user requests to create, modify, analyze, visualize, or work with spreadsheet files (`.xlsx`, `.xls`, `.csv`, `.tsv`) or Google Sheets-targeted spreadsheet artifacts with formulas, formatting, charts, tables, and recalculation (file: r7/spreadsheets/26.630.12135/skills/spreadsheets/SKILL.md)
- template-creator:template-creator: Create or update a reusable personal Codex artifact-template skill. Use when the user invokes $template-creator or asks in natural language to create a template using, from, or based on an attached Word document, PowerPoint presentation, or Excel w (file: r7/template-creator/26.630.12135/skills/template-creator/SKILL.md)
### How to use skills
- Discovery: The list above is the skills available in this session (name + description + short path). Skill bodies live on disk at the listed paths after expanding the matching alias from `### Skill roots`.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
1) After deciding to use a skill, the main agent must expand the listed short `path` with the matching alias from `### Skill roots`, then open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF.
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the directory containing that expanded `SKILL.md` first, and only consider other paths if needed.
3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it.
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
- Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file. Do not load unrelated references, scripts, or assets.
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.
</skills_instructions><plugins_instructions>
## Plugins
A plugin is a local bundle of skills, MCP servers, and apps.
### How to use plugins
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list.
- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as `mcp__server__tool`; use tool provenance to tell which plugin they come from.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associated skills, MCP tools, and apps exposed elsewhere in this turn.
- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback.
</plugins_instructions>
---
**User**
你是本次返工的图层拆分/验收规划 agent。必须遵守红线禁止使用整页截图作为还原资源或验收依据。目标文件/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/yumi.html。Figma fileKey=vaDp5wwn6AOQVlt9zk0f9r节点 cp=859:2027、brother=859:2247、sister=859:2454。请只分析、不改文件。输出1) 必须拆成独立资源的图层节点清单背景、tab 选中态、卡片底、头像框、中心图、等级/进度装饰等2) 每个资源建议导出节点 id/尺寸/位置3) DOM/CSS 分层还原验收点4) 明确如何验收时不使用整页截图。
---
**OpenAI Codex**
我会使用 figma-use skill 读取 Figma 节点结构,并只做分析不改文件。
---
**OpenAI Codex**
已只分析,未修改 `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/yumi.html` 及相关文件。
本次只使用 Figma metadata / 本地文件结构检查;未使用整页截图。
## 0. 先判定当前风险
当前 `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/yumi.css` 仍是整页背景式还原:
- `.yumi-cp-stage { background-image: url('./assets/yumi/yumi-cp.png') }`
- brother / sister 也按整页背景切换
- 本地已有:
- `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/assets/yumi/layers/bg-cp.png`375×812
- `bg-brother.png`375×812
- `bg-sister.png`375×812
- `tab-cp/brother/sister.png`130×48
返工红线:不能把根节点 `859:2027 / 859:2247 / 859:2454` 导出成整页图作为还原依据或资源。背景资源只有在确认来自“纯背景图层”时才允许作为 background-only 资源使用不能包含导航、tab、卡片、头像、文字等前景内容。
---
## 1. 必须拆成独立资源的图层清单
### A. 页面背景层,按 tab 拆
| tab | 建议导出节点 | 尺寸 / 位置 | 说明 |
|---|---:|---|---|
| CP | `859:2028` Mask group或内部背景 `859:2030` | 375×812x0 y0 | 只能导出纯背景,不可导出根 frame |
| Brother | `859:2248` Mask group或内部背景 `859:2250` | 375×812/813x0 y0 | `859:2250` 高度 813需要裁成 stage 812 |
| Sister | `859:2455` Mask group或内部背景 `859:2457` | 375×811x0 y1 | 需要按 stage 对齐 y=1 |
验收要求:背景图不能包含 nav、tab、卡片、头像、文字。
---
### B. 顶部导航资源
| 资源 | 建议导出节点 | 尺寸 / 位置 |
|---|---:|---|
| 返回图标 | `859:2051` | 24×24x16 y54 |
| 帮助图标 | `859:2055` | 24×24x335 y54 |
标题 `Best Friend` 不应导出成图,应由 DOM 文本 + i18n 渲染。
---
### C. tab 底板与选中态
| 资源 | CP | Brother | Sister | 尺寸 / 位置 |
|---|---:|---:|---:|---|
| tab 未选中底板 | `859:2058` | `859:2278` | `859:2485` | 343×37x16 y109 |
| tab 选中态 | `859:2059` / `859:2060` | `859:2279` / `859:2280` | `859:2486` / `859:2487` | 130×48CP x9 y107Brother x126 y107Sister x236 y107 |
tab 文案 `CP / Brother / Sister` 必须是 DOM 文本,使用 `data-i18n`,不作为图片。
---
### D. 主卡片底 / 卡片皮肤
| tab | 建议导出节点 | 尺寸 / 位置 | 说明 |
|---|---:|---|---|
| CP 卡片底 | `859:2065` | 347×199x14 y169 | 包含卡片背景、顶部条、渐变横纹、底部条 |
| Brother 卡片底 | `859:2287` | 347×199x14 y169 | 不包含前景人物/头像/文字 |
| Sister 卡片底 | `859:2494` | 347×199x14 y169 | 不包含前景人物/头像/文字 |
可进一步拆:
- 卡片主体背景CP `859:2066`Sister `859:2495`
- 顶部条CP `859:2069`Brother `859:2289`Sister `859:2498`
- 底部条CP `859:2177`Brother `859:2397`Sister `859:2606`
---
### E. 头像框资源
| 资源 | CP | Brother | Sister | 尺寸 |
|---|---:|---:|---:|---|
| 左头像框 | `859:2182` / `859:2183` | `859:2402` / `859:2403` | `859:2611` / `859:2612` | 66×66 |
| 右头像框 | `859:2210` / `859:2211` | `859:2417` / `859:2418` | `859:2626` / `859:2627` | 66×66 |
| 头像内容占位 | `859:2181` / `859:2209` 等 | 同类节点 | 同类节点 | 61.2857×61.2857 |
建议:
- 头像图片由业务数据渲染,使用 `<img>`
- 头像框导出透明 PNG/SVG 叠在头像上。
- 内部头像区域用 CSS `border-radius` / `overflow:hidden` 裁剪,不要把示例头像导出进资源。
相对 375×812 画板的建议布局:
- 左头像框约 x46 y204 或 x46 y210取决于 tab。
- 右头像框约 x264 y204 或 x264 y210。
- 需要以 Figma 当前 tab 的具体 hero frame y 偏移校准。
---
### F. 中心图 / 心形装饰 / Exp pill
| 资源 | CP | Brother | Sister | 尺寸 / 位置 |
|---|---:|---:|---:|---|
| 中心主图 | `859:2189` | `859:2409` | `859:2618` | CP 136×120Brother/Sister 136×136约 x120 y175 |
| 大爱心 | `859:2185` | `859:2405` | `859:2614` | 72×72约 x152 y227 |
| 小 Exp pill 背景 | `859:2190` | `859:2411` | `859:2620` | 112×28CP/Brother 约 x133 y271Sister 约 x133 y262 |
`888 Exp away from Lv. 2` 必须是 DOM 文本,不应烘焙进图片。
---
### G. 等级 / 进度条装饰
| 资源 | CP | Brother | Sister | 尺寸 / 位置 |
|---|---:|---:|---:|---|
| 左等级 pill 背景 | `859:2218` | `859:2425` | `859:2634` | 24×13 |
| 右等级 pill 背景 | `859:2228` | `859:2435` | `859:2644` | 24×13 |
| 进度条轨道 | `859:2221` | `859:2428` | `859:2637` | 233×16 |
| 进度条填充外层 | `859:2222` | `859:2429` | `859:2638` | 约 52×14 |
| 填充内部高光 | `859:2224` / `859:2225` / `859:2226` | `859:2431` / `859:2432` / `859:2433` | `859:2640` / `859:2641` / `859:2642` | 51×13 / 45×13 等 |
| 进度数字 | `859:2230` | `859:2437` | `859:2646` | 建议 DOM 文本 |
`lv1``1000/200000``Time spent together: 4 days` 全部应为 DOM/i18n 文本。
---
## 2. DOM / CSS 分层还原验收点
建议 DOM 层级:
1. `.yumi-cp-stage`
- 背景层 `.yumi-bg`
- 导航层 `.yumi-nav`
- tab 层 `.yumi-tabs`
- 卡片层 `.yumi-card`
- 关系展示层 `.yumi-hero`
- 进度层 `.yumi-progress`
- 交互热区层 button
核心验收点:
- 不允许 `.yumi-cp-stage` 直接使用整页前景截图作为 `background-image`
- 每个 tab 只切换独立资源:
- background-only 背景
- tab 选中态
- card skin
- center image
- exp pill / level / progress 主题装饰
- 文本必须是 DOM
- `Best Friend`
- `CP / Brother / Sister`
- `999 / Days`
- `888 Exp away from Lv. 2`
- `lv1`
- `1000/200000`
- `Time spent together: 4 days`
- 头像必须支持业务动态数据:
- 用户头像 `<img>` 在底层
- 头像框资源在上层
- 375×812 设计坐标应通过 CSS `calc(x / 375 * 100%)``calc(y / 812 * 100%)` 或统一缩放容器实现。
- tab 热区、返回、帮助按钮不能依赖图片热区,应有真实 button。
- 多语言继续走 `common/i18n.js`,新增文案不得写死在页面脚本字典里。
- 主色/背景色尽量消费 `common/theme.css` 变量Figma 局部图层资源除外。
---
## 3. 如何验收时不使用整页截图
明确禁止:
- 不截取浏览器整页图做 pixel diff。
- 不截取 Figma 根节点 `859:2027 / 859:2247 / 859:2454` 当验收基准。
- 不把 375×812 的前景合成图当实现资源。
建议验收方式:
1. **资源来源验收**
- 建立资源 manifest`filename -> Figma node id -> width/height -> 用途`
- 用 `sips -g pixelWidth -g pixelHeight` 检查尺寸。
- 除 background-only 背景层外,任何 375×812/813 的前景合成图直接判失败。
- background 也必须标注来自 `859:2028 / 859:2248 / 859:2455` 背景层,不得来自根 frame。
2. **DOM 结构验收**
- 用脚本检查页面是否存在:
- nav button
- tab button
- card container
- avatar img
- avatar frame
- center image
- progress track / fill
- DOM 文本节点
- 检查关键文案是否使用 `data-i18n`
3. **几何坐标验收**
- 用 Playwright 读取 `getBoundingClientRect()`
- 对比 Figma metadata 坐标,允许 ±1px 或 ±2px。
- 验收对象包括:
- back16,54,24,24
- help335,54,24,24
- tabs16,109,343,37
- selected tab130×48对应 x9 / x126 / x236
- card14,169,347,199
- progress约 x46 y303width 289height 35
4. **状态切换验收**
- 通过 `?tab=cp / brother / sister` 和点击 tab 验证:
- `data-tab` 正确变化
- URL `tab` 参数同步
- selected tab 资源位置正确
- 中心图、卡片底、等级装饰随 tab 切换
- 不使用截图,只读 DOM 属性、computed style、资源 URL、元素坐标。
5. **额外验收 agents 建议**
- 资源 provenance agent专查是否误用整页截图。
- DOM/i18n agent专查文案是否 DOM 化、多语言是否绑定。
- 几何/交互 agent专查坐标、z-index、tab 状态、按钮热区。
---

View File

@ -0,0 +1,208 @@
# 你是返工后的严格验收 agent。红线禁止使用浏览器整页截图、禁止使用 Figma 根节点 859
_Exported on 07/08/2026 at 00:45:53 GMT+8 from OpenAI Codex via WayLog_
**OpenAI Codex**
<permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`: No filesystem sandboxing - all commands are permitted. Network access is enabled.
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
</permissions instructions><app-context>
# Codex desktop context
- You are running inside the Codex (desktop) app, which allows some additional features not available in the CLI alone:
### Images/Visuals/Files
- In the app, the model can display images and videos using standard Markdown image syntax: ![alt](url)
- When sending or referencing a local image or video, always use an absolute filesystem path in the Markdown image tag (e.g., ![alt](/absolute/path.png)); relative paths and plain text will not render the media.
- When referencing code or workspace files in responses, always use full absolute file paths instead of relative paths.
- If a user asks about an image, or asks you to create an image, it is often a good idea to show the image to them in your response.
- Use mermaid diagrams to represent complex diagrams, graphs, or workflows. Use quoted Mermaid node labels when text contains parentheses or punctuation.
- Return web URLs as Markdown links (e.g., [label](https://example.com)).
### Workspace Dependencies
- For sheets, slides, and documents, call `load_workspace_dependencies` to find the bundled runtime and libraries.
### Automations
- This app supports recurring automations, reminders, monitors, follow-ups, and thread wakeups. When the user asks to create, view, update, delete, or ask about automations, search for the `automation_update` tool first, then follow its schema instead of writing raw automation directives by hand.
- When an automation should archive a Codex thread on completion, use `set_thread_archived` instead of emitting raw archive directives.
### Thread Coordination
- When the user asks to create, fork, inspect, continue, hand off, pin, archive, rename, or otherwise manage Codex threads, search for the relevant thread tool first: `create_thread`, `fork_thread`, `list_threads`, `read_thread`, `send_message_to_thread`, `handoff_thread`, `set_thread_pinned`, `set_thread_archived`, or `set_thread_title`.
- Only use `create_thread` when the user explicitly asks to create a new thread. Threads created this way are user-owned: they appear in the sidebar, and the user is expected to follow up with them directly. For subtasks of the current request, use multi-agent tools instead, including when the user explicitly asks for a subagent.
- After a successful `create_thread` call, emit `::created-thread{threadId="..."}` for a created thread or `::created-thread{pendingWorktreeId="..."}` for queued worktree setup on its own line in your final response.
### Inline Code Comments
- Use the ::code-comment{...} directive when you need to attach feedback directly to specific code lines.
- Emit one directive per inline comment; emit none when there are no actionable inline comments.
- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).
- Optional attributes: start, end (1-based line numbers), priority (0-3).
- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
- Keep line ranges tight; end defaults to start.
- Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2}
### Git
- Branch prefix: `codex/`. Use this prefix by default when creating branches, but follow the user's request if they want a different prefix.
- After successfully staging files, emit `::git-stage{cwd="/absolute/path"}` on its own line in your final response.
- After successfully creating a commit, emit `::git-commit{cwd="/absolute/path"}` on its own line in your final response.
- After successfully creating or switching the thread onto a branch, emit `::git-create-branch{cwd="/absolute/path" branch="branch-name"}` on its own line in your final response.
- After successfully pushing the current branch, emit `::git-push{cwd="/absolute/path" branch="branch-name"}` on its own line in your final response.
- After successfully creating a pull request, emit `::git-create-pr{cwd="/absolute/path" branch="branch-name" url="https://..." isDraft=true}` on its own line in your final response. Include `isDraft=false` for ready PRs.
- Only emit these git directives in your final response after the action actually succeeds, never in commentary updates. Keep attributes single-line.
</app-context><apps_instructions>
## Apps (Connectors)
Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{connector_id})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps.
An app is equivalent to a set of MCP tools within the `codex_apps` MCP.
An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it.
Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps.
</apps_instructions><skills_instructions>
## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table.
### Skill roots
- `r0` = `/Users/zuozuo/.codex/skills`
- `r1` = `/Users/zuozuo/.agents/skills`
- `r2` = `/Users/zuozuo/.codex/skills/.system`
- `r3` = `/Users/zuozuo/.codex/plugins/cache/openai-bundled`
- `r4` = `/Users/zuozuo/.codex/plugins/cache/openai-curated/github/d6169bef/skills`
- `r5` = `/Users/zuozuo/.codex/plugins/cache/openai-curated-remote/figma/2.0.13/skills`
- `r6` = `/Users/zuozuo/.codex/plugins/cache/openai-curated-remote/product-design/0.1.48/skills`
- `r7` = `/Users/zuozuo/.codex/plugins/cache/openai-primary-runtime`
### Available skills
- imagegen: Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing i (file: r2/imagegen/SKILL.md)
- openai-docs: Use when the user asks how to build with OpenAI products or APIs, asks about Codex itself or choosing Codex surfaces, needs up-to-date official documentation with citations, help choosing the latest model for a use case, or model upgrade and prompt (file: r2/openai-docs/SKILL.md)
- plugin-creator: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, a (file: r2/plugin-creator/SKILL.md)
- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: r2/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: r2/skill-installer/SKILL.md)
- browser:control-in-app-browser: Control the in-app Browser. Use to open, navigate, inspect, test, click, type, screenshot, or verify local targets such as localhost, 127.0.0.1, ::1, file://, the current in-app browser tab, and websites shown side by side inside Codex. (file: r3/browser/26.623.141536/skills/control-in-app-browser/SKILL.md)
- documents:documents: Create, edit, redline, and comment on `.docx`, Word, and Google Docs-targeted document artifacts inside the container, with a strict render-and-verify workflow. Use `render_docx.py` to generate page PNGs (and optional PDF) for visual QA, then itera (file: r7/documents/26.630.12135/skills/documents/SKILL.md)
- dws: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。 (file: r1/dws/SKILL.md)
- dws: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。 (file: r0/dws/SKILL.md)
- figma:figma-code-connect: Creates and maintains Figma Code Connect template files that map Figma components to code snippets. Use when the user mentions Code Connect, Figma component mapping, design-to-code translation, or asks to create/update .figma.ts or .figma.js files. (file: r5/figma-code-connect/SKILL.md)
- figma:figma-create-new-file: **MANDATORY prerequisite** — you MUST invoke this skill BEFORE every `create_new_file` tool call. NEVER call `create_new_file` directly without loading this skill first. Trigger whenever the user wants a new blank Figma file — a new design, FigJam, o (file: r5/figma-create-new-file/SKILL.md)
- figma:figma-generate-design: Use this skill alongside figma-use when the task involves translating an application page, view, or multi-section layout into Figma. Triggers: 'write to Figma', 'create in Figma from code', 'push page to Figma', 'take this app/page and build it in Fi (file: r5/figma-generate-design/SKILL.md)
- figma:figma-generate-diagram: MANDATORY prerequisite — load this skill BEFORE every `generate_diagram` tool call. NEVER call `generate_diagram` directly without loading this skill first. Trigger whenever the user asks to create, generate, draw, render, sketch, or build a diagram (file: r5/figma-generate-diagram/SKILL.md)
- figma:figma-generate-library: Build or update a professional-grade design system in Figma from a codebase. Use when the user wants to create variables/tokens, build component libraries, create individual components with proper variant sets and variable bindings, set up theming (file: r5/figma-generate-library/SKILL.md)
- figma:figma-implement-motion: Translates Figma motion and animations into production-ready application code. Use when implementing animation/motion from a Figma design — user mentions "implement this motion", "add animation from Figma", "animate this component", provides a Figma (file: r5/figma-implement-motion/SKILL.md)
- figma:figma-swiftui: SwiftUI ↔ Figma translation. Use whenever the user mentions Swift, SwiftUI, iOS, iPhone, or iPad — in EITHER direction — translating a Figma design into SwiftUI (design → code), or pushing SwiftUI views / screens / tokens back into a Figma file (code (file: r5/figma-swiftui/SKILL.md)
- figma:figma-use: **MANDATORY prerequisite** — you MUST invoke this skill BEFORE every `use_figma` tool call. NEVER call `use_figma` directly without loading this skill first. Skipping it causes common, hard-to-debug failures. Trigger whenever the user wants to perf (file: r5/figma-use/SKILL.md)
- figma:figma-use-figjam: This skill helps agents use Figma's use_figma MCP tool in the FigJam context. Can be used alongside figma-use which has foundational context for using the use_figma tool. (file: r5/figma-use-figjam/SKILL.md)
- figma:figma-use-motion: Motion / animation context for the `use_figma` MCP tool — animating Figma nodes via manual keyframes, animation styles, easing, and timeline duration. Load alongside figma-use whenever a task involves adding, editing, or inspecting animation on a nod (file: r5/figma-use-motion/SKILL.md)
- figma:figma-use-slides: This skill helps agents use Figma's use_figma MCP tool in the Slides context. Can be used alongside figma-use which has foundational context for using the use_figma tool. (file: r5/figma-use-slides/SKILL.md)
- github:gh-address-comments: Address actionable GitHub pull request review feedback. Use when the user wants to inspect unresolved review threads, requested changes, or inline review comments on a PR, then implement selected fixes. Use the GitHub app for PR metadata and flat co (file: r4/gh-address-comments/SKILL.md)
- github:gh-fix-ci: Use when a user asks to debug or fix failing GitHub PR checks that run in GitHub Actions. Use the GitHub app from this plugin for PR metadata and patch context, and use `gh` for Actions check and log inspection before implementing any approved fix. (file: r4/gh-fix-ci/SKILL.md)
- github:github: Triage and orient GitHub repository, pull request, and issue work through the connected GitHub app. Use when the user asks for general GitHub help, wants PR or issue summaries, or needs repository context before choosing a more specific GitHub workflo (file: r4/github/SKILL.md)
- github:yeet: Publish local changes to GitHub by confirming scope, committing intentionally, pushing the branch, and opening a draft PR through the GitHub app from this plugin, with `gh` used only as a fallback where connector coverage is insufficient. (file: r4/yeet/SKILL.md)
- lark-approval: 飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例。审批待办不是飞书任务(任务类待办走 lark-task不负责创建审批定义和发起新审批。 (file: r1/lark-approval/SKILL.md)
- lark-apps: 妙搭Spark/Miaoda应用开发与托管应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传lark-drive、飞书文档编辑lark-doc、原生幻灯片创建lark-slides。 (file: r1/lark-apps/SKILL.md)
- lark-attendance: 飞书考勤打卡:查询自己的考勤打卡记录 (file: r1/lark-attendance/SKILL.md)
- lark-base: 飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive认证/授权转 lark-shared。 (file: r1/lark-base/SKILL.md)
- lark-calendar: 飞书日历:管理日历日程和会议室。查看/搜索日程、创建/更新日程、管理参会人、查询忙闲和推荐时段、预定会议室。当用户需要查看日程安排、创建/修改会议、查询/预定会议室时使用。不负责:查询过去的视频会议记录(走 lark-vc、待办任务走 lark-task。 (file: r1/lark-calendar/SKILL.md)
- lark-contact: 飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名。当用户提到某人姓名要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。 (file: r1/lark-contact/SKILL.md)
- lark-doc: 飞书云文档Docx / Wiki 文档v2 API读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Bas (file: r1/lark-doc/SKILL.md)
- lark-drive: 飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题docx、sheet、bitable、file、folder、wiki也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base导入为飞书在线云文档docx、sheet、bitable、slides。当用户需要上传或下载文件、整理云空间 (file: r1/lark-drive/SKILL.md)
- lark-event: Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, VC meeting ended, Minutes generated, Whiteboard updated, etc.). Use for Lark (file: r1/lark-event/SKILL.md)
- lark-im: 飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据时使用。 (file: r1/lark-im/SKILL.md)
- lark-mail: 飞书邮箱 — draft, compose, send, reply, forward, read, and search emails; manage drafts, folders, labels, contacts, attachments, and mail rules. Use when user mentions 起草邮件, 写一封邮件, 拟邮件, 草稿, 发通知邮件, 发送邮件, 发邮件, 回复邮件, 转发邮件, 查看邮件, 看邮件, 读邮件, 搜索邮件, 查邮件, 收件箱, (file: r1/lark-mail/SKILL.md)
- lark-markdown: 飞书 Markdown查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改、局部 patch 或比较差异时使用。不负责将 Markdown 导入为飞书在线文档,也不负责文件搜索、权限、评论、移动、删除等云空间管理操作。 (file: r1/lark-markdown/SKILL.md)
- lark-minutes: 飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记、纪要/逐字稿内容获取走 lark-vc (file: r1/lark-minutes/SKILL.md)
- lark-okr: 飞书 OKR管理目标与关键结果。查看和编辑 OKR 周期、目标Objective、关键结果Key Result、对齐关系、量化指标和进展记录。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。 (file: r1/lark-okr/SKILL.md)
- lark-openapi-explorer: 飞书/Lark 原生 OpenAPI 探索:从官方文档库中挖掘未经 CLI 封装的原生 OpenAPI 接口。当用户的需求无法被现有 lark-* skill 或 lark-cli 已注册命令满足,需要查找并调用原生飞书 OpenAPI 时使用。 (file: r1/lark-openapi-explorer/SKILL.md)
- lark-shared: Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output. (file: r1/lark-shared/SKILL.md)
- lark-sheets: 飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive (file: r1/lark-sheets/SKILL.md)
- lark-skill-maker: 创建 lark-cli 的自定义 Skill。当用户需要把飞书 API 操作封装成可复用的 Skill包装原子 API 或编排多步流程)时使用。 (file: r1/lark-skill-maker/SKILL.md)
- lark-slides: 飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责云文档内容编辑走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide (file: r1/lark-slides/SKILL.md)
- lark-task: 飞书任务:管理任务、清单和任务智能体。创建待办任务、查看和更新任务状态、拆分子任务、组织任务清单、分配协作成员、上传任务附件、注册或注销任务智能体、更新任务智能体的主页数据、写入智能体任务记录。当用户需要创建待办事项、查看任务列表、跟踪任务进度、管理项目清单或给他人分配任务、为任务上传附件文件、注册注销任务智能体、更新智能体主页数据、写入任务记录时使用。 (file: r1/lark-task/SKILL.md)
- lark-vc: 飞书视频会议:搜索历史会议记录、查询会议纪要(总结/待办/章节/逐字稿)、查询参会人快照。当用户查询已结束的会议、获取会议产物(纪要/妙记)、查看参会人时使用;查询未来日程走 lark-calendar。不负责Agent 真实入会/离会、会中实时事件(走 lark-vc-agent。 (file: r1/lark-vc/SKILL.md)
- lark-vc-agent: 飞书视频会议:让机器人代当前用户加入/离开正在进行的会议并读取会议期间的实时事件参会人加入与离开、发言、聊天、屏幕共享等。1. 用户提供 9 位会议号、要求代为入会或离会时使用 +meeting-join / +meeting-leave——会真实产生入会/离会记录。2. 会议进行中用户想知道“谁加入了”“谁离开了”“谁在发言”“有人共享屏幕吗”等会中动态时,机器人入会后用 +meeting-events 读取事件时间线。3. 典型场景:参会机器人、会中助手、代为旁听、代为参会。前提: (file: r1/lark-vc-agent/SKILL.md)
- lark-whiteboard: 飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用 DSL转成 OpenAPI 格式、PlantUML/Mermaid 格式更新画板内容。 当用户需要查看画板内容、导出画板图片、编辑画板,或是需要可视化表达架构、流程、组织关系、时间线、因果、对比等结构化信息时使用此 skill无论是否提及"画板"。 ⚠️ 原 `lark-whiteboard-cli` skill 已合并至本 skill若 skill 列表中同时存在 `lark-whiteboard- (file: r1/lark-whiteboard/SKILL.md)
- lark-wiki: 飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责上传文件到知识库节点下走 lark-drive (file: r1/lark-wiki/SKILL.md)
- lark-workflow-meeting-summary: 会议纪要整理工作流:汇总指定时间范围内的会议纪要并生成结构化报告。当用户需要整理会议纪要、生成会议周报、回顾一段时间内的会议内容时使用。 (file: r1/lark-workflow-meeting-summary/SKILL.md)
- lark-workflow-standup-report: 日程待办摘要:编排 calendar +agenda 和 task +get-my-tasks生成指定日期的日程与未完成任务摘要。适用于了解今天/明天/本周的安排。 (file: r1/lark-workflow-standup-report/SKILL.md)
- pdf:pdf: Read, create, inspect, render, and verify PDF files where visual layout matters. Use Poppler rendering plus Python tools such as reportlab, pdfplumber, and pypdf for generation and extraction. (file: r7/pdf/26.630.12135/skills/pdf/SKILL.md)
- presentations:Presentations: Create or edit PowerPoint or Google Slides decks (file: r7/presentations/26.630.12135/skills/presentations/SKILL.md)
- product-design:audit: Audit or critique a product flow, journey, workflow, funnel, onboarding path, checkout path, settings path, screen, or multi-step product experience by capturing screenshots first, placing them in Figma or a local folder, then reporting UX, design, (file: r6/audit/SKILL.md)
- product-design:get-context: Mandatory design-brief gate for Product Design build and design workflows. Use before ideation, prototyping, image-to-code builds, redesigns, or product UI work to clarify missing product, visual, and interactivity context or play back the supplied (file: r6/get-context/SKILL.md)
- product-design:ideate: Generate image-based visual alternatives, remixes, or concept directions after Product Design get-context has confirmed the design brief. Use when the user asks for design variants, visual exploration, remixes, or image-generated approaches from provi (file: r6/ideate/SKILL.md)
- product-design:image-to-code: Implement a selected image, screenshot, mockup, or Image Gen reference as a faithful responsive frontend after Product Design get-context has confirmed the design brief. (file: r6/image-to-code/SKILL.md)
- product-design:index: Use to discover specific skills for the Product Design plugin, when it is at-mentioned directly, or for any mentions of potentially relevant work, including: UX research; product, screen, or flow audits; visual ideation; app or interface design, red (file: r6/index/SKILL.md)
- spreadsheets:Spreadsheets: Use this skill when a user requests to create, modify, analyze, visualize, or work with spreadsheet files (`.xlsx`, `.xls`, `.csv`, `.tsv`) or Google Sheets-targeted spreadsheet artifacts with formulas, formatting, charts, tables, and recalculation (file: r7/spreadsheets/26.630.12135/skills/spreadsheets/SKILL.md)
- template-creator:template-creator: Create or update a reusable personal Codex artifact-template skill. Use when the user invokes $template-creator or asks in natural language to create a template using, from, or based on an attached Word document, PowerPoint presentation, or Excel w (file: r7/template-creator/26.630.12135/skills/template-creator/SKILL.md)
### How to use skills
- Discovery: The list above is the skills available in this session (name + description + short path). Skill bodies live on disk at the listed paths after expanding the matching alias from `### Skill roots`.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
1) After deciding to use a skill, the main agent must expand the listed short `path` with the matching alias from `### Skill roots`, then open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF.
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the directory containing that expanded `SKILL.md` first, and only consider other paths if needed.
3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it.
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
- Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file. Do not load unrelated references, scripts, or assets.
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.
</skills_instructions><plugins_instructions>
## Plugins
A plugin is a local bundle of skills, MCP servers, and apps.
### How to use plugins
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list.
- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as `mcp__server__tool`; use tool provenance to tell which plugin they come from.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associated skills, MCP tools, and apps exposed elsewhere in this turn.
- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback.
</plugins_instructions>
---
**User**
你是返工后的严格验收 agent。红线禁止使用浏览器整页截图、禁止使用 Figma 根节点 859:2027/859:2247/859:2454 截图做 pixel diff。请只验收、不修改文件。目标/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/yumi.html 及相关 yumi.css/yumi.js/assets/yumi/layers。验收项1) 确认没有使用整页前景图资源(禁止 yumi-cp.png/yumi-brother.png/yumi-sister.png禁止 manifest 使用根节点2) 检查资源 manifest 中每个资源来自独立 Figma 图层,尺寸合理,允许 bg-* 仅作为背景层且来自非根背景 group3) 检查 DOM 分层结构背景、status/nav、tabs、card skin、avatar、center art、exp/progress/text 都是独立节点,文本是 DOM/i18n4) 检查 tab=cp/brother/sister 资源切换逻辑和热区坐标是否按 Figma metadata无需截图5) 检查 common/theme.css/common/i18n.js 接入及 locale key。若发现红线问题请直接“不通过”并列返修点否则明确“验收通过”。
---
**OpenAI Codex**
我会只做静态验收,不修改文件;按红线先查禁用资源/manifest再核对 DOM、CSS、JS、i18n 与坐标元数据。
---
**OpenAI Codex**
验收通过。
已按红线只做静态/资源验收,未修改文件,未使用浏览器整页截图,未使用 Figma 根节点截图做 pixel diff。
验收结果:
1. **整页前景图/根节点**
- 未发现引用 `yumi-cp.png` / `yumi-brother.png` / `yumi-sister.png`
- `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/activity/cp-space/assets/yumi/layers/manifest.json` 未使用禁用根节点 `859:2027` / `859:2247` / `859:2454`
- 目标页面仅引用 `/assets/yumi/layers/` 下分层资源。
2. **manifest 与资源尺寸**
- `bg-*` 仅作为 `.yumi-bg-layer` 背景层使用。
- `bg-*` manifest node 为 `859:2028` / `859:2248` / `859:2455`,非禁用根节点。
- 所有 manifest 资源实际 PNG 尺寸与 bounds 匹配:背景 `375x812`,卡片 `347x199`tab `130x48`center `136x120/136`level `24x13`avatar frame `66x66`
3. **DOM 分层**
- 背景、status、nav、tabs、card skin、avatar、center art、exp pill、progress、文本均为独立 DOM/图片节点。
- 可变文本为 DOM 文本,不在整页图中。
4. **tab 切换与坐标**
- `tab=cp/brother/sister` 均有独立资源映射。
- 切换会更新 `bg/tab/card/center/level`,并同步 `data-tab` 与选中态。
- tab jewel 坐标与 manifest bounds 对齐:`cp x=9``brother x=126``sister x=236`top `107`
5. **common 接入与 locale key**
- 已接入 `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/common/theme.css`
- 已接入 `/Users/zuozuo/Documents/Project/haiyi/hyapp-h5/common/i18n.js`
- 页面使用的 `cpSpace.*` key 在 `en/ar/tr/es` locale 中均存在。
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1017 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 B

View File

@ -0,0 +1,89 @@
{
"rule": "Layered restoration only. Do not use root-frame or full foreground screenshots as implementation assets.",
"baseFrame": {
"width": 375,
"height": 812
},
"assets": {
"bg-cp.png": {
"figmaNodeId": "859:2028",
"purpose": "background layer only",
"bounds": { "x": 0, "y": 0, "width": 375, "height": 812 }
},
"bg-brother.png": {
"figmaNodeId": "859:2248",
"purpose": "background layer only",
"bounds": { "x": 0, "y": 0, "width": 375, "height": 812 }
},
"bg-sister.png": {
"figmaNodeId": "859:2455",
"purpose": "background layer only",
"bounds": { "x": 0, "y": 0, "width": 375, "height": 812 }
},
"tab-cp.png": {
"figmaNodeId": "859:2059",
"purpose": "selected CP tab jewel",
"bounds": { "x": 9, "y": 107, "width": 130, "height": 48 }
},
"tab-brother.png": {
"figmaNodeId": "859:2279",
"purpose": "selected Brother tab jewel",
"bounds": { "x": 126, "y": 107, "width": 130, "height": 48 }
},
"tab-sister.png": {
"figmaNodeId": "859:2486",
"purpose": "selected Sister tab jewel",
"bounds": { "x": 236, "y": 107, "width": 130, "height": 48 }
},
"card-cp.png": {
"figmaNodeId": "859:2065",
"purpose": "CP card skin layer without dynamic texts",
"bounds": { "x": 14, "y": 169, "width": 347, "height": 199 }
},
"card-brother.png": {
"figmaNodeId": "859:2287",
"purpose": "Brother card skin layer without dynamic texts",
"bounds": { "x": 14, "y": 169, "width": 347, "height": 199 }
},
"card-sister.png": {
"figmaNodeId": "859:2494",
"purpose": "Sister card skin layer without dynamic texts",
"bounds": { "x": 14, "y": 169, "width": 347, "height": 199 }
},
"center-cp.png": {
"figmaNodeId": "859:2189",
"purpose": "CP center heart art",
"bounds": { "x": 120, "y": 175, "width": 136, "height": 120 }
},
"center-brother.png": {
"figmaNodeId": "859:2409",
"purpose": "Brother center shield art",
"bounds": { "x": 120, "y": 175, "width": 136, "height": 136 }
},
"center-sister.png": {
"figmaNodeId": "859:2618",
"purpose": "Sister center floral art",
"bounds": { "x": 120, "y": 175, "width": 136, "height": 136 }
},
"avatar-frame.png": {
"figmaNodeId": "859:2183",
"purpose": "avatar frame layer",
"bounds": { "width": 66, "height": 66 }
},
"level-cp.png": {
"figmaNodeId": "859:2218",
"purpose": "CP level badge layer",
"bounds": { "width": 24, "height": 13 }
},
"level-brother.png": {
"figmaNodeId": "859:2425",
"purpose": "Brother level badge layer",
"bounds": { "width": 24, "height": 13 }
},
"level-sister.png": {
"figmaNodeId": "859:2634",
"purpose": "Sister level badge layer",
"bounds": { "width": 24, "height": 13 }
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

512
activity/cp-space/yumi.css Normal file
View File

@ -0,0 +1,512 @@
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
margin: 0;
width: 100%;
min-height: 100%;
overflow-x: hidden;
background: #15101d;
color: #fff;
font-family:
'SF Pro Display', 'SF Pro Text', 'Source Han Sans SC',
'Noto Sans CJK SC', Arial, 'Helvetica Neue', Helvetica, sans-serif;
}
button {
border: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.yumi-cp-page {
display: flex;
justify-content: center;
width: 100%;
min-height: 100svh;
background: #15101d;
}
.yumi-cp-stage {
--stage-height: 812px;
--design-width: 375px;
--design-height: 812px;
--design-scale: 1;
--accent: #ff0979;
--accent-soft: #ff5762;
--tab-bg: rgba(94, 13, 35, 0.8);
--tab-glow: #ff7948;
--tab-jewel-left: -7px;
--avatar-top: 204px;
--center-height: 120px;
--exp-top: 271px;
--progress-from: #ff1e37;
--progress-mid: #fe6a30;
--progress-to: #ffcf67;
--exp-pill-bg: rgba(255, 102, 169, 0.74);
position: relative;
width: 100%;
height: var(--stage-height);
overflow: hidden;
background: #440b23;
flex: 0 0 auto;
}
.yumi-stage-design {
/* 资源按 375x812 设计稿导出;统一缩放内层画布,避免逐个重算绝对定位和素材尺寸。 */
position: relative;
width: var(--design-width);
height: var(--design-height);
transform: scale(var(--design-scale));
transform-origin: left top;
}
.yumi-cp-page[data-tab='brother'] .yumi-cp-stage {
--accent: #5709ff;
--accent-soft: #5709ff;
--tab-bg: rgba(17, 13, 94, 0.8);
--tab-glow: #4872ff;
--tab-jewel-left: 110px;
--avatar-top: 210px;
--center-height: 136px;
--exp-top: 271px;
--progress-from: #471eff;
--progress-mid: #30bcfe;
--progress-to: #67acff;
--exp-pill-bg: rgba(97, 180, 255, 0.74);
background: #080636;
}
.yumi-cp-page[data-tab='sister'] .yumi-cp-stage {
--accent: #ff4a09;
--accent-soft: #ff4a09;
--tab-bg: rgba(94, 29, 13, 0.8);
--tab-glow: #ff7948;
--tab-jewel-left: 220px;
--avatar-top: 210px;
--center-height: 136px;
--exp-top: 262px;
--progress-from: #ff5e1e;
--progress-mid: #fe6a30;
--progress-to: #ffb167;
--exp-pill-bg: rgba(255, 142, 43, 0.74);
background: #301008;
}
.yumi-bg-layer,
.yumi-card-skin,
.yumi-tab-jewel,
.yumi-center-art,
.yumi-avatar-frame,
.yumi-level-bg {
display: block;
user-select: none;
-webkit-user-drag: none;
pointer-events: none;
}
.yumi-bg-layer {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
object-fit: fill;
}
.yumi-nav,
.yumi-tabs,
.yumi-card {
position: absolute;
z-index: 1;
}
.yumi-nav {
left: 0;
top: 0;
width: 375px;
height: 44px;
}
.yumi-title {
position: absolute;
left: 50%;
top: 9px;
margin: 0;
color: #fff;
font-size: 18px;
font-weight: 500;
line-height: 22px;
text-align: center;
white-space: nowrap;
transform: translateX(-50%);
}
.yumi-nav-button {
position: absolute;
top: 10px;
width: 24px;
height: 24px;
}
.yumi-nav-button--back {
left: 16px;
}
.yumi-nav-button--back::before {
position: absolute;
left: 8px;
top: 5px;
width: 12px;
height: 12px;
border-left: 4px solid #fff;
border-bottom: 4px solid #fff;
border-radius: 2px;
content: '';
transform: rotate(45deg);
}
.yumi-nav-button--help {
left: 335px;
border: 2px solid #fff;
border-radius: 50%;
}
.yumi-nav-button--help::before {
position: absolute;
left: 50%;
top: 2px;
color: #fff;
font-size: 20px;
font-weight: 400;
line-height: 20px;
content: '?';
transform: translateX(-50%);
}
.yumi-tabs {
left: 16px;
top: 65px;
display: grid;
grid-template-columns: repeat(3, 1fr);
width: 343px;
height: 37px;
border: 0.5px solid #f4b747;
border-radius: 32px;
background: var(--tab-bg);
box-shadow: inset 0 0 5.5px var(--tab-glow);
}
.yumi-tab-jewel {
position: absolute;
z-index: 0;
left: var(--tab-jewel-left);
top: -2px;
width: 130px;
height: 48px;
object-fit: fill;
}
.yumi-tab-button {
position: relative;
z-index: 1;
height: 37px;
color: rgba(255, 255, 255, 0.8);
font-size: 18px;
font-weight: 400;
line-height: 18px;
text-align: center;
}
.yumi-tab-button[aria-selected='true'] {
color: #fff;
font-weight: 500;
}
.yumi-tab-button span {
position: relative;
top: -1px;
}
.yumi-card {
left: 14px;
top: 125px;
width: 347px;
height: 199px;
color: var(--accent);
}
.yumi-card-skin {
position: absolute;
inset: 0;
z-index: 0;
width: 347px;
height: 199px;
object-fit: fill;
}
.yumi-avatar {
position: absolute;
z-index: 2;
top: calc(var(--avatar-top) - 169px);
width: 66px;
height: 66px;
margin: 0;
}
.yumi-avatar--left {
left: 32px;
}
.yumi-avatar--right {
left: 250px;
}
.yumi-avatar-photo {
position: absolute;
left: 2.36px;
top: 2.36px;
z-index: 1;
display: block;
width: 61.29px;
height: 61.29px;
overflow: hidden;
border-radius: 50%;
background:
radial-gradient(circle at 50% 38%, #f0eee7 0 19px, transparent 20px),
linear-gradient(135deg, #d5d0c2, #85806f);
}
.yumi-avatar-photo img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.yumi-avatar-frame {
position: absolute;
inset: 0;
z-index: 2;
width: 66px;
height: 66px;
object-fit: contain;
}
.yumi-center-art-wrap {
position: absolute;
z-index: 1;
left: 106px;
top: 6px;
width: 136px;
height: var(--center-height);
text-align: center;
}
.yumi-center-art {
position: absolute;
inset: 0;
z-index: 0;
width: 136px;
height: var(--center-height);
object-fit: fill;
}
.yumi-days-value {
position: absolute;
z-index: 1;
left: 50%;
top: 45px;
color: var(--accent);
font-size: 18px;
font-weight: 800;
line-height: 18px;
text-shadow:
0 1px 0 #fff,
0 0 4px rgba(255, 255, 255, 0.75);
transform: translateX(-50%);
}
.yumi-days-unit {
position: absolute;
z-index: 1;
left: 50%;
top: 68px;
color: var(--accent);
font-size: 8px;
font-weight: 600;
line-height: 8px;
text-shadow: 0 1px 0 #fff;
transform: translateX(-50%);
}
.yumi-exp-pill {
position: absolute;
z-index: 3;
left: 119px;
top: calc(var(--exp-top) - 169px);
display: grid;
place-items: center;
width: 112px;
height: 28px;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 14px;
background:
linear-gradient(rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0)),
var(--exp-pill-bg);
box-shadow:
inset 0 1px 2px rgba(255, 255, 255, 0.8),
inset 0 -2px 3px rgba(255, 255, 255, 0.28),
0 0 6px rgba(255, 255, 255, 0.32);
}
.yumi-exp-pill span {
color: var(--accent);
font-size: 8px;
font-weight: 400;
line-height: 8px;
text-align: center;
text-shadow:
0 1px 0 rgba(255, 255, 255, 0.95),
0 0 2px rgba(255, 255, 255, 0.85);
white-space: nowrap;
}
.yumi-progress-block {
position: absolute;
z-index: 3;
left: 32px;
top: 134px;
width: 289px;
height: 35px;
}
.yumi-level-badge {
position: absolute;
top: 1.5px;
width: 24px;
height: 13px;
}
.yumi-level-badge--left {
left: 0;
}
.yumi-level-badge--right {
right: 0;
}
.yumi-level-bg {
position: absolute;
inset: 0;
width: 24px;
height: 13px;
object-fit: fill;
}
.yumi-level-badge span {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff;
font-size: 9px;
font-weight: 600;
line-height: 9px;
text-shadow: 0 0 1px var(--accent);
}
.yumi-progress-track {
position: absolute;
left: 28px;
top: 0;
width: 233px;
height: 16px;
border-radius: 74px;
background: rgba(255, 255, 255, 0.3);
overflow: hidden;
backdrop-filter: blur(6px);
}
.yumi-progress-fill {
position: absolute;
left: 1px;
top: 1px;
width: 52px;
height: 14px;
border-radius: 7px;
background: linear-gradient(
90deg,
var(--progress-from) 0%,
var(--progress-mid) 50%,
var(--progress-to) 100%
);
box-shadow:
inset -4px 0 3px #fffef8,
inset 0 2px 2px rgba(255, 254, 239, 0.48),
inset 0 -2px 2px rgba(255, 255, 255, 0.45);
}
.yumi-progress-fill::after {
position: absolute;
inset: 1px 2px;
border-radius: inherit;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.95) 0 1px,
transparent 2px
)
0 0 / 7px 7px;
mix-blend-mode: screen;
content: '';
opacity: 0.9;
}
.yumi-progress-text {
position: absolute;
left: 50%;
top: 2px;
color: #fff;
font-size: 14px;
font-weight: 700;
line-height: 14px;
text-align: center;
white-space: nowrap;
transform: translateX(-50%);
}
.yumi-time-text {
position: absolute;
left: 0;
top: 20px;
width: 289px;
margin: 0;
color: var(--accent-soft);
font-size: 11px;
font-weight: 400;
line-height: 15px;
text-align: center;
text-shadow:
0 1px 0 rgba(255, 255, 255, 0.82),
0 0 2px rgba(255, 255, 255, 0.72);
}
.yumi-nav-button:focus-visible,
.yumi-tab-button:focus-visible {
border-radius: 18px;
outline: 2px solid var(--hy-theme-focus-ring, rgba(255, 255, 255, 0.42));
}
[dir='rtl'] .yumi-nav-button--back::before {
transform: rotate(225deg);
}
[dir='rtl'] .yumi-tabs {
direction: ltr;
}

210
activity/cp-space/yumi.html Normal file
View File

@ -0,0 +1,210 @@
<!doctype html>
<html lang="en" data-hy-app-code="yumi">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title data-i18n="cpSpace.pageTitle">Best Friend</title>
<link rel="icon" href="data:," />
<link
rel="stylesheet"
href="../../common/theme.css?v=20260708-yumi-layered"
/>
<link rel="stylesheet" href="./yumi.css?v=20260708-yumi-fullwidth" />
</head>
<body>
<main
class="yumi-cp-page"
id="yumiCpPage"
data-tab="cp"
aria-label="Best Friend space"
data-i18n-aria="cpSpace.pageLabel"
>
<section
class="yumi-cp-stage"
aria-label="Best Friend"
data-i18n-aria="cpSpace.pageLabel"
>
<div class="yumi-stage-design">
<img
class="yumi-bg-layer"
id="yumiBgLayer"
src="./assets/yumi/layers/bg-cp.png"
alt=""
draggable="false"
/>
<nav
class="yumi-nav"
aria-label="Best Friend navigation"
data-i18n-aria="cpSpace.navLabel"
>
<button
class="yumi-nav-button yumi-nav-button--back"
id="yumiBackButton"
type="button"
aria-label="Back"
data-i18n-aria="cpSpace.back"
></button>
<h1 class="yumi-title" data-i18n="cpSpace.title">
Best Friend
</h1>
<button
class="yumi-nav-button yumi-nav-button--help"
id="yumiHelpButton"
type="button"
aria-label="Help"
data-i18n-aria="cpSpace.help"
></button>
</nav>
<nav
class="yumi-tabs"
aria-label="Best Friend tabs"
data-i18n-aria="cpSpace.tabsLabel"
>
<img
class="yumi-tab-jewel"
id="yumiTabJewel"
src="./assets/yumi/layers/tab-cp.png"
alt=""
draggable="false"
/>
<button
class="yumi-tab-button"
type="button"
data-tab="cp"
>
<span data-i18n="cpSpace.tabCp">CP</span>
</button>
<button
class="yumi-tab-button"
type="button"
data-tab="brother"
>
<span data-i18n="cpSpace.tabBrother">Brother</span>
</button>
<button
class="yumi-tab-button"
type="button"
data-tab="sister"
>
<span data-i18n="cpSpace.tabSister">Sister</span>
</button>
</nav>
<article class="yumi-card" aria-label="Best friend level 1">
<img
class="yumi-card-skin"
id="yumiCardSkin"
src="./assets/yumi/layers/card-cp.png"
alt=""
draggable="false"
/>
<figure class="yumi-avatar yumi-avatar--left">
<span
class="yumi-avatar-photo"
id="yumiLeftAvatar"
></span>
<img
class="yumi-avatar-frame"
src="./assets/yumi/layers/avatar-frame.png"
alt=""
draggable="false"
/>
</figure>
<figure class="yumi-avatar yumi-avatar--right">
<span
class="yumi-avatar-photo"
id="yumiRightAvatar"
></span>
<img
class="yumi-avatar-frame"
src="./assets/yumi/layers/avatar-frame.png"
alt=""
draggable="false"
/>
</figure>
<div class="yumi-center-art-wrap" id="yumiCenterWrap">
<img
class="yumi-center-art"
id="yumiCenterArt"
src="./assets/yumi/layers/center-cp.png"
alt=""
draggable="false"
/>
<strong class="yumi-days-value" id="yumiDaysValue"
>999</strong
>
<span
class="yumi-days-unit"
data-i18n="cpSpace.daysUnit"
>Days</span
>
</div>
<div class="yumi-exp-pill">
<span id="yumiExpAway"
>888 Exp away from Lv. 2</span
>
</div>
<section
class="yumi-progress-block"
aria-label="1000/200000"
>
<div
class="yumi-level-badge yumi-level-badge--left"
>
<img
class="yumi-level-bg"
src="./assets/yumi/layers/level-cp.png"
alt=""
draggable="false"
/>
<span id="yumiCurrentLevel">Lv1</span>
</div>
<div class="yumi-progress-track">
<span
class="yumi-progress-fill"
id="yumiProgressFill"
></span>
<strong
class="yumi-progress-text"
id="yumiProgressText"
>1000/200000</strong
>
</div>
<div
class="yumi-level-badge yumi-level-badge--right"
>
<img
class="yumi-level-bg"
src="./assets/yumi/layers/level-cp.png"
alt=""
draggable="false"
/>
<span id="yumiNextLevel">Lv1</span>
</div>
<p class="yumi-time-text" id="yumiTimeText">
Time spent together: 4 days
</p>
</section>
</article>
</div>
</section>
</main>
<script src="../../common/i18n.js?v=20260708-yumi-layered"></script>
<script src="../../common/api.js?v=20260708-yumi-layered"></script>
<script src="../../common/jsbridge.js?v=20260708-yumi-layered"></script>
<script src="../../common/toast.js?v=20260708-yumi-layered"></script>
<script src="../../common_yumi/yumi-common.js?v=20260708-yumi-api"></script>
<script src="./yumi.js?v=20260708-yumi-api"></script>
</body>
</html>

368
activity/cp-space/yumi.js Normal file
View File

@ -0,0 +1,368 @@
(function () {
var VALID_TABS = ['cp', 'brother', 'sister'];
var LAYER_BASE = './assets/yumi/layers/';
var THEMES = {
cp: {
bg: 'bg-cp.png',
tab: 'tab-cp.png',
card: 'card-cp.png',
center: 'center-cp.png',
level: 'level-cp.png',
},
brother: {
bg: 'bg-brother.png',
tab: 'tab-brother.png',
card: 'card-brother.png',
center: 'center-brother.png',
level: 'level-brother.png',
},
sister: {
bg: 'bg-sister.png',
tab: 'tab-sister.png',
card: 'card-sister.png',
center: 'center-sister.png',
level: 'level-sister.png',
},
};
var DEFAULT_RELATION = {
days: 0,
togetherDays: 0,
currentLevel: 0,
nextLevel: 0,
currentExp: 0,
nextExp: 0,
neededExp: 0,
nextExpLevel: 0,
progressPercent: 0,
me: { avatar: '' },
partner: { avatar: '' },
};
var page = document.getElementById('yumiCpPage');
var stage = document.querySelector('.yumi-cp-stage');
var backButton = document.getElementById('yumiBackButton');
var helpButton = document.getElementById('yumiHelpButton');
var tabButtons = Array.prototype.slice.call(
document.querySelectorAll('.yumi-tab-button[data-tab]')
);
var bgLayer = document.getElementById('yumiBgLayer');
var tabJewel = document.getElementById('yumiTabJewel');
var cardSkin = document.getElementById('yumiCardSkin');
var centerArt = document.getElementById('yumiCenterArt');
var levelBgs = Array.prototype.slice.call(
document.querySelectorAll('.yumi-level-bg')
);
var leftAvatar = document.getElementById('yumiLeftAvatar');
var rightAvatar = document.getElementById('yumiRightAvatar');
var daysValue = document.getElementById('yumiDaysValue');
var expAway = document.getElementById('yumiExpAway');
var currentLevel = document.getElementById('yumiCurrentLevel');
var nextLevel = document.getElementById('yumiNextLevel');
var progressFill = document.getElementById('yumiProgressFill');
var progressText = document.getElementById('yumiProgressText');
var timeText = document.getElementById('yumiTimeText');
var state = {
activeTab: normalizeTab(readParam('tab')) || 'cp',
data: clone(DEFAULT_RELATION),
tabData: {},
requestSeq: 0,
};
var stageResizeObserver = null;
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function readParam(name) {
var params = new URLSearchParams(window.location.search || '');
return params.get(name);
}
function normalizeTab(value) {
var tab = String(value || '')
.trim()
.toLowerCase();
return VALID_TABS.indexOf(tab) >= 0 ? tab : '';
}
function readNumber(value, fallback) {
var number = Number(value);
return Number.isFinite(number) ? number : fallback || 0;
}
function firstValue() {
for (var i = 0; i < arguments.length; i += 1) {
var value = arguments[i];
if (value !== undefined && value !== null && value !== '')
return value;
}
return undefined;
}
function t(key, fallback) {
if (window.HyAppI18n && window.HyAppI18n.t) {
return window.HyAppI18n.t(key, fallback);
}
return fallback || key;
}
function formatNumber(value) {
var number = Math.max(0, Math.floor(readNumber(value, 0)));
return String(number).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function asset(name) {
return LAYER_BASE + name;
}
function syncStageScale() {
if (!stage) return;
var width = stage.getBoundingClientRect().width || window.innerWidth;
var scale = Math.max(0.1, width / 375);
// Yumi 素材和定位都是 375x812 设计稿坐标;只同步一个缩放值,避免多处百分比换算造成错位。
stage.style.setProperty('--design-scale', String(scale));
stage.style.setProperty('--stage-height', 812 * scale + 'px');
}
function bindStageScale() {
syncStageScale();
if (!stage) return;
if (window.ResizeObserver) {
stageResizeObserver = new ResizeObserver(syncStageScale);
stageResizeObserver.observe(stage);
return;
}
// 低版本 WebView 没有 ResizeObserver只能监听窗口变化来覆盖旋转和容器宽度变更。
window.addEventListener('resize', syncStageScale);
window.addEventListener('orientationchange', syncStageScale);
}
function syncURL(tab) {
if (!window.history || !window.history.replaceState) return;
var url = new URL(window.location.href);
url.searchParams.set('tab', tab);
// tab 是当前视觉主题状态replaceState 让刷新可复现,同时不把每次切换压入 App WebView 返回栈。
window.history.replaceState(null, '', url.toString());
}
function setImage(node, fileName) {
if (!node) return;
var nextSrc = asset(fileName);
if (node.getAttribute('src') !== nextSrc)
node.setAttribute('src', nextSrc);
}
function renderAvatar(container, url) {
if (!container) return;
container.innerHTML = '';
if (!url) return;
var image = document.createElement('img');
image.src = url;
image.alt = '';
container.appendChild(image);
}
function normalizedRelation(raw) {
var data = raw || {};
return {
days: readNumber(data.days, DEFAULT_RELATION.days),
togetherDays: readNumber(
firstValue(data.togetherDays, data.together_days),
DEFAULT_RELATION.togetherDays
),
currentLevel: readNumber(
firstValue(data.currentLevel, data.current_level, data.level),
DEFAULT_RELATION.currentLevel
),
nextLevel: readNumber(
firstValue(data.nextLevel, data.next_level),
DEFAULT_RELATION.nextLevel
),
currentExp: readNumber(
firstValue(data.currentExp, data.current_exp),
DEFAULT_RELATION.currentExp
),
nextExp: readNumber(
firstValue(data.nextExp, data.next_exp),
DEFAULT_RELATION.nextExp
),
neededExp: readNumber(
firstValue(data.neededExp, data.needed_exp),
DEFAULT_RELATION.neededExp
),
nextExpLevel: readNumber(
firstValue(data.nextExpLevel, data.next_exp_level),
DEFAULT_RELATION.nextExpLevel
),
progressPercent: Math.max(
0,
Math.min(
100,
readNumber(
firstValue(data.progressPercent, data.progress_percent),
DEFAULT_RELATION.progressPercent
)
)
),
me: {
avatar: String(
(data.me && data.me.avatar) || data.me_avatar || ''
),
},
partner: {
avatar: String(
(data.partner && data.partner.avatar) ||
data.partner_avatar ||
''
),
},
};
}
function renderCopy() {
var data = state.data;
daysValue.textContent = formatNumber(data.days);
expAway.textContent = t(
'cpSpace.expAway',
'{exp} Exp away from Lv. {level}'
)
.replace('{exp}', formatNumber(data.neededExp))
.replace('{level}', formatNumber(data.nextExpLevel));
currentLevel.textContent = t(
'cpSpace.levelValue',
'Lv.{level}'
).replace('{level}', formatNumber(data.currentLevel));
nextLevel.textContent = t('cpSpace.levelValue', 'Lv.{level}').replace(
'{level}',
formatNumber(data.nextLevel)
);
progressText.textContent = [
formatNumber(data.currentExp),
formatNumber(data.nextExp),
].join('/');
timeText.textContent = t(
'cpSpace.timeTogether',
'Time spent together: {days} days'
).replace('{days}', formatNumber(data.togetherDays));
progressFill.style.width =
Math.max(1, Math.min(233, (data.progressPercent / 100) * 233)) +
'px';
renderAvatar(leftAvatar, data.me.avatar);
renderAvatar(rightAvatar, data.partner.avatar);
}
function setActiveTab(tab, options) {
var nextTab = normalizeTab(tab) || 'cp';
var theme = THEMES[nextTab];
state.activeTab = nextTab;
page.setAttribute('data-tab', nextTab);
setImage(bgLayer, theme.bg);
setImage(tabJewel, theme.tab);
setImage(cardSkin, theme.card);
setImage(centerArt, theme.center);
levelBgs.forEach(function (node) {
setImage(node, theme.level);
});
tabButtons.forEach(function (button) {
var selected = button.getAttribute('data-tab') === nextTab;
button.setAttribute('aria-selected', selected ? 'true' : 'false');
});
renderCopy();
if (!options || options.syncURL !== false) syncURL(nextTab);
}
function applyExternalData(payload) {
var data = payload || {};
var tabData = data[state.activeTab] || data.relation || data;
state.data = normalizedRelation(tabData);
state.tabData[state.activeTab] = state.data;
renderCopy();
}
function showTabData(tab) {
state.data = clone(state.tabData[tab] || DEFAULT_RELATION);
renderCopy();
}
function loadTabData(tab) {
var targetTab = normalizeTab(tab) || 'cp';
if (!window.YumiCommon || !window.YumiCommon.cpSpace) {
return Promise.resolve();
}
var seq = (state.requestSeq += 1);
// 数据由 Java 的 CP 关系接口按 relationType 拉取seq 防止用户快速切 tab 时旧响应覆盖新主题。
return window.YumiCommon.cpSpace
.loadRelation({
tab: targetTab,
userId: readParam('userId') || readParam('user_id') || '',
cpUserId:
readParam('cpUserId') || readParam('cp_user_id') || '',
})
.then(function (data) {
if (seq !== state.requestSeq) return;
state.tabData[targetTab] = normalizedRelation(data);
if (state.activeTab === targetTab) showTabData(targetTab);
})
.catch(function (error) {
if (seq !== state.requestSeq) return;
// WebView 首屏不能因为 token、网关或关系为空白屏失败保留当前可交互页面问题交给日志定位。
if (window.console && console.warn) {
console.warn('Yumi CP relation load failed:', error);
}
});
}
function bindEvents() {
tabButtons.forEach(function (button) {
button.addEventListener('click', function () {
var tab = button.getAttribute('data-tab');
setActiveTab(tab);
showTabData(state.activeTab);
loadTabData(state.activeTab);
});
});
if (backButton) {
backButton.addEventListener('click', function () {
if (window.YumiCommon && window.YumiCommon.back) {
window.YumiCommon.back();
return;
}
if (window.HyAppBridge && window.HyAppBridge.back) {
window.HyAppBridge.back();
return;
}
// 无客户端 bridge 的浏览器验收环境仍需保留真实返回热区。
window.history.back();
});
}
if (helpButton) {
helpButton.addEventListener('click', function () {
if (window.HyAppBridge && window.HyAppBridge.post) {
window.HyAppBridge.post('cpSpaceHelpTap', {
page: 'yumi-cp-space',
});
}
});
}
window.addEventListener('hyapp:i18n-ready', renderCopy);
window.addEventListener('hyapp:cp-space-yumi-data', function (event) {
applyExternalData(event.detail || {});
});
}
bindStageScale();
bindEvents();
if (window.HyAppCPSpaceYumiData)
applyExternalData(window.HyAppCPSpaceYumiData);
setActiveTab(state.activeTab, { syncURL: false });
showTabData(state.activeTab);
loadTabData(state.activeTab);
if (window.HyAppBridge && window.HyAppBridge.ready) {
// ready 只声明页面能力;真实关系数据由 YumiCommon 接口层拉取,客户端仍可通过事件覆盖。
window.HyAppBridge.ready({ page: 'yumi-cp-space' });
}
})();

141
app/da_fami.html Normal file
View File

@ -0,0 +1,141 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delete Fami Voice Party Account | Fami Voice Party</title>
<meta name="description" content="This page explains how Fami Voice Party users can request permanent deletion of their account and associated data.">
<link rel="canonical" href="https://h5.global-interaction.com/app/da_fami.html">
<link rel="stylesheet" href="../common/theme.css">
<style>
html,
body {
margin: 0;
min-height: 100%;
background: var(--hy-theme-bg);
color: var(--hy-theme-text);
font-family: Arial, Helvetica, sans-serif;
}
body {
padding: 32px 16px 48px;
}
.page {
width: min(820px, 100%);
margin: 0 auto;
box-sizing: border-box;
padding: 28px;
border: 1px solid var(--hy-theme-line);
border-radius: 8px;
background: var(--hy-theme-surface);
box-shadow: var(--hy-theme-shadow);
}
h1,
h2 {
color: var(--hy-theme-primary-deep);
letter-spacing: 0;
}
h1 {
margin: 0 0 14px;
font-size: 30px;
line-height: 1.22;
}
h2 {
margin: 28px 0 10px;
font-size: 20px;
line-height: 1.35;
}
p,
li {
font-size: 15px;
line-height: 1.72;
}
p {
margin: 10px 0;
}
ul {
margin: 10px 0 0;
padding-left: 22px;
}
a {
color: var(--hy-theme-button);
font-weight: 700;
}
.meta {
color: var(--hy-theme-muted);
}
.action-link {
display: inline-block;
margin: 8px 0 6px;
padding: 10px 14px;
border-radius: 8px;
background: var(--hy-theme-button);
color: var(--hy-theme-surface);
text-decoration: none;
box-shadow: var(--hy-theme-button-shadow);
}
@media (max-width: 560px) {
body {
padding: 20px 12px 36px;
}
.page {
padding: 22px 18px;
}
h1 {
font-size: 25px;
}
}
</style>
</head>
<body>
<div class="page">
<h1>Delete Fami Voice Party Account</h1>
<p class="meta">Effective date: July 7, 2026</p>
<p class="meta">Contact: karchermary201@gmail.com</p>
<p><a class="action-link" href="mailto:karchermary201@gmail.com?subject=Delete%20Fami%20Voice%20Party%20Account">Start a deletion request by email</a></p>
<p>This page explains how Fami Voice Party users can request permanent deletion of their account and associated data.</p>
<h2>1. Who can use this page</h2>
<p>Use this page if you created a Fami Voice Party account and want to request permanent deletion of that account and the associated user data managed by Fami Voice Party.</p>
<p>You can submit a deletion request even if you no longer have access to the Fami Voice Party app on your device.</p>
<h2>2. How to submit a deletion request</h2>
<p>Send your deletion request by email to <a href="mailto:karchermary201@gmail.com">karchermary201@gmail.com</a>. Please use the subject line: Delete Fami Voice Party Account.</p>
<p>To help us find and verify the correct account, include the following information when available:</p>
<ul>
<li>Your Fami Voice Party UID.</li>
<li>Your registered email address or phone number.</li>
<li>Your Fami Voice Party nickname or profile name.</li>
<li>A clear statement that you want your Fami Voice Party account and associated data to be deleted.</li>
</ul>
<h2>3. Identity verification</h2>
<p>To protect account security and prevent unauthorized deletion, we may ask you to complete a reasonable identity-verification step before processing your request.</p>
<h2>4. What will be deleted</h2>
<p>After your request is verified and approved, we will delete your Fami Voice Party account and the associated account data that is managed in our systems, including profile information, app activity records linked to the account, and other user data linked to that account, unless retention is required for the reasons described below.</p>
<h2>5. Data that may be retained</h2>
<p>Certain records may be retained for a limited period where reasonably necessary for fraud prevention, abuse handling, dispute resolution, financial recordkeeping, tax, security, or legal and regulatory compliance.</p>
<h2>6. Processing time</h2>
<p>We aim to complete verified deletion requests within 7 business days, although certain cases may require additional time if further verification or compliance review is necessary.</p>
<h2>7. Contact</h2>
<p>If you have questions about account deletion or personal-data handling, contact <a href="mailto:karchermary201@gmail.com">karchermary201@gmail.com</a>.</p>
</div>
</body>
</html>

140
app/fami_privacy.html Normal file
View File

@ -0,0 +1,140 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Privacy Policy | Fami Voice Party</title>
<meta name="description" content="Privacy Policy for Fami Voice Party. Learn what information we collect, how we use it, and how to contact us about privacy requests.">
<link rel="canonical" href="https://h5.global-interaction.com/app/fami_privacy.html">
<link rel="stylesheet" href="../common/theme.css">
<style>
html,
body {
margin: 0;
min-height: 100%;
background: var(--hy-theme-bg);
color: var(--hy-theme-text);
font-family: Arial, Helvetica, sans-serif;
}
body {
padding: 32px 16px 48px;
}
.page {
width: min(820px, 100%);
margin: 0 auto;
box-sizing: border-box;
padding: 28px;
border: 1px solid var(--hy-theme-line);
border-radius: 8px;
background: var(--hy-theme-surface);
box-shadow: var(--hy-theme-shadow);
}
h1,
h2 {
color: var(--hy-theme-primary-deep);
letter-spacing: 0;
}
h1 {
margin: 0 0 14px;
font-size: 30px;
line-height: 1.22;
}
h2 {
margin: 28px 0 10px;
font-size: 20px;
line-height: 1.35;
}
p,
li {
font-size: 15px;
line-height: 1.72;
}
p {
margin: 10px 0;
}
ul {
margin: 10px 0 0;
padding-left: 22px;
}
a {
color: var(--hy-theme-button);
font-weight: 700;
}
.meta {
color: var(--hy-theme-muted);
}
@media (max-width: 560px) {
body {
padding: 20px 12px 36px;
}
.page {
padding: 22px 18px;
}
h1 {
font-size: 25px;
}
}
</style>
</head>
<body>
<div class="page">
<h1>Privacy Policy for Fami Voice Party</h1>
<p>Fami Voice Party values your privacy. This Privacy Policy explains what information we collect, how we use it, when we share it, and the choices you have when you use our app and related services. Please read it carefully before using Fami Voice Party.</p>
<h2>1. Information We Collect</h2>
<p>We collect information you provide directly, such as your nickname, avatar, gender, birthday, country or region, invitation code, profile content, messages, voice-room activity, follows, friend interactions, check-ins, customer support requests, and account deletion requests.</p>
<p>We also collect technical and usage information needed to operate the service, including device identifiers, device model, operating system, app version, language, country or region, network status, login method, channel or install source, crash logs, analytics events, room impressions and clicks, room join results, room stay milestones, microphone actions, message sending actions, and push-notification permission status.</p>
<p>When you join a voice room or use microphone features, your voice is transmitted to other participants in real time. We may process related technical metadata, room activity, and safety records to operate, secure, troubleshoot, and moderate the service.</p>
<p>If you grant device permissions, we may access the relevant feature only for the requested purpose, such as microphone access for voice rooms, photo access for avatar or image uploads, and notification permission for service messages and reminders. You can manage permissions in your device settings.</p>
<h2>2. How We Use Information</h2>
<p>We use information to create and secure your account, provide voice-room, messaging, social, profile, check-in, payment-related, and customer support features, personalize content and recommendations, maintain service quality, prevent fraud and abuse, troubleshoot errors, measure product performance, and comply with legal obligations.</p>
<p>We may use analytics information to understand onboarding, login, profile completion, room discovery, room joining, room stay, messaging, microphone, follow, friend, check-in, and push-permission funnels so that we can improve the product experience.</p>
<h2>3. How We Share Information</h2>
<p>We do not sell your personal information. We may share information with service providers that help us operate the app, such as cloud hosting, analytics, customer support, payment processing, messaging, voice, safety, and notification services. These providers are allowed to use the information only to provide services to us.</p>
<p>We may also disclose information if required by law, to protect the rights and safety of users or the public, to investigate fraud, abuse, or security incidents, or as part of a merger, acquisition, financing, or transfer of business assets.</p>
<h2>4. Data Retention and Deletion</h2>
<p>We keep information for as long as necessary to provide the service, maintain security, resolve disputes, meet legal requirements, and enforce our terms. Retention periods may vary depending on the type of information and the reason it was collected.</p>
<p>You may request access, correction, or deletion of your personal information by contacting us. You may also request account deletion through the account deletion page or by email. Some information may be retained when required by law, for safety, fraud prevention, dispute resolution, or legitimate business records.</p>
<h2>5. Your Choices and Rights</h2>
<p>You can update certain profile information in the app, control device permissions in your device settings, and unsubscribe from non-essential notifications where supported. Depending on your location, you may also have the right to access, correct, delete, export, object to, or restrict certain processing of your personal information. To exercise these rights, contact us using the email below.</p>
<h2>6. Security</h2>
<p>We use administrative, technical, and organizational safeguards designed to protect personal information against unauthorized access, loss, misuse, alteration, and disclosure. However, no internet or electronic storage system can be guaranteed to be completely secure.</p>
<h2>7. Third-Party Links and Services</h2>
<p>Our app may contain links to third-party websites or services. We are not responsible for the privacy practices or content of these external websites. Please review their privacy policies before engaging with any third-party sites.</p>
<h2>8. Children</h2>
<p>Fami Voice Party is not intended for children under the age required by applicable law. If we learn that we have collected personal information from a child without appropriate consent, we will take reasonable steps to delete it.</p>
<h2>9. International Use</h2>
<p>Your information may be processed in countries or regions where we or our service providers operate. We take steps designed to protect your information in accordance with this Privacy Policy and applicable law.</p>
<h2>10. Updates to This Privacy Policy</h2>
<p>We may update this Privacy Policy from time to time to reflect changes in our service, business practices, or legal requirements. The updated policy will be posted on this page. Please review this page periodically for changes.</p>
<p class="meta">Last updated: July 7, 2026</p>
<h2>11. Contact Us</h2>
<p>If you have questions, concerns, or requests about this Privacy Policy or your personal information, please contact us at:</p>
<p>Email: <a href="mailto:karchermary201@gmail.com">karchermary201@gmail.com</a></p>
<p>We will review your request and respond within a reasonable time as required by applicable law.</p>
</div>
</body>
</html>

View File

@ -7,8 +7,8 @@
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/> />
<title>Recharge Reward</title> <title>Recharge Reward</title>
<link rel="stylesheet" href="../common/theme.css?v=20260707" /> <link rel="stylesheet" href="../../common/theme.css?v=20260709" />
<link rel="stylesheet" href="./style.css?v=20260707-modules" /> <link rel="stylesheet" href="./style.css?v=20260709-api" />
</head> </head>
<body> <body>
<div class="app-viewport" id="appViewport"> <div class="app-viewport" id="appViewport">
@ -18,7 +18,11 @@
aria-label="Recharge Reward activity" aria-label="Recharge Reward activity"
data-i18n-aria="rechargeReward.pageLabel" data-i18n-aria="rechargeReward.pageLabel"
> >
<img class="bg bg-top" src="./assets/recharge-activity/bg-top.png" alt="" /> <img
class="bg bg-top"
src="./assets/recharge-activity/bg-top.png"
alt=""
/>
<img <img
class="bg bg-mid bg-mid-a" class="bg bg-mid bg-mid-a"
src="./assets/recharge-activity/bg-mid-a.png" src="./assets/recharge-activity/bg-mid-a.png"
@ -56,17 +60,44 @@
src="./assets/recharge-activity/countdown-frame.png" src="./assets/recharge-activity/countdown-frame.png"
alt="" alt=""
/> />
<strong class="count-number" data-count="days">01</strong> <strong class="count-number" data-count="days"
<strong class="count-number" data-count="hours">01</strong> >01</strong
<strong class="count-number" data-count="minutes">01</strong> >
<strong class="count-number" data-count="seconds">01</strong> <strong class="count-number" data-count="hours"
<span class="count-label" data-i18n="rechargeReward.days">Days</span> >01</strong
<span class="count-label" data-i18n="rechargeReward.hours">Hours</span> >
<span class="count-label" data-i18n="rechargeReward.minutes">Minutes</span> <strong class="count-number" data-count="minutes"
<span class="count-label" data-i18n="rechargeReward.seconds">Seconds</span> >01</strong
>
<strong class="count-number" data-count="seconds"
>01</strong
>
<span
class="count-label"
data-i18n="rechargeReward.days"
>Days</span
>
<span
class="count-label"
data-i18n="rechargeReward.hours"
>Hours</span
>
<span
class="count-label"
data-i18n="rechargeReward.minutes"
>Minutes</span
>
<span
class="count-label"
data-i18n="rechargeReward.seconds"
>Seconds</span
>
</section> </section>
<section class="profile-card" aria-label="My recharge status"> <section
class="profile-card"
aria-label="My recharge status"
>
<img <img
class="profile-frame" class="profile-frame"
src="./assets/recharge-activity/profile-frame.png" src="./assets/recharge-activity/profile-frame.png"
@ -78,7 +109,9 @@
alt="" alt=""
data-profile-avatar data-profile-avatar
/> />
<h1 class="profile-name" data-profile-name>nanfangjian</h1> <h1 class="profile-name" data-profile-name>
nanfangjian
</h1>
<p class="profile-id" data-profile-id>ID: 12345678</p> <p class="profile-id" data-profile-id>ID: 12345678</p>
<p class="profile-recharge" data-profile-recharge> <p class="profile-recharge" data-profile-recharge>
Accumlated Recharge:5998 Accumlated Recharge:5998
@ -92,7 +125,9 @@
<script> <script>
document.documentElement.dataset.hyAppCode = 'aslan'; document.documentElement.dataset.hyAppCode = 'aslan';
</script> </script>
<script src="../common/i18n.js?v=20260707"></script> <script src="../../common/i18n.js?v=20260709"></script>
<script src="./script.js?v=20260707-modules"></script> <script src="../../common/toast.js?v=20260709"></script>
<script src="../../common_aslan/aslan-common.js?v=20260709"></script>
<script src="./script.js?v=20260709-api"></script>
</body> </body>
</html> </html>

View File

@ -1,7 +1,10 @@
(function () { (function () {
var ASSET_BASE = './assets/recharge-activity/'; var ASSET_BASE = './assets/recharge-activity/';
var viewport = document.getElementById('appViewport'); var viewport = document.getElementById('appViewport');
var page = document.getElementById('page');
var stage = document.querySelector('.stage');
var rewardSections = document.getElementById('rewardSections'); var rewardSections = document.getElementById('rewardSections');
var bgBottom = document.querySelector('.bg-bottom');
var countNodes = { var countNodes = {
days: document.querySelector('[data-count="days"]'), days: document.querySelector('[data-count="days"]'),
hours: document.querySelector('[data-count="hours"]'), hours: document.querySelector('[data-count="hours"]'),
@ -10,40 +13,38 @@
}; };
var DESIGN_WIDTH = 1080; var DESIGN_WIDTH = 1080;
var DESIGN_HEIGHT = 3273;
var VIEWPORT_WIDTH = 375; var VIEWPORT_WIDTH = 375;
var MAX_PAGE_WIDTH = 430;
var DEFAULT_DESIGN_HEIGHT = 3273;
var designHeight = DEFAULT_DESIGN_HEIGHT;
var countdownEndAt = resolveCountdownEndAt(); var countdownEndAt = resolveCountdownEndAt();
var activityData = { var countdownTimer = 0;
var state = {
user: { user: {
name: 'nanfangjian', name: '',
id: '12345678', id: '',
avatar: ASSET_BASE + 'profile-avatar.png', avatar: ASSET_BASE + 'profile-avatar.png',
accumulatedRecharge: '5998', accumulatedRecharge: 0,
}, },
rewards: [ rewards: [],
{ claiming: {},
amount: '$100', loading: true,
top: 1776, error: '',
background: ASSET_BASE + 'reward-section-a.png',
claim: { text: 'Receive', claimable: true, claimed: false },
cards: [
{ price: '$100', icon: ASSET_BASE + 'reward-icon.png', left: 111, top: 293 },
{ price: '$100', icon: ASSET_BASE + 'reward-icon.png', left: 560, top: 293 },
],
},
{
amount: '$100',
top: 2466,
background: ASSET_BASE + 'reward-section-b.png',
claim: { text: 'Receive', claimable: true, claimed: false },
cards: [
{ price: '$100', icon: ASSET_BASE + 'reward-icon.png', left: 70.5, top: 278 },
{ price: '$100', icon: ASSET_BASE + 'reward-icon.png', left: 339.5, top: 278 },
{ price: '$100', icon: ASSET_BASE + 'reward-icon.png', left: 608.5, top: 278 },
],
},
],
}; };
var fallbackCards = [
{
price: '$100',
icon: ASSET_BASE + 'reward-icon.png',
left: 111,
top: 293,
},
{
price: '$100',
icon: ASSET_BASE + 'reward-icon.png',
left: 560,
top: 293,
},
];
function getParams() { function getParams() {
return new URLSearchParams(window.location.search || ''); return new URLSearchParams(window.location.search || '');
@ -68,26 +69,133 @@
return String(Math.max(0, value)).padStart(2, '0'); return String(Math.max(0, value)).padStart(2, '0');
} }
function resolveCountdownEndAt() { function showToast(message) {
if (window.HyAppToast && window.HyAppToast.show) {
window.HyAppToast.show(message);
return;
}
window.dispatchEvent(
new CustomEvent('hyapp:toast', { detail: { message: message } })
);
}
function numeric(value, fallback) {
var number = Number(value);
return Number.isFinite(number) ? number : fallback || 0;
}
function parseJSON(value) {
if (!value || typeof value !== 'string') return null;
try {
return JSON.parse(value);
} catch (_) {
return null;
}
}
function firstValue() {
for (var i = 0; i < arguments.length; i += 1) {
var value = arguments[i];
if (value !== undefined && value !== null && value !== '')
return value;
}
return '';
}
function formatUSD(value) {
var amount = numeric(value, 0);
return (
'$' +
amount.toLocaleString('en-US', {
maximumFractionDigits: 2,
minimumFractionDigits: amount % 1 === 0 ? 0 : 2,
})
);
}
function monthlyResetEndAt() {
var now = new Date();
return new Date(
now.getFullYear(),
now.getMonth() + 1,
1,
0,
0,
0,
0
).getTime();
}
function activityTimeEndAt(activityTime) {
var matches = String(activityTime || '').match(
/(\d{1,2})\s*\/\s*(\d{1,2})\s*$/
);
if (!matches) return 0;
var now = new Date();
var day = Number(matches[1]);
var month = Number(matches[2]);
if (!Number.isFinite(day) || !Number.isFinite(month)) return 0;
// 后端月累充返回的 activityTime 是 DD/MM - DD/MM不带年份结束日次日 00:00 就是月度清空点。
var endAt = new Date(
now.getFullYear(),
month - 1,
day + 1,
0,
0,
0,
0
).getTime();
return Number.isFinite(endAt) && endAt > Date.now() ? endAt : 0;
}
function resolveCountdownEndAt(statusData) {
var params = getParams(); var params = getParams();
var explicitEnd = Date.parse(params.get('end_at') || params.get('endAt') || ''); var explicitEnd = Date.parse(
if (Number.isFinite(explicitEnd) && explicitEnd > Date.now()) return explicitEnd; params.get('end_at') || params.get('endAt') || ''
);
if (Number.isFinite(explicitEnd) && explicitEnd > Date.now())
return explicitEnd;
var hours = Number(params.get('hours')); var hours = Number(params.get('hours'));
if (Number.isFinite(hours) && hours > 0) return Date.now() + hours * 60 * 60 * 1000; if (Number.isFinite(hours) && hours > 0)
return Date.now() + hours * 60 * 60 * 1000;
// This page is intentionally interface-free for now; when an API is added, return (
// use the server period end time here and do not recover failed requests with mock data. activityTimeEndAt(statusData && statusData.activityTime) ||
return Date.now() + 25 * 60 * 60 * 1000 + 61 * 1000; monthlyResetEndAt()
);
}
function getPageWidth() {
var viewportWidth =
window.innerWidth ||
document.documentElement.clientWidth ||
VIEWPORT_WIDTH;
return Math.min(Math.max(viewportWidth, 1), MAX_PAGE_WIDTH);
} }
function updateScale() { function updateScale() {
var scale = Math.min(window.innerWidth / VIEWPORT_WIDTH, 1); var pageWidth = getPageWidth();
var scaledHeight = Math.ceil(DESIGN_HEIGHT * (VIEWPORT_WIDTH / DESIGN_WIDTH)); var designScale = pageWidth / DESIGN_WIDTH;
var scaledHeight = Math.ceil(designHeight * designScale);
document.documentElement.style.setProperty('--app-scale', String(scale)); // 视觉稿仍以 1080 坐标布局,只把整张背景和内容按真实 WebView 宽度等比缩放,避免 412px 等机型露出页面两侧空白。
document.documentElement.style.setProperty(
'--page-width',
pageWidth + 'px'
);
document.documentElement.style.setProperty(
'--design-scale',
String(designScale)
);
viewport.style.minHeight = scaledHeight + 'px'; viewport.style.minHeight = scaledHeight + 'px';
document.body.style.minHeight = Math.ceil(scaledHeight * scale) + 'px'; viewport.style.height = scaledHeight + 'px';
page.style.minHeight = scaledHeight + 'px';
page.style.height = scaledHeight + 'px';
document.body.style.minHeight =
Math.max(scaledHeight, window.innerHeight || 0) + 'px';
} }
function updateCountdown() { function updateCountdown() {
@ -104,14 +212,175 @@
countNodes.seconds.textContent = twoDigits(seconds); countNodes.seconds.textContent = twoDigits(seconds);
} }
function normalizeUser(profile, statusData) {
profile = profile || {};
var params = getParams();
return {
name: firstValue(
profile.userNickname,
profile.username,
profile.name,
params.get('name'),
'Aslan'
),
id: String(
firstValue(
profile.specialId,
profile.displayUserId,
profile.display_user_id,
profile.account,
profile.id,
profile.userId,
params.get('uid'),
params.get('id'),
''
)
),
avatar: firstValue(
profile.userAvatar,
profile.avatar,
profile.headImg,
profile.headImage,
params.get('avatar'),
ASSET_BASE + 'profile-avatar.png'
),
accumulatedRecharge: firstValue(
statusData && statusData.monthlyRecharge,
params.get('recharge'),
0
),
};
}
function rewardProps(resource) {
var group =
resource && resource.activityResource
? resource.activityResource.propsGroup || {}
: {};
return Array.isArray(group.activityRewardProps)
? group.activityRewardProps
: [];
}
function rule(resource) {
return resource && resource.activityResource
? resource.activityResource.rule || {}
: {};
}
function thresholdAmount(resource) {
var data = parseJSON(rule(resource).jsonData) || {};
return numeric(data.quantity, 0);
}
function cardLayout(count, index) {
if (count >= 4) {
return (
[
{ left: 70.5, top: 278 },
{ left: 292.5, top: 278 },
{ left: 514.5, top: 278 },
{ left: 736.5, top: 278 },
][index] || { left: 736.5, top: 278 }
);
}
if (count === 3) {
return (
[
{ left: 70.5, top: 278 },
{ left: 339.5, top: 278 },
{ left: 608.5, top: 278 },
][index] || { left: 608.5, top: 278 }
);
}
return (
[
{ left: 111, top: 293 },
{ left: 560, top: 293 },
][index] || { left: 560, top: 293 }
);
}
function normalizeCard(item, threshold, count, index) {
var pos = cardLayout(count, index);
return {
price:
item && item.amount
? formatUSD(item.amount)
: formatUSD(threshold),
icon: (item && item.cover) || ASSET_BASE + 'reward-icon.png',
left: pos.left,
top: pos.top,
};
}
function normalizeReward(resource, index) {
var currentRule = rule(resource);
var ruleId = String(currentRule.id || resource.ruleId || '');
var threshold = thresholdAmount(resource);
var props = rewardProps(resource).slice(0, 4);
var receiveStatus = String(resource.receiveStatus || '').toUpperCase();
var claimed = receiveStatus === 'RECEIVED';
var claimable = receiveStatus === 'NOT_RECEIVED';
var cardCount = props.length || fallbackCards.length;
return {
ruleId: ruleId,
amount: formatUSD(threshold),
top: 1776 + index * 690,
background:
index % 2 === 0
? ASSET_BASE + 'reward-section-a.png'
: ASSET_BASE + 'reward-section-b.png',
claim: {
text: claimed
? t('rechargeReward.claimed', 'Claimed')
: t('rechargeReward.claim', 'Claim'),
claimable: claimable,
claimed: claimed,
loading: Boolean(state.claiming[ruleId]),
},
cards: props.length
? props.map(function (item, cardIndex) {
return normalizeCard(
item,
threshold,
cardCount,
cardIndex
);
})
: fallbackCards.map(function (card) {
return Object.assign({}, card, {
price: formatUSD(threshold),
});
}),
};
}
function normalizeRewards(statusData) {
var resources =
statusData && Array.isArray(statusData.activityResources)
? statusData.activityResources
: [];
return resources
.map(function (item, index) {
return normalizeReward(item, index);
})
.filter(function (item) {
return item.ruleId;
});
}
function renderProfile(user) { function renderProfile(user) {
document.querySelector('[data-profile-avatar]').src = user.avatar; document.querySelector('[data-profile-avatar]').src =
document.querySelector('[data-profile-name]').textContent = user.name; user.avatar || ASSET_BASE + 'profile-avatar.png';
document.querySelector('[data-profile-name]').textContent =
user.name || 'Aslan';
document.querySelector('[data-profile-id]').textContent = document.querySelector('[data-profile-id]').textContent =
t('rechargeReward.idPrefix', 'ID:') + ' ' + user.id; t('rechargeReward.idPrefix', 'ID:') + ' ' + (user.id || '-');
document.querySelector('[data-profile-recharge]').textContent = document.querySelector('[data-profile-recharge]').textContent =
t('rechargeReward.accumulatedRecharge', 'Accumlated Recharge:') + t('rechargeReward.accumulatedRecharge', 'Accumulated Recharge:') +
user.accumulatedRecharge; formatUSD(user.accumulatedRecharge || 0);
} }
function renderRewardCard(card) { function renderRewardCard(card) {
@ -135,31 +404,65 @@
].join(''); ].join('');
} }
function renderClaimButton(claim) { function renderClaimButton(section) {
var stateClass = claim.claimed ? ' is-claimed' : claim.claimable ? ' is-claimable' : ' is-disabled'; var claim = section.claim || {};
var stateClass = claim.claimed
? ' is-claimed'
: claim.claimable
? ' is-claimable'
: ' is-disabled';
var disabled = !claim.claimable || claim.claimed || claim.loading;
return [ return [
'<button class="claim-button', '<button class="claim-button',
stateClass, stateClass,
'" type="button"', '" type="button" data-rule-id="',
claim.claimable && !claim.claimed ? '' : ' disabled', escapeHTML(section.ruleId),
'"',
disabled ? ' disabled' : '',
'>', '>',
'<img class="claim-button-bg" src="', '<img class="claim-button-bg" src="',
ASSET_BASE, ASSET_BASE,
'receive-button-bg.png" alt="" />', 'receive-button-bg.png" alt="" />',
'<span>', '<span>',
escapeHTML(claim.text || 'Receive'), escapeHTML(
claim.loading
? t('rechargeReward.claiming', 'Claiming...')
: claim.text || t('rechargeReward.claim', 'Claim')
),
'</span>', '</span>',
'</button>', '</button>',
].join(''); ].join('');
} }
function renderRewards(rewards) { function renderRewards(rewards) {
if (state.loading) {
rewardSections.innerHTML =
'<p class="reward-message">' +
escapeHTML(t('rechargeReward.loading', 'Loading...')) +
'</p>';
return;
}
if (state.error) {
rewardSections.innerHTML =
'<p class="reward-message is-error">' +
escapeHTML(state.error) +
'</p>';
return;
}
if (!rewards.length) {
rewardSections.innerHTML =
'<p class="reward-message">' +
escapeHTML(t('rechargeReward.noRewards', 'No rewards yet')) +
'</p>';
return;
}
rewardSections.innerHTML = rewards rewardSections.innerHTML = rewards
.map(function (section) { .map(function (section) {
var title = t('rechargeReward.rechargeTitle', 'Recharge {amount}').replace( var title = t(
'{amount}', 'rechargeReward.rechargeTitle',
section.amount 'Recharge {amount}'
); ).replace('{amount}', section.amount);
return [ return [
'<section class="reward-section" style="--section-top:', '<section class="reward-section" style="--section-top:',
section.top, section.top,
@ -177,27 +480,175 @@
return renderRewardCard(card); return renderRewardCard(card);
}) })
.join(''), .join(''),
renderClaimButton(section.claim || { text: 'Receive', claimable: false }), renderClaimButton(section),
'</section>', '</section>',
].join(''); ].join('');
}) })
.join(''); .join('');
} }
function updateDesignHeight() {
var rewardCount = Math.max(state.rewards.length, 2);
designHeight = Math.max(
DEFAULT_DESIGN_HEIGHT,
1776 + rewardCount * 690 + 120
);
stage.style.height = designHeight + 'px';
if (bgBottom)
bgBottom.style.top = Math.max(2695, designHeight - 578) + 'px';
updateScale();
}
function renderPage() { function renderPage() {
document.title = t('rechargeReward.pageTitle', 'Recharge Reward'); document.title = t('rechargeReward.pageTitle', 'Recharge Reward');
renderProfile(activityData.user); renderProfile(state.user);
renderRewards(activityData.rewards); updateDesignHeight();
renderRewards(state.rewards);
}
function mockData() {
var params = getParams();
var claimed = params.get('claimed') === '1';
return Promise.resolve({
profile: {
userNickname: params.get('name') || 'Aslan',
account: params.get('uid') || '12345678',
userAvatar:
params.get('avatar') || ASSET_BASE + 'profile-avatar.png',
},
status: {
monthlyRecharge: numeric(params.get('recharge'), 5998),
activityResources: [
{
receiveStatus: claimed ? 'RECEIVED' : 'NOT_RECEIVED',
activityResource: {
rule: {
id: '1000000000000000001',
jsonData: '{"quantity":100}',
},
propsGroup: { activityRewardProps: [] },
},
},
{
receiveStatus: 'INELIGIBLE',
activityResource: {
rule: {
id: '1000000000000000002',
jsonData: '{"quantity":200}',
},
propsGroup: { activityRewardProps: [] },
},
},
],
},
});
}
function loadRemoteData() {
if (!window.AslanCommon) {
return Promise.reject(new Error('Aslan bridge unavailable'));
}
return Promise.all([
window.AslanCommon.user.profile().catch(function (error) {
console.warn('Aslan profile failed', error);
return null;
}),
window.AslanCommon.rechargeReward.status(),
]).then(function (result) {
return { profile: result[0], status: result[1] };
});
}
function loadData() {
state.loading = true;
state.error = '';
renderPage();
var params = getParams();
var loader =
params.get('mock') === '1' || params.get('mock') === 'true'
? mockData()
: loadRemoteData();
return loader
.then(function (payload) {
var statusData = payload.status || {};
countdownEndAt = resolveCountdownEndAt(statusData);
updateCountdown();
state.user = normalizeUser(payload.profile, statusData);
state.rewards = normalizeRewards(statusData);
state.loading = false;
state.error = '';
renderPage();
})
.catch(function (error) {
console.warn('Aslan recharge reward load failed', error);
state.loading = false;
state.error =
(error && error.message) ||
t('rechargeReward.loadFailed', 'Load failed');
renderPage();
});
}
function markClaimed(ruleId) {
state.rewards = state.rewards.map(function (item) {
if (item.ruleId !== ruleId) return item;
item.claim.claimed = true;
item.claim.claimable = false;
item.claim.loading = false;
item.claim.text = t('rechargeReward.claimed', 'Claimed');
return item;
});
}
function claimReward(ruleId) {
if (!ruleId || state.claiming[ruleId] || !window.AslanCommon) return;
state.claiming[ruleId] = true;
renderPage();
// 领取必须只提交后端规则 Long 的字符串形式,避免 JS Number 精度损失导致发错档位。
window.AslanCommon.rechargeReward
.claim(ruleId)
.then(function () {
delete state.claiming[ruleId];
markClaimed(ruleId);
renderPage();
showToast(t('rechargeReward.claimSuccess', 'Claimed'));
return loadData();
})
.catch(function (error) {
delete state.claiming[ruleId];
renderPage();
showToast(
(error && error.message) ||
t('rechargeReward.claimFailed', 'Claim failed')
);
});
}
function bindEvents() {
rewardSections.addEventListener('click', function (event) {
var button = event.target.closest('[data-rule-id]');
if (!button || button.disabled) return;
claimReward(button.getAttribute('data-rule-id'));
});
window.addEventListener('resize', updateScale);
window.addEventListener('orientationchange', updateScale);
window.addEventListener('hyapp:i18n-ready', function () {
renderPage();
});
} }
function init() { function init() {
updateScale(); updateScale();
updateCountdown(); updateCountdown();
renderPage(); renderPage();
bindEvents();
loadData();
window.setInterval(updateCountdown, 1000); if (countdownTimer) window.clearInterval(countdownTimer);
window.addEventListener('resize', updateScale); countdownTimer = window.setInterval(updateCountdown, 1000);
window.addEventListener('hyapp:i18n-ready', renderPage);
} }
init(); init();

View File

@ -4,7 +4,7 @@
} }
:root { :root {
--app-scale: 1; --page-width: 375px;
--design-scale: 0.3472222222; --design-scale: 0.3472222222;
} }
@ -14,13 +14,9 @@ body {
width: 100%; width: 100%;
min-height: 100%; min-height: 100%;
overflow-x: hidden; overflow-x: hidden;
background: var(--hy-theme-bg); background: #1b100d;
color: #f6e0b2; color: #f6e0b2;
font-family: font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;
Arial,
"Helvetica Neue",
Helvetica,
sans-serif;
} }
body { body {
@ -29,16 +25,17 @@ body {
.app-viewport { .app-viewport {
position: relative; position: relative;
width: 375px; width: var(--page-width);
max-width: 430px;
min-height: 1137px; min-height: 1137px;
margin: 0 auto; margin: 0 auto;
transform: scale(var(--app-scale)); overflow: hidden;
transform-origin: top center;
} }
.page { .page {
position: relative; position: relative;
width: 375px; width: var(--page-width);
max-width: 430px;
min-height: 1137px; min-height: 1137px;
overflow: hidden; overflow: hidden;
background: #1b100d; background: #1b100d;
@ -138,19 +135,19 @@ body {
0 0 14px rgba(255, 210, 135, 0.58); 0 0 14px rgba(255, 210, 135, 0.58);
} }
.count-number[data-count="days"] { .count-number[data-count='days'] {
left: 97px; left: 97px;
} }
.count-number[data-count="hours"] { .count-number[data-count='hours'] {
left: 301px; left: 301px;
} }
.count-number[data-count="minutes"] { .count-number[data-count='minutes'] {
left: 506px; left: 506px;
} }
.count-number[data-count="seconds"] { .count-number[data-count='seconds'] {
left: 706px; left: 706px;
} }
@ -260,6 +257,33 @@ body {
width: 900px; width: 900px;
} }
.reward-message {
position: absolute;
left: 90px;
top: 1810px;
z-index: 5;
width: 900px;
margin: 0;
padding: 36px 44px;
border: 3px solid rgba(255, 206, 149, 0.4);
border-radius: 28px;
background: rgba(54, 24, 9, 0.76);
color: #f6e0b2;
font-size: 38px;
font-weight: 900;
line-height: 52px;
letter-spacing: 0;
text-align: center;
text-shadow:
0 2px 0 #3a1704,
0 0 12px rgba(255, 207, 113, 0.36);
}
.reward-message.is-error {
border-color: rgba(255, 112, 112, 0.52);
color: #ffd1c2;
}
.title-pill { .title-pill {
position: absolute; position: absolute;
left: 0; left: 0;
@ -366,6 +390,11 @@ body {
opacity: 0.68; opacity: 0.68;
} }
.claim-button.is-claimed,
.claim-button.is-disabled {
filter: grayscale(0.45);
}
.claim-button-bg { .claim-button-bg {
position: absolute; position: absolute;
inset: 0; inset: 0;

View File

@ -153,6 +153,14 @@ var default_api = 'https://api.global-interaction.com/';
return normalizeBaseURL(window.location.origin + path); return normalizeBaseURL(window.location.origin + path);
} }
function localDevProxyBase(env) {
if (!isLocalDevOrigin()) return '';
if (env === 'test') return devProxyBase('/__api_test__/');
if (env === 'local') return devProxyBase('/__api_local__/');
// 本地 H5 调试默认仍访问生产数据,但浏览器只能请求同源代理;由 scripts/h5_dev_server.py 代发到生产 API。
return devProxyBase('/__api_prod__/');
}
function resolveEnv() { function resolveEnv() {
var env = readQuery('env'); var env = readQuery('env');
if (env === 'test' || env === 'local') { if (env === 'test' || env === 'local') {
@ -169,10 +177,9 @@ var default_api = 'https://api.global-interaction.com/';
var env = resolveEnv(); var env = resolveEnv();
var configuredAPI = activeAppAPIBaseURL(env); var configuredAPI = activeAppAPIBaseURL(env);
if (configuredAPI) return configuredAPI; if (configuredAPI) return configuredAPI;
if (env === 'test') var devProxy = localDevProxyBase(env);
return isLocalDevOrigin() if (devProxy) return devProxy;
? devProxyBase('/__api_test__/') if (env === 'test') return normalizeBaseURL(test_api);
: normalizeBaseURL(test_api);
if (env === 'local') return normalizeBaseURL(local_api); if (env === 'local') return normalizeBaseURL(local_api);
return normalizeBaseURL(default_api); return normalizeBaseURL(default_api);
} }
@ -597,6 +604,32 @@ var default_api = 'https://api.global-interaction.com/';
body: payload || {}, body: payload || {},
}); });
}, },
getCoinSellerSubSellerPermission: function () {
return request(
'/api/v1/wallet/coin-seller/sub-sellers/permission',
{
method: 'GET',
}
);
},
inviteCoinSellerSubSeller: function (payload) {
return request('/api/v1/wallet/coin-seller/sub-sellers', {
method: 'POST',
body: payload || {},
});
},
listCoinSellerSubSellers: function (params) {
return request('/api/v1/wallet/coin-seller/sub-sellers', {
method: 'GET',
query: params || {},
});
},
transferCoinSellerToSubSeller: function (payload) {
return request('/api/v1/wallet/coin-seller/sub-sellers/transfer', {
method: 'POST',
body: payload || {},
});
},
}; };
// H5 充值 API 只做路径和参数名封装token、app_code、env、错误 envelope 仍由 request 统一处理。 // H5 充值 API 只做路径和参数名封装token、app_code、env、错误 envelope 仍由 request 统一处理。
@ -1476,15 +1509,85 @@ var default_api = 'https://api.global-interaction.com/';
method: 'GET', method: 'GET',
}); });
}, },
listResources: function (resourceType, pageSize) { listResources: function (resourceType, pageSize, page) {
return request('/api/v1/manager-center/resource-grants/resources', { return request('/api/v1/manager-center/resource-grants/resources', {
method: 'GET', method: 'GET',
query: { query: {
resource_type: resourceType, resource_type: resourceType,
page: page || 1,
page_size: pageSize || 20, page_size: pageSize || 20,
}, },
}); });
}, },
listAllResources: function (resourceType, pageSize) {
var requestedSize = Math.floor(Number(pageSize));
var size =
Number.isFinite(requestedSize) && requestedSize > 0
? Math.min(requestedSize, 20)
: 20;
// Gateway 单页最多返回 20 条;经理中心必须根据 total 拉完剩余分页,否则较早创建但已开启赠送的资源会永久被截断。
return managerCenterAPI
.listResources(resourceType, size, 1)
.then(function (firstPage) {
var firstItems = (firstPage && firstPage.items) || [];
var total = Number(firstPage && firstPage.total);
if (!Number.isFinite(total) || total < firstItems.length) {
total = firstItems.length;
}
var pageCount = Math.max(1, Math.ceil(total / size));
if (pageCount === 1) return firstPage || { items: [] };
var remainingRequests = [];
for (var page = 2; page <= pageCount; page += 1) {
remainingRequests.push(
managerCenterAPI.listResources(
resourceType,
size,
page
)
);
}
return Promise.all(remainingRequests).then(
function (remainingPages) {
var resources = [];
var seenResourceIDs = Object.create(null);
[firstPage]
.concat(remainingPages)
.forEach(function (pageData) {
(
(pageData && pageData.items) ||
[]
).forEach(function (resource) {
var resourceID =
resource &&
(resource.resource_id ||
resource.resourceId);
// 分页期间后台可能调整排序;按资源 ID 去重可避免同一素材在两个相邻页重复展示。
if (
resourceID &&
seenResourceIDs[String(resourceID)]
) {
return;
}
if (resourceID) {
seenResourceIDs[
String(resourceID)
] = true;
}
resources.push(resource);
});
});
return Object.assign({}, firstPage || {}, {
items: resources,
page: 1,
page_size: size,
total: Math.max(total, resources.length),
});
}
);
});
},
grantResource: function (payload) { grantResource: function (payload) {
return request('/api/v1/manager-center/resource-grants', { return request('/api/v1/manager-center/resource-grants', {
method: 'POST', method: 'POST',

104
common/loading.js Normal file
View File

@ -0,0 +1,104 @@
(function () {
// 通用全屏 loading拉鲁猫吉祥物 + 转圈)。
// 样式复用 common/theme.css 的 .hy-loading 系列类:新页面按仓库约定必须引入 theme.css
// 组件自身只负责 DOM 注入和显隐状态,不重复维护一份样式副本避免两处漂移。
// 吉祥物图取 theme.css 默认值lalu 紫猫动画 webp其他 app 主题可在
// [data-hy-app-code='...'] 下覆盖 .hy-loading__mascot 的 background 换皮。
var LOADING_ID = 'hy-global-loading';
// show/hide 采用引用计数:并发请求各自 show/hide 时,最后一个 hide 才真正关闭遮罩,
// 避免先完成的请求把还在加载的请求的遮罩提前关掉。
var visibleCount = 0;
function ensureNode() {
var node = document.getElementById(LOADING_ID);
if (node) return node;
node = document.createElement('div');
node.id = LOADING_ID;
node.className = 'hy-loading';
node.setAttribute('role', 'status');
node.setAttribute('aria-live', 'polite');
node.setAttribute('aria-hidden', 'true');
var mascot = document.createElement('div');
mascot.className = 'hy-loading__mascot';
mascot.setAttribute('aria-hidden', 'true');
var spinner = document.createElement('div');
spinner.className = 'hy-loading__spinner';
spinner.setAttribute('aria-hidden', 'true');
var text = document.createElement('div');
text.className = 'hy-loading__text';
text.hidden = true;
node.appendChild(mascot);
node.appendChild(spinner);
node.appendChild(text);
document.body.appendChild(node);
return node;
}
function render() {
var node = ensureNode();
var active = visibleCount > 0;
node.classList.toggle('is-active', active);
node.setAttribute('aria-hidden', active ? 'false' : 'true');
}
function setText(node, message) {
var text = node.querySelector('.hy-loading__text');
if (!text) return;
var value = String(message || '').trim();
// 文案由调用方传入已本地化的字符串组件不内置多语言字典common/i18n.js 归页面消费)。
text.textContent = value;
text.hidden = !value;
}
function show(options) {
if (!document.body) {
// 页面脚本可能在 body 就绪前调用;挂到 DOMContentLoaded 重放,保持调用方无需关心时序。
document.addEventListener(
'DOMContentLoaded',
function () {
show(options);
},
{ once: true }
);
return;
}
visibleCount += 1;
var node = ensureNode();
setText(node, options && options.text);
render();
}
function hide(force) {
if (force) {
visibleCount = 0;
} else if (visibleCount > 0) {
visibleCount -= 1;
}
if (!document.getElementById(LOADING_ID)) return;
render();
}
function isVisible() {
return visibleCount > 0;
}
window.HyAppLoading = {
show: show,
hide: hide,
isVisible: isVisible,
};
// 与 HyAppToast 的 hyapp:toast 对齐的事件桥:原生壳或其他脚本可通过事件控制遮罩。
window.addEventListener('hyapp:loading', function (event) {
var detail = event.detail || {};
if (detail.visible === false) {
hide(detail.force === true);
return;
}
show(detail);
});
})();

View File

@ -65,6 +65,14 @@
"rechargeReward.idPrefix": "المعرّف:", "rechargeReward.idPrefix": "المعرّف:",
"rechargeReward.accumulatedRecharge": "إجمالي الشحن:", "rechargeReward.accumulatedRecharge": "إجمالي الشحن:",
"rechargeReward.rechargeTitle": "اشحن {amount}", "rechargeReward.rechargeTitle": "اشحن {amount}",
"rechargeReward.claim": "استلام",
"rechargeReward.claiming": "جار الاستلام...",
"rechargeReward.claimed": "تم الاستلام",
"rechargeReward.claimSuccess": "تم الاستلام",
"rechargeReward.claimFailed": "فشل الاستلام",
"rechargeReward.loading": "جار التحميل...",
"rechargeReward.noRewards": "لا توجد مكافآت بعد",
"rechargeReward.loadFailed": "فشل التحميل",
"wheel.pageTitle": "عجلة يومي", "wheel.pageTitle": "عجلة يومي",
"wheel.pageLabel": "عجلة يومي", "wheel.pageLabel": "عجلة يومي",
"wheel.classic": "كلاسيكي", "wheel.classic": "كلاسيكي",
@ -479,6 +487,21 @@
"coinseller_center.loading": "جار التحميل...", "coinseller_center.loading": "جار التحميل...",
"coinseller_center.load_failed": "فشل التحميل. حاول مرة أخرى لاحقا.", "coinseller_center.load_failed": "فشل التحميل. حاول مرة أخرى لاحقا.",
"coinseller_center.transfer_failed": "فشل التحويل.", "coinseller_center.transfer_failed": "فشل التحويل.",
"coinseller_center.sub_sellers": "بائعو العملات الفرعيون",
"coinseller_center.sub_sellers_desc": "ادع بائعي عملات فرعيين وأدر المعتمدين مباشرة",
"coinseller_center.invite_sub_seller": "دعوة بائع عملات فرعي",
"coinseller_center.invite_sub_seller_hint": "أدخل معرف المستخدم. سيظهر البائع بعد المراجعة الرسمية.",
"coinseller_center.invite_now": "ادع الآن",
"coinseller_center.invite_success": "ستراجع الجهة الرسمية دعوتك خلال 24 ساعة.",
"coinseller_center.invite_failed": "فشلت الدعوة.",
"coinseller_center.no_sub_sellers": "لا يوجد بائعو عملات فرعيون بعد.",
"coinseller_center.sub_seller_wallet": "المحفظة",
"coinseller_center.transfer": "تحويل",
"coinseller_center.transfer_now": "حوّل الآن",
"coinseller_center.transfer_success": "تم التحويل بنجاح.",
"coinseller_center.transfer_to_sub_seller": "تحويل إلى بائع فرعي",
"coinseller_center.transfer_to_sub_seller_hint": "حوّل العملات إلى محفظة بائع العملات الفرعي.",
"coinseller_center.select_sub_seller_first": "اختر بائع عملات فرعيا أولا.",
"lalu_landing.message": "أرسل لك هدية. تعال والعب معي.", "lalu_landing.message": "أرسل لك هدية. تعال والعب معي.",
"lalu_landing.frame_duration": "3 أيام", "lalu_landing.frame_duration": "3 أيام",
"lalu_landing.join_party": "انضم إلى الحفلة", "lalu_landing.join_party": "انضم إلى الحفلة",
@ -628,6 +651,7 @@
"cpSpace.pageLabel": "مساحة أفضل صديق", "cpSpace.pageLabel": "مساحة أفضل صديق",
"cpSpace.navLabel": "تنقل أفضل صديق", "cpSpace.navLabel": "تنقل أفضل صديق",
"cpSpace.back": "رجوع", "cpSpace.back": "رجوع",
"cpSpace.help": "مساعدة",
"cpSpace.title": "أفضل صديق", "cpSpace.title": "أفضل صديق",
"cpSpace.tabsLabel": "تبويبات أفضل صديق", "cpSpace.tabsLabel": "تبويبات أفضل صديق",
"cpSpace.tabCp": "CP", "cpSpace.tabCp": "CP",
@ -643,6 +667,8 @@
"cpSpace.daysValue": "{days} Days", "cpSpace.daysValue": "{days} Days",
"cpSpace.levelValue": "Lv.{level}", "cpSpace.levelValue": "Lv.{level}",
"cpSpace.expProgress": "{current}/{next} EXP", "cpSpace.expProgress": "{current}/{next} EXP",
"cpSpace.expAway": "متبقٍ {exp} خبرة للوصول إلى Lv. {level}",
"cpSpace.timeTogether": "الوقت معًا: {days} أيام",
"cpSpace.maxLevelExp": "{exp} EXP", "cpSpace.maxLevelExp": "{exp} EXP",
"cpSpace.canceling": "جار الإلغاء...", "cpSpace.canceling": "جار الإلغاء...",
"cpSpace.cancelConfirmWithCost": "إلغاء علاقة CP سيكلف {coins} عملة. هل تريد المتابعة؟", "cpSpace.cancelConfirmWithCost": "إلغاء علاقة CP سيكلف {coins} عملة. هل تريد المتابعة؟",
@ -712,5 +738,30 @@
"contact_card.required": "أدخل جهة اتصال WhatsApp.", "contact_card.required": "أدخل جهة اتصال WhatsApp.",
"contact_card.api_unavailable": "واجهة جهة الاتصال غير متاحة.", "contact_card.api_unavailable": "واجهة جهة الاتصال غير متاحة.",
"contact_card.saved": "تم الحفظ.", "contact_card.saved": "تم الحفظ.",
"contact_card.save_failed": "فشل الحفظ." "contact_card.save_failed": "فشل الحفظ.",
"manager_center.title": "مركز الإدارة",
"manager_center.back": "رجوع",
"manager_center.change_language": "تغيير اللغة",
"manager_center.copy_id": "نسخ معرف المدير",
"manager_center.role_manager": "مدير",
"manager_center.this_month": "هذا الشهر",
"manager_center.last_month": "الشهر الماضي",
"manager_center.team_salary": "راتب الفريق",
"manager_center.active_hosts": "المضيفون النشطون",
"manager_center.total_active": "إجمالي النشطين",
"manager_center.growth": "النمو",
"manager_center.vs_last_month": "مقارنة بالشهر الماضي",
"manager_center.wallet": "المحفظة",
"manager_center.bank_cards": "البطاقات البنكية",
"manager_center.admin": "مشرف",
"manager_center.bd_leader": "BD Leader",
"manager_center.invite_admin": "دعوة مشرف",
"manager_center.invite_bd": "دعوة BD",
"manager_center.super_admin": "مشرف عام",
"manager_center.block_users": "حظر المستخدمين",
"manager_center.send_frame": "إرسال إطار",
"manager_center.send_car": "إرسال مركبة",
"manager_center.send_badge": "إرسال شارة",
"manager_center.send_vip": "إرسال VIP",
"manager_center.user_level": "مستوى المستخدم"
} }

View File

@ -65,6 +65,14 @@
"rechargeReward.idPrefix": "ID:", "rechargeReward.idPrefix": "ID:",
"rechargeReward.accumulatedRecharge": "Accumulated Recharge:", "rechargeReward.accumulatedRecharge": "Accumulated Recharge:",
"rechargeReward.rechargeTitle": "Recharge {amount}", "rechargeReward.rechargeTitle": "Recharge {amount}",
"rechargeReward.claim": "Claim",
"rechargeReward.claiming": "Claiming...",
"rechargeReward.claimed": "Claimed",
"rechargeReward.claimSuccess": "Claimed",
"rechargeReward.claimFailed": "Claim failed",
"rechargeReward.loading": "Loading...",
"rechargeReward.noRewards": "No rewards yet",
"rechargeReward.loadFailed": "Load failed",
"wheel.pageTitle": "Yumi Wheel", "wheel.pageTitle": "Yumi Wheel",
"wheel.pageLabel": "Yumi Wheel", "wheel.pageLabel": "Yumi Wheel",
"wheel.classic": "Classic", "wheel.classic": "Classic",
@ -485,6 +493,21 @@
"coinseller_center.loading": "Loading...", "coinseller_center.loading": "Loading...",
"coinseller_center.load_failed": "Load failed. Try again later.", "coinseller_center.load_failed": "Load failed. Try again later.",
"coinseller_center.transfer_failed": "Transfer failed.", "coinseller_center.transfer_failed": "Transfer failed.",
"coinseller_center.sub_sellers": "My sub coin sellers",
"coinseller_center.sub_sellers_desc": "Invite sub coin sellers and manage approved direct sellers",
"coinseller_center.invite_sub_seller": "Invite sub coin seller",
"coinseller_center.invite_sub_seller_hint": "Enter the user's display ID. The seller will appear after official review.",
"coinseller_center.invite_now": "Invite now",
"coinseller_center.invite_success": "Official will review your invitation within 24 hours.",
"coinseller_center.invite_failed": "Invite failed.",
"coinseller_center.no_sub_sellers": "No sub coin sellers yet.",
"coinseller_center.sub_seller_wallet": "Wallet",
"coinseller_center.transfer": "Transfer",
"coinseller_center.transfer_now": "Transfer now",
"coinseller_center.transfer_success": "Transfer success.",
"coinseller_center.transfer_to_sub_seller": "Transfer to sub seller",
"coinseller_center.transfer_to_sub_seller_hint": "Transfer coins into the sub seller coin-seller wallet.",
"coinseller_center.select_sub_seller_first": "Select a sub coin seller first.",
"lalu_landing.message": "Sent you a gift. Come play with me.", "lalu_landing.message": "Sent you a gift. Come play with me.",
"lalu_landing.frame_duration": "3 days", "lalu_landing.frame_duration": "3 days",
"lalu_landing.join_party": "Join party", "lalu_landing.join_party": "Join party",
@ -634,6 +657,7 @@
"cpSpace.pageLabel": "Best Friend space", "cpSpace.pageLabel": "Best Friend space",
"cpSpace.navLabel": "Best Friend navigation", "cpSpace.navLabel": "Best Friend navigation",
"cpSpace.back": "Back", "cpSpace.back": "Back",
"cpSpace.help": "Help",
"cpSpace.title": "Best Friend", "cpSpace.title": "Best Friend",
"cpSpace.tabsLabel": "Best Friend tabs", "cpSpace.tabsLabel": "Best Friend tabs",
"cpSpace.tabCp": "CP", "cpSpace.tabCp": "CP",
@ -649,6 +673,8 @@
"cpSpace.daysValue": "{days} Days", "cpSpace.daysValue": "{days} Days",
"cpSpace.levelValue": "Lv.{level}", "cpSpace.levelValue": "Lv.{level}",
"cpSpace.expProgress": "{current}/{next} EXP", "cpSpace.expProgress": "{current}/{next} EXP",
"cpSpace.expAway": "{exp} Exp away from Lv. {level}",
"cpSpace.timeTogether": "Time spent together: {days} days",
"cpSpace.maxLevelExp": "{exp} EXP", "cpSpace.maxLevelExp": "{exp} EXP",
"cpSpace.canceling": "Canceling...", "cpSpace.canceling": "Canceling...",
"cpSpace.cancelConfirmWithCost": "Canceling this CP will cost {coins} coins. Continue?", "cpSpace.cancelConfirmWithCost": "Canceling this CP will cost {coins} coins. Continue?",
@ -718,5 +744,30 @@
"contact_card.required": "Enter WhatsApp contact.", "contact_card.required": "Enter WhatsApp contact.",
"contact_card.api_unavailable": "Contact API unavailable.", "contact_card.api_unavailable": "Contact API unavailable.",
"contact_card.saved": "Saved.", "contact_card.saved": "Saved.",
"contact_card.save_failed": "Save failed." "contact_card.save_failed": "Save failed.",
"manager_center.title": "Manage Center",
"manager_center.back": "Back",
"manager_center.change_language": "Change language",
"manager_center.copy_id": "Copy manager ID",
"manager_center.role_manager": "Manager",
"manager_center.this_month": "This Month",
"manager_center.last_month": "Last Month",
"manager_center.team_salary": "Team Salary",
"manager_center.active_hosts": "Active Hosts",
"manager_center.total_active": "Total Active",
"manager_center.growth": "Growth",
"manager_center.vs_last_month": "vs Last Month",
"manager_center.wallet": "Wallet",
"manager_center.bank_cards": "Bank Cards",
"manager_center.admin": "Admin",
"manager_center.bd_leader": "BD Leader",
"manager_center.invite_admin": "Invite Admin",
"manager_center.invite_bd": "Invite BD",
"manager_center.super_admin": "Super Admin",
"manager_center.block_users": "Block Users",
"manager_center.send_frame": "Send Frame",
"manager_center.send_car": "Send Car",
"manager_center.send_badge": "Send Badge",
"manager_center.send_vip": "Send VIP",
"manager_center.user_level": "User Level"
} }

View File

@ -65,6 +65,14 @@
"rechargeReward.idPrefix": "ID:", "rechargeReward.idPrefix": "ID:",
"rechargeReward.accumulatedRecharge": "Recarga acumulada:", "rechargeReward.accumulatedRecharge": "Recarga acumulada:",
"rechargeReward.rechargeTitle": "Recarga {amount}", "rechargeReward.rechargeTitle": "Recarga {amount}",
"rechargeReward.claim": "Reclamar",
"rechargeReward.claiming": "Reclamando...",
"rechargeReward.claimed": "Reclamado",
"rechargeReward.claimSuccess": "Reclamado",
"rechargeReward.claimFailed": "Error al reclamar",
"rechargeReward.loading": "Cargando...",
"rechargeReward.noRewards": "No hay recompensas todavía",
"rechargeReward.loadFailed": "Error al cargar",
"wheel.pageTitle": "Rueda de Yumi", "wheel.pageTitle": "Rueda de Yumi",
"wheel.pageLabel": "Rueda de Yumi", "wheel.pageLabel": "Rueda de Yumi",
"wheel.classic": "Clásico", "wheel.classic": "Clásico",
@ -479,6 +487,21 @@
"coinseller_center.loading": "Cargando...", "coinseller_center.loading": "Cargando...",
"coinseller_center.load_failed": "Error al cargar. Inténtalo de nuevo más tarde.", "coinseller_center.load_failed": "Error al cargar. Inténtalo de nuevo más tarde.",
"coinseller_center.transfer_failed": "Transferencia fallida.", "coinseller_center.transfer_failed": "Transferencia fallida.",
"coinseller_center.sub_sellers": "Mis subvendedores de monedas",
"coinseller_center.sub_sellers_desc": "Invita subvendedores y gestiona los directos aprobados",
"coinseller_center.invite_sub_seller": "Invitar subvendedor",
"coinseller_center.invite_sub_seller_hint": "Ingresa el ID visible del usuario. Aparecera despues de la revision oficial.",
"coinseller_center.invite_now": "Invitar ahora",
"coinseller_center.invite_success": "El equipo oficial revisara tu invitacion en 24 horas.",
"coinseller_center.invite_failed": "Error al invitar.",
"coinseller_center.no_sub_sellers": "Aún no hay subvendedores.",
"coinseller_center.sub_seller_wallet": "Billetera",
"coinseller_center.transfer": "Transferir",
"coinseller_center.transfer_now": "Transferir ahora",
"coinseller_center.transfer_success": "Transferencia exitosa.",
"coinseller_center.transfer_to_sub_seller": "Transferir al subvendedor",
"coinseller_center.transfer_to_sub_seller_hint": "Transfiere monedas a la billetera de vendedor del subvendedor.",
"coinseller_center.select_sub_seller_first": "Selecciona un subvendedor primero.",
"lalu_landing.message": "Te envió un regalo. Ven a jugar conmigo.", "lalu_landing.message": "Te envió un regalo. Ven a jugar conmigo.",
"lalu_landing.frame_duration": "3 días", "lalu_landing.frame_duration": "3 días",
"lalu_landing.join_party": "Unirse a la fiesta", "lalu_landing.join_party": "Unirse a la fiesta",
@ -628,6 +651,7 @@
"cpSpace.pageLabel": "Espacio de mejor amigo", "cpSpace.pageLabel": "Espacio de mejor amigo",
"cpSpace.navLabel": "Navegación de mejor amigo", "cpSpace.navLabel": "Navegación de mejor amigo",
"cpSpace.back": "Volver", "cpSpace.back": "Volver",
"cpSpace.help": "Ayuda",
"cpSpace.title": "Mejor amigo", "cpSpace.title": "Mejor amigo",
"cpSpace.tabsLabel": "Pestañas de mejor amigo", "cpSpace.tabsLabel": "Pestañas de mejor amigo",
"cpSpace.tabCp": "CP", "cpSpace.tabCp": "CP",
@ -643,6 +667,8 @@
"cpSpace.daysValue": "{days} Days", "cpSpace.daysValue": "{days} Days",
"cpSpace.levelValue": "Lv.{level}", "cpSpace.levelValue": "Lv.{level}",
"cpSpace.expProgress": "{current}/{next} EXP", "cpSpace.expProgress": "{current}/{next} EXP",
"cpSpace.expAway": "Faltan {exp} Exp para Lv. {level}",
"cpSpace.timeTogether": "Tiempo juntos: {days} días",
"cpSpace.maxLevelExp": "{exp} EXP", "cpSpace.maxLevelExp": "{exp} EXP",
"cpSpace.canceling": "Cancelando...", "cpSpace.canceling": "Cancelando...",
"cpSpace.cancelConfirmWithCost": "Cancelar este CP costará {coins} monedas. ¿Continuar?", "cpSpace.cancelConfirmWithCost": "Cancelar este CP costará {coins} monedas. ¿Continuar?",
@ -712,5 +738,30 @@
"contact_card.required": "Ingresa el contacto de WhatsApp.", "contact_card.required": "Ingresa el contacto de WhatsApp.",
"contact_card.api_unavailable": "API de contacto no disponible.", "contact_card.api_unavailable": "API de contacto no disponible.",
"contact_card.saved": "Guardado.", "contact_card.saved": "Guardado.",
"contact_card.save_failed": "No se pudo guardar." "contact_card.save_failed": "No se pudo guardar.",
"manager_center.title": "Centro de gestión",
"manager_center.back": "Atrás",
"manager_center.change_language": "Cambiar idioma",
"manager_center.copy_id": "Copiar ID de manager",
"manager_center.role_manager": "Gerente",
"manager_center.this_month": "Este mes",
"manager_center.last_month": "Mes pasado",
"manager_center.team_salary": "Salario del equipo",
"manager_center.active_hosts": "Anfitriones activos",
"manager_center.total_active": "Total activos",
"manager_center.growth": "Crecimiento",
"manager_center.vs_last_month": "vs mes pasado",
"manager_center.wallet": "Billetera",
"manager_center.bank_cards": "Tarjetas bancarias",
"manager_center.admin": "Admin",
"manager_center.bd_leader": "BD Leader",
"manager_center.invite_admin": "Invitar admin",
"manager_center.invite_bd": "Invitar BD",
"manager_center.super_admin": "Súper Admin",
"manager_center.block_users": "Bloquear usuarios",
"manager_center.send_frame": "Enviar marco",
"manager_center.send_car": "Enviar vehículo",
"manager_center.send_badge": "Enviar insignia",
"manager_center.send_vip": "Enviar VIP",
"manager_center.user_level": "Nivel de usuario"
} }

View File

@ -32,6 +32,14 @@
"rechargeReward.idPrefix": "ID:", "rechargeReward.idPrefix": "ID:",
"rechargeReward.accumulatedRecharge": "Recharge akumulatif:", "rechargeReward.accumulatedRecharge": "Recharge akumulatif:",
"rechargeReward.rechargeTitle": "Recharge {amount}", "rechargeReward.rechargeTitle": "Recharge {amount}",
"rechargeReward.claim": "Klaim",
"rechargeReward.claiming": "Mengklaim...",
"rechargeReward.claimed": "Diklaim",
"rechargeReward.claimSuccess": "Diklaim",
"rechargeReward.claimFailed": "Klaim gagal",
"rechargeReward.loading": "Memuat...",
"rechargeReward.noRewards": "Belum ada hadiah",
"rechargeReward.loadFailed": "Gagal memuat",
"wheel.pageTitle": "Yumi Wheel", "wheel.pageTitle": "Yumi Wheel",
"wheel.pageLabel": "Yumi Wheel", "wheel.pageLabel": "Yumi Wheel",
"wheel.classic": "Classic", "wheel.classic": "Classic",
@ -446,6 +454,21 @@
"coinseller_center.loading": "Memuat...", "coinseller_center.loading": "Memuat...",
"coinseller_center.load_failed": "Gagal memuat. Coba lagi nanti.", "coinseller_center.load_failed": "Gagal memuat. Coba lagi nanti.",
"coinseller_center.transfer_failed": "Transfer gagal.", "coinseller_center.transfer_failed": "Transfer gagal.",
"coinseller_center.sub_sellers": "Sub penjual koin saya",
"coinseller_center.sub_sellers_desc": "Undang sub penjual koin dan kelola seller langsung yang disetujui",
"coinseller_center.invite_sub_seller": "Undang sub penjual koin",
"coinseller_center.invite_sub_seller_hint": "Masukkan ID tampilan pengguna. Seller akan muncul setelah tinjauan resmi.",
"coinseller_center.invite_now": "Undang sekarang",
"coinseller_center.invite_success": "Pihak resmi akan meninjau undangan Anda dalam 24 jam.",
"coinseller_center.invite_failed": "Undangan gagal.",
"coinseller_center.no_sub_sellers": "Belum ada sub penjual koin.",
"coinseller_center.sub_seller_wallet": "Dompet",
"coinseller_center.transfer": "Transfer",
"coinseller_center.transfer_now": "Transfer sekarang",
"coinseller_center.transfer_success": "Transfer berhasil.",
"coinseller_center.transfer_to_sub_seller": "Transfer ke sub penjual",
"coinseller_center.transfer_to_sub_seller_hint": "Transfer koin ke dompet penjual koin milik sub penjual.",
"coinseller_center.select_sub_seller_first": "Pilih sub penjual koin terlebih dahulu.",
"lalu_landing.message": "Mengirim hadiah untukmu. Ayo main bersama.", "lalu_landing.message": "Mengirim hadiah untukmu. Ayo main bersama.",
"lalu_landing.frame_duration": "3 hari", "lalu_landing.frame_duration": "3 hari",
"lalu_landing.join_party": "Gabung party", "lalu_landing.join_party": "Gabung party",
@ -595,6 +618,7 @@
"cpSpace.pageLabel": "Ruang sahabat terbaik", "cpSpace.pageLabel": "Ruang sahabat terbaik",
"cpSpace.navLabel": "Navigasi sahabat terbaik", "cpSpace.navLabel": "Navigasi sahabat terbaik",
"cpSpace.back": "Kembali", "cpSpace.back": "Kembali",
"cpSpace.help": "Bantuan",
"cpSpace.title": "Sahabat Terbaik", "cpSpace.title": "Sahabat Terbaik",
"cpSpace.tabsLabel": "Tab sahabat terbaik", "cpSpace.tabsLabel": "Tab sahabat terbaik",
"cpSpace.tabCp": "CP", "cpSpace.tabCp": "CP",
@ -610,6 +634,8 @@
"cpSpace.daysValue": "{days} Days", "cpSpace.daysValue": "{days} Days",
"cpSpace.levelValue": "Lv.{level}", "cpSpace.levelValue": "Lv.{level}",
"cpSpace.expProgress": "{current}/{next} EXP", "cpSpace.expProgress": "{current}/{next} EXP",
"cpSpace.expAway": "{exp} Exp lagi ke Lv. {level}",
"cpSpace.timeTogether": "Waktu bersama: {days} hari",
"cpSpace.maxLevelExp": "{exp} EXP", "cpSpace.maxLevelExp": "{exp} EXP",
"cpSpace.canceling": "Membatalkan...", "cpSpace.canceling": "Membatalkan...",
"cpSpace.cancelConfirmWithCost": "Membatalkan CP ini akan memakai {coins} koin. Lanjutkan?", "cpSpace.cancelConfirmWithCost": "Membatalkan CP ini akan memakai {coins} koin. Lanjutkan?",
@ -640,5 +666,30 @@
"contact_card.required": "Masukkan kontak WhatsApp.", "contact_card.required": "Masukkan kontak WhatsApp.",
"contact_card.api_unavailable": "API kontak tidak tersedia.", "contact_card.api_unavailable": "API kontak tidak tersedia.",
"contact_card.saved": "Tersimpan.", "contact_card.saved": "Tersimpan.",
"contact_card.save_failed": "Gagal menyimpan." "contact_card.save_failed": "Gagal menyimpan.",
"manager_center.title": "Pusat Manajemen",
"manager_center.back": "Kembali",
"manager_center.change_language": "Ubah bahasa",
"manager_center.copy_id": "Salin ID manager",
"manager_center.role_manager": "Manajer",
"manager_center.this_month": "Bulan Ini",
"manager_center.last_month": "Bulan Lalu",
"manager_center.team_salary": "Gaji Tim",
"manager_center.active_hosts": "Host Aktif",
"manager_center.total_active": "Total Aktif",
"manager_center.growth": "Pertumbuhan",
"manager_center.vs_last_month": "vs Bulan Lalu",
"manager_center.wallet": "Dompet",
"manager_center.bank_cards": "Kartu Bank",
"manager_center.admin": "Admin",
"manager_center.bd_leader": "BD Leader",
"manager_center.invite_admin": "Undang Admin",
"manager_center.invite_bd": "Undang BD",
"manager_center.super_admin": "Super Admin",
"manager_center.block_users": "Blokir Pengguna",
"manager_center.send_frame": "Kirim Bingkai",
"manager_center.send_car": "Kirim Kendaraan",
"manager_center.send_badge": "Kirim Lencana",
"manager_center.send_vip": "Kirim VIP",
"manager_center.user_level": "Level Pengguna"
} }

View File

@ -32,6 +32,14 @@
"rechargeReward.idPrefix": "ID:", "rechargeReward.idPrefix": "ID:",
"rechargeReward.accumulatedRecharge": "Toplam yükleme:", "rechargeReward.accumulatedRecharge": "Toplam yükleme:",
"rechargeReward.rechargeTitle": "{amount} yükle", "rechargeReward.rechargeTitle": "{amount} yükle",
"rechargeReward.claim": "Al",
"rechargeReward.claiming": "Alınıyor...",
"rechargeReward.claimed": "Alındı",
"rechargeReward.claimSuccess": "Alındı",
"rechargeReward.claimFailed": "Alma başarısız",
"rechargeReward.loading": "Yükleniyor...",
"rechargeReward.noRewards": "Henüz ödül yok",
"rechargeReward.loadFailed": "Yükleme başarısız",
"wheel.pageTitle": "Yumi Çarkı", "wheel.pageTitle": "Yumi Çarkı",
"wheel.pageLabel": "Yumi Çarkı", "wheel.pageLabel": "Yumi Çarkı",
"wheel.classic": "Klasik", "wheel.classic": "Klasik",
@ -446,6 +454,21 @@
"coinseller_center.loading": "Yükleniyor...", "coinseller_center.loading": "Yükleniyor...",
"coinseller_center.load_failed": "Yükleme başarısız. Daha sonra tekrar deneyin.", "coinseller_center.load_failed": "Yükleme başarısız. Daha sonra tekrar deneyin.",
"coinseller_center.transfer_failed": "Transfer başarısız.", "coinseller_center.transfer_failed": "Transfer başarısız.",
"coinseller_center.sub_sellers": "Alt coin sellerlarım",
"coinseller_center.sub_sellers_desc": "Alt coin seller davet edin ve onaylı doğrudan sellerları yönetin",
"coinseller_center.invite_sub_seller": "Alt coin seller davet et",
"coinseller_center.invite_sub_seller_hint": "Kullanıcının görünen ID'sini girin. Seller resmi incelemeden sonra görünür.",
"coinseller_center.invite_now": "Şimdi davet et",
"coinseller_center.invite_success": "Yetkililer davetinizi 24 saat içinde inceleyecek.",
"coinseller_center.invite_failed": "Davet başarısız.",
"coinseller_center.no_sub_sellers": "Henüz alt coin seller yok.",
"coinseller_center.sub_seller_wallet": "Cüzdan",
"coinseller_center.transfer": "Transfer",
"coinseller_center.transfer_now": "Şimdi transfer et",
"coinseller_center.transfer_success": "Transfer başarılı.",
"coinseller_center.transfer_to_sub_seller": "Alt sellera transfer",
"coinseller_center.transfer_to_sub_seller_hint": "Coinleri alt sellerın coin-seller cüzdanına aktarın.",
"coinseller_center.select_sub_seller_first": "Önce bir alt coin seller seçin.",
"lalu_landing.message": "Sana bir hediye gönderdi. Gel birlikte oynayalım.", "lalu_landing.message": "Sana bir hediye gönderdi. Gel birlikte oynayalım.",
"lalu_landing.frame_duration": "3 gün", "lalu_landing.frame_duration": "3 gün",
"lalu_landing.join_party": "Partiye katıl", "lalu_landing.join_party": "Partiye katıl",
@ -595,6 +618,7 @@
"cpSpace.pageLabel": "En iyi arkadaş alanı", "cpSpace.pageLabel": "En iyi arkadaş alanı",
"cpSpace.navLabel": "En iyi arkadaş gezinmesi", "cpSpace.navLabel": "En iyi arkadaş gezinmesi",
"cpSpace.back": "Geri", "cpSpace.back": "Geri",
"cpSpace.help": "Yardım",
"cpSpace.title": "En İyi Arkadaş", "cpSpace.title": "En İyi Arkadaş",
"cpSpace.tabsLabel": "En iyi arkadaş sekmeleri", "cpSpace.tabsLabel": "En iyi arkadaş sekmeleri",
"cpSpace.tabCp": "CP", "cpSpace.tabCp": "CP",
@ -610,6 +634,8 @@
"cpSpace.daysValue": "{days} Days", "cpSpace.daysValue": "{days} Days",
"cpSpace.levelValue": "Lv.{level}", "cpSpace.levelValue": "Lv.{level}",
"cpSpace.expProgress": "{current}/{next} EXP", "cpSpace.expProgress": "{current}/{next} EXP",
"cpSpace.expAway": "Lv. {level} için {exp} Exp kaldı",
"cpSpace.timeTogether": "Birlikte geçirilen süre: {days} gün",
"cpSpace.maxLevelExp": "{exp} EXP", "cpSpace.maxLevelExp": "{exp} EXP",
"cpSpace.canceling": "İptal ediliyor...", "cpSpace.canceling": "İptal ediliyor...",
"cpSpace.cancelConfirmWithCost": "Bu CP ilişkisini iptal etmek {coins} coin tutar. Devam edilsin mi?", "cpSpace.cancelConfirmWithCost": "Bu CP ilişkisini iptal etmek {coins} coin tutar. Devam edilsin mi?",
@ -679,5 +705,30 @@
"contact_card.required": "WhatsApp iletişimini gir.", "contact_card.required": "WhatsApp iletişimini gir.",
"contact_card.api_unavailable": "İletişim API kullanılamıyor.", "contact_card.api_unavailable": "İletişim API kullanılamıyor.",
"contact_card.saved": "Kaydedildi.", "contact_card.saved": "Kaydedildi.",
"contact_card.save_failed": "Kaydetme başarısız." "contact_card.save_failed": "Kaydetme başarısız.",
"manager_center.title": "Yönetim Merkezi",
"manager_center.back": "Geri",
"manager_center.change_language": "Dili değiştir",
"manager_center.copy_id": "Manager ID kopyala",
"manager_center.role_manager": "Yönetici",
"manager_center.this_month": "Bu Ay",
"manager_center.last_month": "Geçen Ay",
"manager_center.team_salary": "Ekip Maaşı",
"manager_center.active_hosts": "Aktif Yayıncılar",
"manager_center.total_active": "Toplam Aktif",
"manager_center.growth": "Büyüme",
"manager_center.vs_last_month": "Geçen Aya Göre",
"manager_center.wallet": "Cüzdan",
"manager_center.bank_cards": "Banka Kartları",
"manager_center.admin": "Admin",
"manager_center.bd_leader": "BD Leader",
"manager_center.invite_admin": "Admin Davet Et",
"manager_center.invite_bd": "BD Davet Et",
"manager_center.super_admin": "Süper Admin",
"manager_center.block_users": "Kullanıcıları Engelle",
"manager_center.send_frame": "Çerçeve Gönder",
"manager_center.send_car": "Araç Gönder",
"manager_center.send_badge": "Rozet Gönder",
"manager_center.send_vip": "VIP Gönder",
"manager_center.user_level": "Kullanıcı Seviyesi"
} }

View File

@ -32,6 +32,14 @@
"rechargeReward.idPrefix": "ID", "rechargeReward.idPrefix": "ID",
"rechargeReward.accumulatedRecharge": "累计充值:", "rechargeReward.accumulatedRecharge": "累计充值:",
"rechargeReward.rechargeTitle": "充值 {amount}", "rechargeReward.rechargeTitle": "充值 {amount}",
"rechargeReward.claim": "领取",
"rechargeReward.claiming": "领取中...",
"rechargeReward.claimed": "已领取",
"rechargeReward.claimSuccess": "领取成功",
"rechargeReward.claimFailed": "领取失败",
"rechargeReward.loading": "加载中...",
"rechargeReward.noRewards": "暂无奖励",
"rechargeReward.loadFailed": "加载失败",
"wheel.pageTitle": "Yumi 转盘", "wheel.pageTitle": "Yumi 转盘",
"wheel.pageLabel": "Yumi 转盘", "wheel.pageLabel": "Yumi 转盘",
"wheel.classic": "Classic", "wheel.classic": "Classic",
@ -452,6 +460,21 @@
"coinseller_center.loading": "加载中...", "coinseller_center.loading": "加载中...",
"coinseller_center.load_failed": "加载失败,请稍后重试。", "coinseller_center.load_failed": "加载失败,请稍后重试。",
"coinseller_center.transfer_failed": "转账失败。", "coinseller_center.transfer_failed": "转账失败。",
"coinseller_center.sub_sellers": "我的子币商",
"coinseller_center.sub_sellers_desc": "邀请子币商,审核通过后管理直属子币商",
"coinseller_center.invite_sub_seller": "邀请子币商",
"coinseller_center.invite_sub_seller_hint": "输入用户展示 ID提交申请后由官方审核。",
"coinseller_center.invite_now": "立即邀请",
"coinseller_center.invite_success": "官方会在24小时内审核您的邀请。",
"coinseller_center.invite_failed": "邀请失败。",
"coinseller_center.no_sub_sellers": "暂无子币商。",
"coinseller_center.sub_seller_wallet": "钱包",
"coinseller_center.transfer": "转账",
"coinseller_center.transfer_now": "立即转账",
"coinseller_center.transfer_success": "转账成功。",
"coinseller_center.transfer_to_sub_seller": "转账给子币商",
"coinseller_center.transfer_to_sub_seller_hint": "将金币转入子币商的币商钱包。",
"coinseller_center.select_sub_seller_first": "请先选择子币商。",
"lalu_landing.message": "送你一份礼物,快来和我一起玩吧!", "lalu_landing.message": "送你一份礼物,快来和我一起玩吧!",
"lalu_landing.frame_duration": "3天", "lalu_landing.frame_duration": "3天",
"lalu_landing.join_party": "加入派对", "lalu_landing.join_party": "加入派对",
@ -601,6 +624,7 @@
"cpSpace.pageLabel": "挚友空间", "cpSpace.pageLabel": "挚友空间",
"cpSpace.navLabel": "挚友空间导航", "cpSpace.navLabel": "挚友空间导航",
"cpSpace.back": "返回", "cpSpace.back": "返回",
"cpSpace.help": "帮助",
"cpSpace.title": "Best Friend", "cpSpace.title": "Best Friend",
"cpSpace.tabsLabel": "挚友空间标签", "cpSpace.tabsLabel": "挚友空间标签",
"cpSpace.tabCp": "CP", "cpSpace.tabCp": "CP",
@ -616,6 +640,8 @@
"cpSpace.daysValue": "{days} Days", "cpSpace.daysValue": "{days} Days",
"cpSpace.levelValue": "Lv.{level}", "cpSpace.levelValue": "Lv.{level}",
"cpSpace.expProgress": "{current}/{next} EXP", "cpSpace.expProgress": "{current}/{next} EXP",
"cpSpace.expAway": "距离 Lv. {level} 还差 {exp} 经验",
"cpSpace.timeTogether": "在一起时长:{days} 天",
"cpSpace.maxLevelExp": "{exp} EXP", "cpSpace.maxLevelExp": "{exp} EXP",
"cpSpace.canceling": "取消中...", "cpSpace.canceling": "取消中...",
"cpSpace.cancelConfirmWithCost": "取消 CP 关系需要支付 {coins} 金币,是否继续?", "cpSpace.cancelConfirmWithCost": "取消 CP 关系需要支付 {coins} 金币,是否继续?",
@ -646,5 +672,30 @@
"contact_card.required": "请输入 WhatsApp 联系方式。", "contact_card.required": "请输入 WhatsApp 联系方式。",
"contact_card.api_unavailable": "联系方式接口不可用。", "contact_card.api_unavailable": "联系方式接口不可用。",
"contact_card.saved": "已保存。", "contact_card.saved": "已保存。",
"contact_card.save_failed": "保存失败。" "contact_card.save_failed": "保存失败。",
"manager_center.title": "管理中心",
"manager_center.back": "返回",
"manager_center.change_language": "切换语言",
"manager_center.copy_id": "复制经理 ID",
"manager_center.role_manager": "经理",
"manager_center.this_month": "本月",
"manager_center.last_month": "上月",
"manager_center.team_salary": "团队工资",
"manager_center.active_hosts": "活跃主播",
"manager_center.total_active": "累计活跃",
"manager_center.growth": "增长",
"manager_center.vs_last_month": "环比上月",
"manager_center.wallet": "钱包",
"manager_center.bank_cards": "银行卡",
"manager_center.admin": "管理员",
"manager_center.bd_leader": "BD Leader",
"manager_center.invite_admin": "邀请管理员",
"manager_center.invite_bd": "邀请 BD",
"manager_center.super_admin": "超级管理员",
"manager_center.block_users": "封禁用户",
"manager_center.send_frame": "赠送头像框",
"manager_center.send_car": "赠送座驾",
"manager_center.send_badge": "赠送徽章",
"manager_center.send_vip": "赠送 VIP",
"manager_center.user_level": "用户等级"
} }

136
common/skeleton.js Normal file
View File

@ -0,0 +1,136 @@
(function () {
// 页面级骨架屏:加载期整页覆盖一层模拟页面结构的占位层,数据就绪后整层淡出。
// 样式在 common/theme.css 的 .hy-page-skeleton 系列类,流光块复用 .hy-skeleton主题色自动跟随 App。
// 布局通过 section 描述数组配置,默认布局对齐各中心页的“资料卡 + 卡片栅格 + 操作宫格”结构;
// 结构差异大的页面调用 show({ sections: [...] }) 自定义。
var SKELETON_ID = 'hy-page-skeleton';
var LEAVE_MS = 200;
var leaveTimer = 0;
var DEFAULT_SECTIONS = [
// 顶栏:返回按钮 + 标题 + 右侧按钮
{ type: 'header' },
// 资料卡:圆头像 + 两行文本
{ type: 'profile' },
// KPI 卡片 2x2圆图标 + 两条文本
{ type: 'grid', count: 4, columns: 2, lines: 2, height: 128 },
// 资产行 2x1
{ type: 'grid', count: 2, columns: 2, lines: 1, height: 78 },
// 操作宫格 3x3居中圆图标 + 短文本
{ type: 'grid', count: 9, columns: 3, lines: 1, height: 112, center: true },
];
function block(circle) {
var node = document.createElement('div');
node.className = circle ? 'hy-skeleton hy-skeleton--circle' : 'hy-skeleton';
return node;
}
function buildSection(section) {
if (section.type === 'header') {
var header = document.createElement('div');
header.className = 'hy-page-skeleton__header';
header.appendChild(block(true));
header.appendChild(block(false));
header.appendChild(block(true));
return header;
}
if (section.type === 'profile') {
var card = document.createElement('div');
card.className = 'hy-page-skeleton__card hy-page-skeleton__profile';
var avatar = block(true);
avatar.style.width = '76px';
avatar.style.height = '76px';
card.appendChild(avatar);
var lines = document.createElement('div');
lines.className = 'hy-page-skeleton__lines';
lines.appendChild(block(false));
lines.appendChild(block(false));
card.appendChild(lines);
return card;
}
// grid等宽单元格每格圆图标 + N 条文本
var grid = document.createElement('div');
grid.className = 'hy-page-skeleton__grid';
grid.style.gridTemplateColumns =
'repeat(' + (section.columns || 2) + ', minmax(0, 1fr))';
for (var i = 0; i < (section.count || 2); i += 1) {
var cell = document.createElement('div');
cell.className =
'hy-page-skeleton__cell' +
(section.center ? ' hy-page-skeleton__cell--center' : '');
if (section.height) cell.style.minHeight = section.height + 'px';
cell.appendChild(block(true));
for (var line = 0; line < (section.lines || 1); line += 1) {
var bar = block(false);
bar.style.width = line === 0 ? '72%' : '48%';
cell.appendChild(bar);
}
grid.appendChild(cell);
}
return grid;
}
function ensureNode(sections) {
var node = document.getElementById(SKELETON_ID);
if (node) return node;
node = document.createElement('div');
node.id = SKELETON_ID;
node.className = 'hy-page-skeleton';
node.setAttribute('aria-hidden', 'true');
for (var i = 0; i < sections.length; i += 1) {
node.appendChild(buildSection(sections[i]));
}
// 骨架结构之上居中叠加拉鲁猫 loading 图 + 转圈(视觉与 .hy-loading 全屏遮罩同源)。
var mascotWrap = document.createElement('div');
mascotWrap.className = 'hy-page-skeleton__mascot-wrap';
var mascot = document.createElement('div');
mascot.className = 'hy-loading__mascot';
var spinner = document.createElement('div');
spinner.className = 'hy-loading__spinner';
mascotWrap.appendChild(mascot);
mascotWrap.appendChild(spinner);
node.appendChild(mascotWrap);
document.body.appendChild(node);
return node;
}
function show(options) {
if (!document.body) {
document.addEventListener(
'DOMContentLoaded',
function () {
show(options);
},
{ once: true }
);
return;
}
window.clearTimeout(leaveTimer);
var node = ensureNode((options && options.sections) || DEFAULT_SECTIONS);
node.classList.remove('is-leaving');
node.classList.add('is-active');
}
function hide() {
var node = document.getElementById(SKELETON_ID);
if (!node || !node.classList.contains('is-active')) return;
// 淡出后再摘除激活态,避免数据渲染瞬间硬切;重复 hide 幂等。
node.classList.add('is-leaving');
window.clearTimeout(leaveTimer);
leaveTimer = window.setTimeout(function () {
node.classList.remove('is-active', 'is-leaving');
}, LEAVE_MS);
}
function isVisible() {
var node = document.getElementById(SKELETON_ID);
return !!node && node.classList.contains('is-active');
}
window.HyAppSkeleton = {
show: show,
hide: hide,
isVisible: isVisible,
};
})();

View File

@ -170,3 +170,165 @@
animation: none; animation: none;
} }
} }
/* 通用骨架屏加载中给文本/块级占位元素加 .hy-skeleton 变成流光占位条数据到达后移除该类恢复真实内容
底色取主题软色变量lalu/yumi/aslan 三套主题自动跟随圆形占位头像等叠加 .hy-skeleton--circle */
.hy-skeleton {
position: relative;
display: inline-block;
overflow: hidden;
min-width: 3.2em;
min-height: 1em;
border-radius: var(--hy-skeleton-radius, 6px);
background: linear-gradient(
90deg,
var(--hy-theme-primary-soft) 25%,
var(--hy-theme-line) 37%,
var(--hy-theme-primary-soft) 63%
);
background-size: 400% 100%;
color: transparent !important;
animation: hy-skeleton-shimmer 1.4s ease infinite;
pointer-events: none;
user-select: none;
}
.hy-skeleton--circle {
--hy-skeleton-radius: 50%;
}
/* 占位期间隐藏子节点(图标等),避免真实内容在流光条上透出。 */
.hy-skeleton > * {
visibility: hidden;
}
@keyframes hy-skeleton-shimmer {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.hy-skeleton {
animation: none;
}
}
/* 页面级骨架屏common/skeleton.js整页覆盖一层模拟页面结构的占位层
数据就绪后整层淡出移除块级元素复用上面的 .hy-skeleton 流光条 */
.hy-page-skeleton {
position: fixed;
inset: 0;
z-index: 80;
display: none;
overflow: hidden;
padding: calc(14px + env(safe-area-inset-top)) 16px 30px;
background: var(--hy-theme-bg);
opacity: 1;
transition: opacity 0.2s ease;
}
.hy-page-skeleton.is-active {
display: block;
}
.hy-page-skeleton.is-leaving {
opacity: 0;
pointer-events: none;
}
/* 骨架层中央叠加拉鲁猫 loading 图 + 转圈,视觉复用 .hy-loading__mascot / .hy-loading__spinner。 */
.hy-page-skeleton__mascot-wrap {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
pointer-events: none;
}
.hy-page-skeleton__header {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) 36px;
align-items: center;
justify-items: center;
height: 58px;
margin-bottom: 10px;
}
.hy-page-skeleton__header .hy-skeleton:nth-child(2) {
width: 42%;
height: 20px;
}
.hy-page-skeleton__card {
margin-bottom: 12px;
padding: 14px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-card);
background: var(--hy-theme-surface);
}
.hy-page-skeleton__profile {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 12px;
align-items: center;
min-height: 112px;
}
.hy-page-skeleton__lines {
display: grid;
gap: 10px;
}
.hy-page-skeleton__lines .hy-skeleton:first-child {
width: 56%;
height: 18px;
}
.hy-page-skeleton__lines .hy-skeleton:last-child {
width: 38%;
height: 13px;
}
.hy-page-skeleton__grid {
display: grid;
gap: 12px;
margin-bottom: 12px;
}
.hy-page-skeleton__cell {
display: grid;
justify-items: start;
gap: 9px;
padding: 16px 13px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-card);
background: var(--hy-theme-surface);
}
.hy-page-skeleton__cell--center {
justify-items: center;
}
.hy-page-skeleton__cell .hy-skeleton {
height: 13px;
}
.hy-page-skeleton__cell .hy-skeleton--circle {
width: 46px;
height: 46px;
min-width: 46px;
}
@media (prefers-reduced-motion: reduce) {
.hy-page-skeleton {
transition: none;
}
}

View File

@ -0,0 +1,523 @@
(function () {
'use strict';
var PROD_API_BASE = 'https://api.atuchat.com/';
var LOCAL_API_BASE = 'http://127.0.0.1:1100/';
var APP_CODE = 'aslan';
var DEFAULT_ORIGIN = 'ATYOU';
var readyPromise = null;
var headers = {
authorization: '',
'req-app-intel':
'version=1.0.0;build=1;model=H5;sysVersion=web;channel=Web',
'req-client': 'H5',
'req-imei': 'H5-STATIC',
'req-lang': 'en',
'req-sys-origin': 'origin=ATYOU;originChild=ATYOU',
'req-version': 'V2',
'req-zone': 'Asia/Shanghai',
};
function currentParams() {
var params = new URLSearchParams(window.location.search || '');
var hash = window.location.hash || '';
if (hash.indexOf('?') >= 0) {
new URLSearchParams(hash.split('?').slice(1).join('?')).forEach(
function (value, key) {
if (!params.has(key)) params.set(key, value);
}
);
}
return params;
}
function query(name) {
return currentParams().get(name);
}
function normalizeBaseURL(value) {
return String(value || '').replace(/\/+$/, '');
}
function resolveEnv() {
var env = String(query('env') || '')
.trim()
.toLowerCase();
return env === 'local' || env === 'test' || env === 'dev' ? env : '';
}
function baseURL() {
var override =
query('apiBase') ||
query('api_base') ||
query('aslan_api_base') ||
window.ASLAN_API_BASE_URL ||
(window.AslanCommonConfig && window.AslanCommonConfig.apiBase);
if (override) return normalizeBaseURL(override);
return resolveEnv() === 'local'
? normalizeBaseURL(LOCAL_API_BASE)
: normalizeBaseURL(PROD_API_BASE);
}
function buildURL(path, queryData) {
var url = new URL(
String(path || '').replace(/^\/+/, ''),
baseURL() + '/'
);
Object.keys(queryData || {}).forEach(function (key) {
var value = queryData[key];
if (value === undefined || value === null || value === '') return;
url.searchParams.set(key, value);
});
return url.toString();
}
function normalizeAuthorization(value) {
var text = String(value || '').trim();
if (!text) return '';
return /^Bearer\s+/i.test(text) ? text : 'Bearer ' + text;
}
function splitHeaderPairs(value) {
var result = {};
String(value || '')
.split(';')
.forEach(function (item) {
var index = item.indexOf('=');
if (index > -1) {
result[item.slice(0, index).trim()] = item
.slice(index + 1)
.trim();
}
});
return result;
}
function putHeader(target, key, value) {
if (
value === undefined ||
value === null ||
value === '' ||
target[key]
) {
return;
}
target[key] = String(value);
}
function normalizeHeaderCandidate(value, depth) {
depth = depth || 0;
if (depth > 4 || value === null || value === undefined) return {};
if (typeof value === 'string') {
var trimmed = value.trim();
if (!trimmed) return {};
if (trimmed.charAt(0) === '{' || trimmed.charAt(0) === '[') {
try {
return normalizeHeaderCandidate(
JSON.parse(trimmed),
depth + 1
);
} catch (_) {
return {};
}
}
return { authorization: trimmed };
}
if (Array.isArray(value)) {
return value.reduce(function (result, item) {
return Object.assign(
result,
normalizeHeaderCandidate(item, depth + 1)
);
}, {});
}
if (typeof value !== 'object') return {};
var appIntel = splitHeaderPairs(
value['Req-App-Intel'] || value['req-app-intel']
);
var sysOrigin = splitHeaderPairs(
value['Req-Sys-Origin'] || value['req-sys-origin']
);
var normalized = {};
putHeader(
normalized,
'authorization',
value.Authorization ||
value.authorization ||
value.token ||
value.accessToken
);
putHeader(
normalized,
'reqLang',
value['Req-Lang'] ||
value['req-lang'] ||
value.reqLang ||
value.lang
);
putHeader(
normalized,
'appVersion',
appIntel.version || value.appVersion || value['App-Version']
);
putHeader(
normalized,
'buildVersion',
appIntel.build || value.buildVersion
);
putHeader(normalized, 'model', appIntel.model || value.model);
putHeader(normalized, 'channel', appIntel.channel || value.channel);
putHeader(
normalized,
'reqImei',
appIntel['Req-Imei'] ||
appIntel.imei ||
value['Req-Imei'] ||
value['req-imei'] ||
value.reqImei
);
putHeader(
normalized,
'origin',
sysOrigin.origin || value.sysOrigin || value.origin
);
putHeader(
normalized,
'child',
sysOrigin.originChild ||
sysOrigin.child ||
value.sysOriginChild ||
value.child
);
['headerInfo', 'headers', 'auth', 'data', 'state'].forEach(
function (key) {
Object.assign(
normalized,
normalizeHeaderCandidate(value[key], depth + 1)
);
}
);
return normalized;
}
function setHeadersFromApp(rawOrObject) {
var data = normalizeHeaderCandidate(rawOrObject || {});
var next = {};
if (data.authorization) {
next.authorization = normalizeAuthorization(data.authorization);
}
if (data.reqLang) next['req-lang'] = data.reqLang;
if (
data.appVersion ||
data.buildVersion ||
data.model ||
data.channel
) {
next['req-app-intel'] = [
data.appVersion ? 'version=' + data.appVersion : '',
data.buildVersion ? 'build=' + data.buildVersion : '',
data.model ? 'model=' + data.model : '',
data.channel ? 'channel=' + data.channel : '',
]
.filter(Boolean)
.join(';');
}
if (data.reqImei) next['req-imei'] = data.reqImei;
if (data.origin) {
next['req-sys-origin'] = [
'origin=' + data.origin,
'originChild=' + (data.child || data.origin),
].join(';');
}
headers = Object.assign(headers, next);
return headers;
}
function discoverQueryHeaders() {
return {
authorization:
query('authorization') ||
query('Authorization') ||
query('token') ||
query('accessToken') ||
query('access_token'),
reqLang: query('lang') || query('language') || query('locale'),
origin: query('sysOrigin') || query('origin') || DEFAULT_ORIGIN,
child:
query('sysOriginChild') ||
query('originChild') ||
DEFAULT_ORIGIN,
channel: query('channel') || query('appChannel'),
};
}
function readStorageHeaders(storage) {
var collected = {};
if (!storage) return collected;
[
'authorization',
'Authorization',
'token',
'accessToken',
'headerInfo',
'headersFromApp',
'appHeaders',
'user',
'userInfo',
].forEach(function (key) {
try {
Object.assign(
collected,
normalizeHeaderCandidate(storage.getItem(key))
);
} catch (_) {}
});
return collected;
}
function applyDiscoveredHeaders() {
setHeadersFromApp(discoverQueryHeaders());
setHeadersFromApp(window.__APP_HEADERS__);
setHeadersFromApp(window.__HEADER_INFO__);
setHeadersFromApp(window.headerInfo);
try {
setHeadersFromApp(readStorageHeaders(window.localStorage));
setHeadersFromApp(readStorageHeaders(window.sessionStorage));
} catch (_) {}
}
function ensureAslanAppCode() {
document.documentElement.dataset.hyAppCode = APP_CODE;
var params = currentParams();
if (
params.get('app_code') ||
params.get('app') ||
params.get('appCode') ||
!window.history ||
!window.history.replaceState
) {
return;
}
var url = new URL(window.location.href);
url.searchParams.set('app_code', APP_CODE);
window.history.replaceState(null, '', url.toString());
}
function isAppEnvironment() {
return Boolean(
window.app ||
window.FlutterApp ||
window.FlutterPageControl ||
window.webkit
);
}
function requestAccessOrigin() {
return new Promise(function (resolve) {
var settled = false;
var timeoutId = null;
function finish(raw) {
if (raw) setHeadersFromApp(raw);
if (settled) return;
settled = true;
if (timeoutId) window.clearTimeout(timeoutId);
resolve(raw || null);
}
window.renderData = finish;
window.getIosAccessOriginParam = finish;
if (!isAppEnvironment()) {
finish(null);
return;
}
// Aslan Flutter 在页面完成后注入 window.app.getAccessOrigin这里轮询短时间等待避免首屏请求早于注入而丢登录态。
if (
window.app &&
typeof window.app.getAccessOrigin === 'function'
) {
try {
finish(window.app.getAccessOrigin());
return;
} catch (_) {}
}
if (
window.FlutterApp &&
typeof window.FlutterApp.postMessage === 'function'
) {
try {
window.FlutterApp.postMessage('requestAccessOrigin');
} catch (_) {}
}
var attempt = 0;
var poll = function () {
attempt += 1;
if (
window.app &&
typeof window.app.getAccessOrigin === 'function'
) {
try {
finish(window.app.getAccessOrigin());
return;
} catch (_) {}
}
if (!settled && attempt < 30) window.setTimeout(poll, 80);
};
poll();
timeoutId = window.setTimeout(function () {
finish(null);
}, 2400);
});
}
function connectToApp() {
if (readyPromise) return readyPromise;
ensureAslanAppCode();
applyDiscoveredHeaders();
readyPromise = requestAccessOrigin().then(function () {
return {
baseURL: baseURL(),
headers: Object.assign({}, headers),
authenticated: Boolean(headers.authorization),
};
});
return readyPromise;
}
function parseResponse(response) {
return response.text().then(function (text) {
var payload = text ? JSON.parse(text) : {};
if (!response.ok) {
var httpError = new Error(
payload.message ||
payload.errorMsg ||
response.statusText ||
'request_failed'
);
httpError.status = response.status;
httpError.payload = payload;
throw httpError;
}
if (payload && payload.status === false) {
var apiError = new Error(
payload.message ||
payload.errorMsg ||
payload.error ||
'api_request_failed'
);
apiError.payload = payload;
apiError.code = payload.errorCode || payload.code;
throw apiError;
}
if (
payload &&
Object.prototype.hasOwnProperty.call(payload, 'body')
) {
return payload.body;
}
if (
payload &&
Object.prototype.hasOwnProperty.call(payload, 'data')
) {
return payload.data;
}
return payload;
});
}
function stripAppEncryptedHeaders(target) {
// H5 WebView 只复用 App 登录态,不参与 App Dio 加密;带 Req-Atyou 会让网关按密文解码普通 H5 请求。
Object.keys(target || {}).forEach(function (key) {
if (String(key).toLowerCase() === 'req-atyou') delete target[key];
});
}
function request(path, options) {
var opts = options || {};
var body = opts.body;
if (body && typeof body === 'object' && !(body instanceof FormData)) {
body = JSON.stringify(body);
}
return connectToApp().then(function () {
var requestHeaders = Object.assign({}, headers, opts.headers || {});
stripAppEncryptedHeaders(requestHeaders);
if (!requestHeaders.authorization)
delete requestHeaders.authorization;
if (body) {
requestHeaders['content-type'] =
requestHeaders['content-type'] || 'application/json';
}
return window
.fetch(buildURL(path, opts.query), {
method: opts.method || 'GET',
headers: requestHeaders,
body: body,
credentials: 'omit',
cache: 'no-store',
})
.then(parseResponse);
});
}
function back() {
if (
window.FlutterPageControl &&
window.FlutterPageControl.postMessage
) {
window.FlutterPageControl.postMessage('close_page');
return;
}
if (window.HyAppBridge && window.HyAppBridge.back) {
window.HyAppBridge.back();
return;
}
window.history.back();
}
window.AslanCommon = {
baseURL: baseURL,
buildURL: buildURL,
headers: function () {
return Object.assign({}, headers);
},
connectToApp: connectToApp,
setHeadersFromApp: setHeadersFromApp,
request: request,
get: function (path, queryData) {
return request(path, { method: 'GET', query: queryData });
},
post: function (path, body, queryData) {
return request(path, {
method: 'POST',
body: body,
query: queryData,
});
},
back: back,
user: {
profile: function () {
return request('/user/user-profile/user-id', { method: 'GET' });
},
},
rechargeReward: {
status: function () {
return request(
'/activity/cumulative-recharge/recharge-reward',
{
method: 'GET',
}
);
},
claim: function (ruleId) {
return request('/activity/cumulative-recharge', {
method: 'POST',
body: { id: String(ruleId || '') },
});
},
},
};
})();

641
common_yumi/yumi-common.js Normal file
View File

@ -0,0 +1,641 @@
(function () {
'use strict';
var PROD_API_BASE = 'https://jvapi.haiyihy.com';
var TEST_API_BASE = 'https://jvapi-test.haiyihy.com';
var LOCAL_API_BASE = 'http://127.0.0.1:13000';
var APP_CODE = 'yumi';
var RELATION_TYPE_BY_TAB = {
cp: 'CP',
brother: 'BROTHER',
sister: 'SISTERS',
};
var headers = {
authorization: '',
'req-app-intel':
'version=1.0.0;build=1;model=H5;sysVersion=web;channel=Web',
'req-client': 'H5',
'req-imei': 'H5-STATIC',
'req-lang': 'en',
'req-sys-origin': 'origin=YUMI;originChild=YUMI',
'req-version': 'V2',
'req-zone': 'Asia/Shanghai',
};
var readyPromise = null;
function currentParams() {
var params = new URLSearchParams(window.location.search || '');
var hash = window.location.hash || '';
if (hash.indexOf('?') >= 0) {
new URLSearchParams(hash.split('?').slice(1).join('?')).forEach(
function (value, key) {
if (!params.has(key)) params.set(key, value);
}
);
}
return params;
}
function query(name) {
return currentParams().get(name);
}
function normalizeBaseURL(value) {
return String(value || '').replace(/\/+$/, '');
}
function resolveEnv() {
var env = String(query('env') || '')
.trim()
.toLowerCase();
return env === 'test' || env === 'local' || env === 'dev' ? env : '';
}
function baseURL() {
var config = window.YumiCommonConfig || {};
var override =
query('apiBase') ||
query('api_base') ||
query('yumi_api_base') ||
window.YUMI_API_BASE_URL ||
config.apiBase;
if (override) return normalizeBaseURL(override);
var env = resolveEnv();
if (env === 'local')
return normalizeBaseURL(config.localApi || LOCAL_API_BASE);
if (env === 'test' || env === 'dev')
return normalizeBaseURL(config.testApi || TEST_API_BASE);
return normalizeBaseURL(config.prodApi || PROD_API_BASE);
}
function buildURL(path, queryData) {
var url = new URL(
String(path || '').replace(/^\/+/, ''),
baseURL() + '/'
);
Object.keys(queryData || {}).forEach(function (key) {
var value = queryData[key];
if (value === undefined || value === null || value === '') return;
url.searchParams.set(key, value);
});
return url.toString();
}
function normalizeAuthorization(value) {
var text = String(value || '').trim();
if (!text) return '';
return /^Bearer\s+/i.test(text) ? text : 'Bearer ' + text;
}
function tokenForHyAppAPI(value) {
return String(value || '').replace(/^Bearer\s+/i, '');
}
function splitHeaderPairs(value) {
var result = {};
String(value || '')
.split(';')
.forEach(function (item) {
var index = item.indexOf('=');
if (index > -1)
result[item.slice(0, index)] = item.slice(index + 1);
});
return result;
}
function putHeader(target, key, value) {
if (
value === undefined ||
value === null ||
value === '' ||
target[key]
)
return;
target[key] = String(value);
}
function normalizeHeaderCandidate(value, depth) {
depth = depth || 0;
if (depth > 4 || value === null || value === undefined) return {};
if (typeof value === 'string') {
var trimmed = value.trim();
if (!trimmed) return {};
if (trimmed.charAt(0) === '{' || trimmed.charAt(0) === '[') {
try {
return normalizeHeaderCandidate(
JSON.parse(trimmed),
depth + 1
);
} catch (_) {
return {};
}
}
return trimmed ? { authorization: trimmed } : {};
}
if (Array.isArray(value)) {
return value.reduce(function (result, item) {
return Object.assign(
result,
normalizeHeaderCandidate(item, depth + 1)
);
}, {});
}
if (typeof value !== 'object') return {};
var appIntel = splitHeaderPairs(
value['Req-App-Intel'] || value['req-app-intel']
);
var sysOrigin = splitHeaderPairs(
value['Req-Sys-Origin'] || value['req-sys-origin']
);
var normalized = {};
putHeader(
normalized,
'authorization',
value.Authorization ||
value.authorization ||
value.token ||
value.accessToken
);
putHeader(
normalized,
'reqLang',
value['Req-Lang'] ||
value['req-lang'] ||
value.reqLang ||
value.lang
);
putHeader(
normalized,
'appVersion',
appIntel.version || value.appVersion || value['App-Version']
);
putHeader(
normalized,
'buildVersion',
appIntel.build || value.buildVersion
);
putHeader(normalized, 'model', appIntel.model || value.model);
putHeader(normalized, 'channel', appIntel.channel || value.channel);
putHeader(
normalized,
'reqImei',
value['Req-Imei'] || value['req-imei'] || value.reqImei
);
putHeader(
normalized,
'origin',
sysOrigin.origin || value.sysOrigin || value.origin
);
putHeader(
normalized,
'child',
sysOrigin.originChild ||
sysOrigin.child ||
value.sysOriginChild ||
value.child
);
['headerInfo', 'headers', 'auth', 'data', 'state'].forEach(
function (key) {
Object.assign(
normalized,
normalizeHeaderCandidate(value[key], depth + 1)
);
}
);
return normalized;
}
function setHeadersFromApp(rawOrObject) {
var data = normalizeHeaderCandidate(rawOrObject || {});
var next = {};
if (data.authorization)
next.authorization = normalizeAuthorization(data.authorization);
if (data.reqLang) next['req-lang'] = data.reqLang;
if (
data.appVersion ||
data.buildVersion ||
data.model ||
data.channel
) {
next['req-app-intel'] = [
data.appVersion ? 'version=' + data.appVersion : '',
data.buildVersion ? 'build=' + data.buildVersion : '',
data.model ? 'model=' + data.model : '',
data.channel ? 'channel=' + data.channel : '',
]
.filter(Boolean)
.join(';');
}
if (data.reqImei) next['req-imei'] = data.reqImei;
if (data.origin) {
next['req-sys-origin'] = [
'origin=' + data.origin,
'originChild=' + (data.child || data.origin),
].join(';');
}
headers = Object.assign(headers, next);
if (window.HyAppAPI) {
if (next.authorization)
window.HyAppAPI.setAccessToken(
tokenForHyAppAPI(next.authorization)
);
window.HyAppAPI.setAppCode(APP_CODE);
}
return headers;
}
function discoverQueryHeaders() {
return {
authorization:
query('authorization') ||
query('Authorization') ||
query('token') ||
query('accessToken') ||
query('access_token'),
reqLang: query('lang') || query('language') || query('locale'),
origin: query('sysOrigin') || query('origin') || 'YUMI',
child: query('sysOriginChild') || query('originChild') || 'YUMI',
channel: query('channel') || query('appChannel'),
};
}
function readStorageHeaders(storage) {
var collected = {};
if (!storage) return collected;
[
'authorization',
'Authorization',
'token',
'accessToken',
'headerInfo',
'headersFromApp',
'appHeaders',
'user',
'userInfo',
].forEach(function (key) {
try {
Object.assign(
collected,
normalizeHeaderCandidate(storage.getItem(key))
);
} catch (_) {}
});
return collected;
}
function applyDiscoveredHeaders() {
setHeadersFromApp(discoverQueryHeaders());
setHeadersFromApp(window.__APP_HEADERS__);
setHeadersFromApp(window.__HEADER_INFO__);
setHeadersFromApp(window.headerInfo);
try {
setHeadersFromApp(readStorageHeaders(window.localStorage));
setHeadersFromApp(readStorageHeaders(window.sessionStorage));
} catch (_) {}
}
function ensureYumiAppCode() {
if (window.HyAppAPI) window.HyAppAPI.setAppCode(APP_CODE);
var params = currentParams();
if (
params.get('app_code') ||
params.get('app') ||
params.get('appCode')
)
return;
if (!window.history || !window.history.replaceState) return;
var url = new URL(window.location.href);
url.searchParams.set('app_code', APP_CODE);
// 目标仓 common/api.js 没有 app_code 会默认走 laluYumi 页入口必须显式落到 Yumi 租户。
window.history.replaceState(null, '', url.toString());
}
function isAppEnvironment() {
return Boolean(
window.app ||
window.FlutterApp ||
window.FlutterPageControl ||
window.webkit
);
}
function requestAccessOrigin() {
return new Promise(function (resolve) {
var settled = false;
var timeoutId = null;
function finish(raw) {
if (raw) setHeadersFromApp(raw);
if (settled) return;
settled = true;
if (timeoutId) window.clearTimeout(timeoutId);
resolve(raw || null);
}
window.renderData = finish;
window.getIosAccessOriginParam = finish;
if (!isAppEnvironment()) {
finish(null);
return;
}
// Android 普通 WebView 会注入 window.app.getAccessOriginiOS/旧页可能只通过回调异步回填。
if (
window.app &&
typeof window.app.getAccessOrigin === 'function'
) {
try {
finish(window.app.getAccessOrigin());
return;
} catch (_) {}
}
if (
window.FlutterApp &&
typeof window.FlutterApp.postMessage === 'function'
) {
try {
window.FlutterApp.postMessage('requestAccessOrigin');
} catch (_) {}
}
var attempt = 0;
var poll = function () {
attempt += 1;
if (
window.app &&
typeof window.app.getAccessOrigin === 'function'
) {
try {
finish(window.app.getAccessOrigin());
return;
} catch (_) {}
}
if (!settled && attempt < 30) window.setTimeout(poll, 60);
};
poll();
timeoutId = window.setTimeout(function () {
finish(null);
}, 1800);
});
}
function connectToApp() {
if (readyPromise) return readyPromise;
ensureYumiAppCode();
applyDiscoveredHeaders();
readyPromise = requestAccessOrigin().then(function () {
return {
baseURL: baseURL(),
headers: Object.assign({}, headers),
authenticated: Boolean(headers.authorization),
};
});
return readyPromise;
}
function parseResponse(response) {
return response.text().then(function (text) {
var payload = text ? JSON.parse(text) : {};
if (!response.ok) {
var httpError = new Error(
payload.message || response.statusText || 'request_failed'
);
httpError.status = response.status;
httpError.payload = payload;
throw httpError;
}
if (payload && payload.status === false) {
var apiError = new Error(
payload.message || payload.errorMsg || 'api_request_failed'
);
apiError.payload = payload;
throw apiError;
}
if (
payload &&
Object.prototype.hasOwnProperty.call(payload, 'body')
)
return payload.body;
if (
payload &&
Object.prototype.hasOwnProperty.call(payload, 'data')
)
return payload.data;
return payload;
});
}
function request(path, options) {
var opts = options || {};
var body = opts.body;
if (body && typeof body === 'object' && !(body instanceof FormData)) {
body = JSON.stringify(body);
}
return connectToApp().then(function () {
var requestHeaders = Object.assign({}, headers, opts.headers || {});
if (!requestHeaders.authorization)
delete requestHeaders.authorization;
if (body)
requestHeaders['content-type'] =
requestHeaders['content-type'] || 'application/json';
return window
.fetch(buildURL(path, opts.query), {
method: opts.method || 'GET',
headers: requestHeaders,
body: body,
credentials: 'omit',
cache: 'no-store',
})
.then(parseResponse);
});
}
function readNumber(value, fallback) {
var number = Number(value);
return Number.isFinite(number) ? number : fallback || 0;
}
function relationType(tabOrType) {
var key = String(tabOrType || 'cp')
.trim()
.toLowerCase();
return (
RELATION_TYPE_BY_TAB[key] || String(tabOrType || 'CP').toUpperCase()
);
}
function userAvatar(profile) {
profile = profile || {};
return (
profile.userAvatar ||
profile.avatar ||
profile.headImg ||
profile.headImage ||
''
);
}
function userId(profile) {
profile = profile || {};
return profile.id || profile.userId || profile.uid || '';
}
function choosePair(list, type) {
var rows = Array.isArray(list) ? list : list ? [list] : [];
for (var i = 0; i < rows.length; i += 1) {
if (relationType(rows[i].relationType) === type) return rows[i];
}
return rows[0] || null;
}
function sortedLevels(levels) {
return (Array.isArray(levels) ? levels : [])
.slice()
.sort(function (a, b) {
return readNumber(a.level, 0) - readNumber(b.level, 0);
});
}
function levelProgress(rights) {
var intimacy = readNumber(rights && rights.intimacyValue, 0);
var levels = sortedLevels(rights && rights.levels);
var current = levels[0] || { level: 0, requiredIntimacyValue: 0 };
var next = levels[1] || current;
levels.forEach(function (level, index) {
if (readNumber(level.requiredIntimacyValue, 0) <= intimacy) {
current = level;
next = levels[index + 1] || level;
}
});
var currentThreshold = readNumber(current.requiredIntimacyValue, 0);
var nextThreshold = Math.max(
readNumber(next.requiredIntimacyValue, currentThreshold),
currentThreshold
);
var currentExp = Math.max(0, intimacy - currentThreshold);
var nextExp = Math.max(0, nextThreshold - currentThreshold);
var progressPercent = nextExp ? (currentExp / nextExp) * 100 : 100;
return {
currentLevel: readNumber(current.level, 0),
nextLevel: readNumber(next.level, current.level),
currentExp: currentExp,
nextExp: nextExp || intimacy,
neededExp: Math.max(0, nextThreshold - intimacy),
nextExpLevel: readNumber(next.level, current.level),
progressPercent: Math.max(0, Math.min(100, progressPercent)),
};
}
function normalizeRelation(pair, rights) {
pair = pair || {};
rights = rights || {};
var progress = levelProgress(rights);
var me = pair.meUserProfile || pair.me || {};
var partner = pair.cpUserProfile || pair.partner || {};
return Object.assign(progress, {
relationType: relationType(
pair.relationType || rights.relationType
),
status: rights.relationshipStatus || pair.status || 'NONE',
cpUserId: rights.cpUserId || userId(partner),
days: readNumber(pair.days, 0),
togetherDays: readNumber(pair.days, 0),
me: { avatar: userAvatar(me), id: userId(me) },
partner: { avatar: userAvatar(partner), id: userId(partner) },
raw: { pair: pair, rights: rights },
});
}
function pair(options) {
var opts = options || {};
return request('/user/cp-relationship/pair', {
query: {
relationType: relationType(opts.relationType || opts.tab),
userId: opts.userId,
},
});
}
function rights(options) {
var opts = options || {};
return request('/user/cp/rights/config', {
query: {
relationType: relationType(opts.relationType || opts.tab),
cpUserId: opts.cpUserId,
},
});
}
function loadRelation(options) {
var opts = options || {};
var type = relationType(opts.relationType || opts.tab);
var selectedPair = null;
var firstError = null;
return pair({ relationType: type, userId: opts.userId })
.then(function (pairList) {
selectedPair = choosePair(pairList, type);
var cpUserId =
opts.cpUserId ||
(selectedPair && userId(selectedPair.cpUserProfile)) ||
'';
return rights({ relationType: type, cpUserId: cpUserId });
})
.catch(function (error) {
firstError = error;
return rights({ relationType: type, cpUserId: opts.cpUserId });
})
.then(function (rightsData) {
return normalizeRelation(selectedPair, rightsData);
})
.catch(function (error) {
throw firstError || error;
});
}
function back() {
if (
window.FlutterPageControl &&
window.FlutterPageControl.postMessage
) {
window.FlutterPageControl.postMessage('close_page');
return;
}
if (window.HyAppBridge && window.HyAppBridge.back) {
window.HyAppBridge.back();
return;
}
window.history.back();
}
window.YumiCommon = {
baseURL: baseURL,
buildURL: buildURL,
headers: function () {
return Object.assign({}, headers);
},
connectToApp: connectToApp,
setHeadersFromApp: setHeadersFromApp,
request: request,
get: function (path, queryData) {
return request(path, { method: 'GET', query: queryData });
},
post: function (path, body, queryData) {
return request(path, {
method: 'POST',
body: body,
query: queryData,
});
},
back: back,
cpSpace: {
relationType: relationType,
pair: pair,
rights: rights,
loadRelation: loadRelation,
normalizeRelation: normalizeRelation,
},
};
})();

View File

@ -112,6 +112,38 @@
</div> </div>
</section> </section>
<section
class="card sub-seller-card"
id="subSellerSection"
hidden
>
<div class="section-head">
<div>
<h2 data-i18n="coinseller_center.sub_sellers">
My sub coin sellers
</h2>
<p data-i18n="coinseller_center.sub_sellers_desc">
Invite and manage direct sub coin sellers
</p>
</div>
<button
class="round-action small"
id="inviteSubSellerButton"
type="button"
aria-label="Invite sub coin seller"
data-i18n-aria="coinseller_center.invite_sub_seller"
hidden
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
</button>
</div>
<div class="form-status" id="subSellerStatus" hidden></div>
<div class="sub-seller-list" id="subSellerList"></div>
</section>
<section class="card search-card"> <section class="card search-card">
<div class="section-head"> <div class="section-head">
<div> <div>
@ -131,7 +163,9 @@
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
<path d="M9.5 9a2.7 2.7 0 0 1 5.2.9c0 1.9-2.7 2.2-2.7 4.1" /> <path
d="M9.5 9a2.7 2.7 0 0 1 5.2.9c0 1.9-2.7 2.2-2.7 4.1"
/>
<path d="M12 17h.01" /> <path d="M12 17h.01" />
</svg> </svg>
</button> </button>
@ -152,34 +186,34 @@
> >
Search Search
</button> </button>
</form> </form>
<div class="form-status" id="searchStatus" hidden></div> <div class="form-status" id="searchStatus" hidden></div>
<div class="target-card" id="targetCard" hidden> <div class="target-card" id="targetCard" hidden>
<div class="target-avatar" id="targetAvatar"></div> <div class="target-avatar" id="targetAvatar"></div>
<div class="target-main"> <div class="target-main">
<div <div
class="role-pill" class="role-pill"
data-i18n="coinseller_center.target_user" data-i18n="coinseller_center.target_user"
> >
Target user Target user
</div> </div>
<div class="target-name" id="targetName">-</div> <div class="target-name" id="targetName">-</div>
<div class="target-meta" id="targetUID">UID: -</div> <div class="target-meta" id="targetUID">UID: -</div>
</div> </div>
<button <button
class="target-coin-action" class="target-coin-action"
id="openRechargeButton" id="openRechargeButton"
type="button" type="button"
aria-label="Recharge coins" aria-label="Recharge coins"
data-i18n-aria="coinseller_center.recharge_coins" data-i18n-aria="coinseller_center.recharge_coins"
> >
<span class="coin-icon" aria-hidden="true">$</span> <span class="coin-icon" aria-hidden="true">$</span>
</button> </button>
</div> </div>
</section> </section>
<section class="card recent-card"> <section class="card recent-card">
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 data-i18n="coinseller_center.recent_records"> <h2 data-i18n="coinseller_center.recent_records">
@ -241,7 +275,10 @@
</button> </button>
</div> </div>
<div class="history-body"> <div class="history-body">
<form class="history-search-form" id="historySearchForm"> <form
class="history-search-form"
id="historySearchForm"
>
<input <input
id="historySearchInput" id="historySearchInput"
type="text" type="text"
@ -290,8 +327,15 @@
Expenditure Expenditure
</button> </button>
</div> </div>
<div class="form-status" id="historyStatus" hidden></div> <div
<div class="record-list history-records" id="historyList"></div> class="form-status"
id="historyStatus"
hidden
></div>
<div
class="record-list history-records"
id="historyList"
></div>
<button <button
class="load-more" class="load-more"
id="loadMoreButton" id="loadMoreButton"
@ -382,10 +426,151 @@
<div <div
class="sheet-modal" class="sheet-modal"
id="helpModal" id="inviteSubSellerModal"
hidden hidden
aria-hidden="true" aria-hidden="true"
> >
<button
class="modal-backdrop"
type="button"
data-close-modal="inviteSubSellerModal"
aria-label="Close"
data-i18n-aria="coinseller_center.close"
></button>
<section
class="bottom-sheet invite-sub-seller-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="inviteSubSellerTitle"
>
<div class="sheet-head">
<h2
id="inviteSubSellerTitle"
data-i18n="coinseller_center.invite_sub_seller"
>
Invite sub coin seller
</h2>
<button
class="sheet-close"
type="button"
data-close-modal="inviteSubSellerModal"
aria-label="Close"
data-i18n-aria="coinseller_center.close"
>
&times;
</button>
</div>
<form class="sub-seller-form" id="inviteSubSellerForm">
<p data-i18n="coinseller_center.invite_sub_seller_hint">
Enter the user's display ID to open a direct sub
coin seller.
</p>
<input
id="inviteSubSellerInput"
type="text"
inputmode="text"
autocomplete="off"
placeholder="Enter user ID"
data-i18n-placeholder="coinseller_center.enter_user_id"
/>
<button
id="submitInviteSubSellerButton"
type="submit"
data-i18n="coinseller_center.invite_now"
>
Invite now
</button>
<div
class="form-status"
id="inviteSubSellerStatus"
hidden
></div>
</form>
</section>
</div>
<div
class="sheet-modal"
id="subSellerTransferModal"
hidden
aria-hidden="true"
>
<button
class="modal-backdrop"
type="button"
data-close-modal="subSellerTransferModal"
aria-label="Close"
data-i18n-aria="coinseller_center.close"
></button>
<section
class="bottom-sheet recharge-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="subSellerTransferTitle"
>
<div class="sheet-head">
<h2
id="subSellerTransferTitle"
data-i18n="coinseller_center.transfer_to_sub_seller"
>
Transfer to sub seller
</h2>
<button
class="sheet-close"
type="button"
data-close-modal="subSellerTransferModal"
aria-label="Close"
data-i18n-aria="coinseller_center.close"
>
&times;
</button>
</div>
<div class="recharge-sheet-body">
<div
class="recharge-target"
id="subSellerTransferTargetName"
>
-
</div>
<p
data-i18n="coinseller_center.transfer_to_sub_seller_hint"
>
Transfer coins into the sub seller coin-seller
wallet.
</p>
<form class="recharge-form" id="subSellerTransferForm">
<label class="coin-input-shell">
<span class="input-coin" aria-hidden="true"
>$</span
>
<input
id="subSellerTransferAmountInput"
type="text"
inputmode="numeric"
autocomplete="off"
placeholder="Enter coins amount"
data-i18n-placeholder="coinseller_center.coin_amount_placeholder"
/>
</label>
<button
id="submitSubSellerTransferButton"
type="submit"
disabled
data-i18n="coinseller_center.transfer_now"
>
Transfer now
</button>
</form>
<div
class="form-status"
id="subSellerTransferStatus"
hidden
></div>
</div>
</section>
</div>
<div class="sheet-modal" id="helpModal" hidden aria-hidden="true">
<button <button
class="modal-backdrop" class="modal-backdrop"
type="button" type="button"
@ -419,13 +604,15 @@
How to recharge? How to recharge?
</p> </p>
<span data-i18n="coinseller_center.faq_answer_1"> <span data-i18n="coinseller_center.faq_answer_1">
Search a user, enter the coin amount, then confirm recharge. Search a user, enter the coin amount, then confirm
recharge.
</span> </span>
<p data-i18n="coinseller_center.faq_question_2"> <p data-i18n="coinseller_center.faq_question_2">
How to check records? How to check records?
</p> </p>
<span data-i18n="coinseller_center.faq_answer_2"> <span data-i18n="coinseller_center.faq_answer_2">
Open history and switch between income and expenditure. Open history and switch between income and
expenditure.
</span> </span>
</div> </div>
</section> </section>
@ -434,9 +621,9 @@
<script src="../../common/toast.js"></script> <script src="../../common/toast.js"></script>
<script src="../../common/jsbridge.js"></script> <script src="../../common/jsbridge.js"></script>
<script src="../../common/api.js?v=20260604-coinseller-records"></script> <script src="../../common/api.js?v=20260709-coinseller-sub-sellers"></script>
<script src="../../common/i18n.js?v=20260604-coinseller"></script> <script src="../../common/i18n.js?v=20260604-coinseller"></script>
<script src="../common/contact-card.js?v=20260629-user-contact"></script> <script src="../common/contact-card.js?v=20260629-user-contact"></script>
<script src="./script.js?v=20260604-record-user-profile"></script> <script src="./script.js?v=20260709-sub-sellers"></script>
</body> </body>
</html> </html>

View File

@ -79,6 +79,29 @@
time: '2026-06-03 18:42', time: '2026-06-03 18:42',
}, },
], ],
subSellerPermission: {
visible: true,
canInvite: true,
canTransfer: true,
},
subSellers: [
{
uid: '7001001',
userId: '107001001',
name: 'Sub Seller Sara',
avatar: avatarData('S', '#efe4ff', '#7d57c7'),
status: 'active',
balance: 320000,
},
{
uid: '7001002',
userId: '107001002',
name: 'Sub Seller Omar',
avatar: avatarData('O', '#e7f7f3', '#31a88f'),
status: 'active',
balance: 180000,
},
],
}; };
var data = { var data = {
@ -87,6 +110,8 @@
users: [], users: [],
quickAmounts: mock.quickAmounts.slice(), quickAmounts: mock.quickAmounts.slice(),
records: [], records: [],
subSellerPermission: emptySubSellerPermission(),
subSellers: [],
}; };
var state = { var state = {
@ -99,6 +124,11 @@
loading: query.get('mock') !== '1', loading: query.get('mock') !== '1',
searching: false, searching: false,
submitting: false, submitting: false,
subSellerLoading: false,
invitingSubSeller: false,
subSellerTransferTarget: null,
subSellerTransferAmount: '',
subSellerTransferSubmitting: false,
}; };
function $(id) { function $(id) {
@ -140,6 +170,14 @@
}; };
} }
function emptySubSellerPermission() {
return {
visible: false,
canInvite: false,
canTransfer: false,
};
}
function initial(text) { function initial(text) {
return String(text || '') return String(text || '')
.trim() .trim()
@ -163,6 +201,15 @@
return number > 0 ? number : 0; return number > 0 ? number : 0;
} }
function booleanValue(value) {
if (value === true || value === 1) return true;
if (value === false || value === 0) return false;
var text = String(value || '')
.trim()
.toLowerCase();
return text === 'true' || text === '1' || text === 'yes';
}
function firstValue(source, keys) { function firstValue(source, keys) {
if (!source) return ''; if (!source) return '';
for (var i = 0; i < keys.length; i += 1) { for (var i = 0; i < keys.length; i += 1) {
@ -219,6 +266,14 @@
); );
} }
function hasSubSellerAPI() {
return (
hasAPI() &&
window.HyAppAPI.wallet.getCoinSellerSubSellerPermission &&
window.HyAppAPI.wallet.listCoinSellerSubSellers
);
}
function setImage(image, fallback, src, name) { function setImage(image, fallback, src, name) {
if (!image || !fallback) return; if (!image || !fallback) return;
fallback.textContent = initial(name) || 'C'; fallback.textContent = initial(name) || 'C';
@ -536,12 +591,135 @@
}); });
} }
function normalizeSubSellerPermission(payload) {
var source = unwrapData(payload) || {};
var canInvite = booleanValue(
firstValue(source, [
'can_invite_sub_seller',
'canInviteSubSeller',
'can_open_sub_seller',
'canOpenSubSeller',
'can_invite',
'canInvite',
])
);
var canTransfer = booleanValue(
firstValue(source, [
'can_transfer_sub_seller',
'canTransferSubSeller',
'can_transfer',
'canTransfer',
])
);
var canList = booleanValue(
firstValue(source, [
'can_list_sub_sellers',
'canListSubSellers',
'can_list',
'canList',
])
);
var explicitVisible = firstValue(source, [
'visible',
'enabled',
'allowed',
'has_permission',
'hasPermission',
]);
return {
visible:
explicitVisible === '' || explicitVisible === undefined
? canInvite || canTransfer || canList
: booleanValue(explicitVisible),
canInvite: canInvite,
canTransfer: canTransfer,
};
}
function normalizeSubSeller(item, index) {
var source = item || {};
var displayID =
firstValue(source, [
'display_user_id',
'displayUserID',
'displayUserId',
'display_id',
'displayId',
]) || '';
var userID = firstValue(source, ['user_id', 'userId', 'id']) || '';
var name =
firstValue(source, ['username', 'nickname', 'name']) ||
t('coinseller_center.user_name_template', 'User {id}').replace(
'{id}',
displayID || userID || index + 1
);
return {
uid: displayID || userID,
userId: userID,
name: name,
avatar:
firstValue(source, [
'avatar',
'avatar_url',
'avatarUrl',
'profile_picture_url',
'profilePictureUrl',
]) || '',
status: firstValue(source, ['status']) || '',
balance: toNumber(
firstValue(source, [
'coin_seller_balance',
'coinSellerBalance',
'available_amount',
'availableAmount',
'balance',
'amount',
])
),
region:
firstValue(source, [
'country_display_name',
'countryDisplayName',
'region_name',
'regionName',
'country_name',
'countryName',
]) || '',
};
}
function normalizeSubSellers(payload) {
var source = unwrapData(payload) || {};
var items = Array.isArray(source.items)
? source.items
: Array.isArray(source.sub_sellers)
? source.sub_sellers
: Array.isArray(source.subSellers)
? source.subSellers
: Array.isArray(source.children)
? source.children
: Array.isArray(source)
? source
: [];
return items
.map(function (item, index) {
return normalizeSubSeller(item, index);
})
.filter(function (item) {
var status = String(item.status || '').toLowerCase();
// 列表接口的服务端契约只返回 active 直属关系;这里再过滤一次,避免异常数据把 pending 申请展示成可转账子币商。
return !status || status === 'active';
});
}
function applyMockData() { function applyMockData() {
data.profile = Object.assign({}, mock.profile); data.profile = Object.assign({}, mock.profile);
data.balance = Object.assign({}, mock.balance); data.balance = Object.assign({}, mock.balance);
data.users = mock.users.slice(); data.users = mock.users.slice();
data.quickAmounts = mock.quickAmounts.slice(); data.quickAmounts = mock.quickAmounts.slice();
data.records = mock.records.slice(); data.records = mock.records.slice();
data.subSellerPermission = Object.assign({}, mock.subSellerPermission);
data.subSellers = mock.subSellers.slice();
} }
function applyEmptyData() { function applyEmptyData() {
@ -550,6 +728,51 @@
data.users = []; data.users = [];
data.quickAmounts = mock.quickAmounts.slice(); data.quickAmounts = mock.quickAmounts.slice();
data.records = []; data.records = [];
data.subSellerPermission = emptySubSellerPermission();
data.subSellers = [];
}
function loadSubSellerCapability() {
if (!hasSubSellerAPI()) {
data.subSellerPermission = emptySubSellerPermission();
data.subSellers = [];
return Promise.resolve();
}
state.subSellerLoading = true;
renderSubSellers();
return window.HyAppAPI.wallet
.getCoinSellerSubSellerPermission()
.then(function (response) {
data.subSellerPermission =
normalizeSubSellerPermission(response);
// 子币商列表属于权限增强区;只有服务端明确给到能力时才拉取,避免普通币商看到空功能入口。
if (!data.subSellerPermission.visible) {
data.subSellers = [];
return null;
}
return window.HyAppAPI.wallet
.listCoinSellerSubSellers({
page: 1,
page_size: 50,
})
.then(function (listResponse) {
data.subSellers = normalizeSubSellers(listResponse);
});
})
.catch(function (error) {
if (window.console && window.console.warn) {
window.console.warn(
'[coinseller-center] sub seller api failed',
error
);
}
data.subSellerPermission = emptySubSellerPermission();
data.subSellers = [];
})
.finally(function () {
state.subSellerLoading = false;
renderSubSellers();
});
} }
function loadRealData() { function loadRealData() {
@ -584,6 +807,7 @@
return null; return null;
}) })
: Promise.resolve(null), : Promise.resolve(null),
loadSubSellerCapability(),
]) ])
.then(function (results) { .then(function (results) {
data.profile = normalizeProfile(results[0]); data.profile = normalizeProfile(results[0]);
@ -647,6 +871,87 @@
} }
} }
function createSubSellerRow(item) {
var row = document.createElement('article');
var avatar = document.createElement('div');
var main = document.createElement('div');
var title = document.createElement('div');
var meta = document.createElement('div');
var balance = document.createElement('div');
var action = document.createElement('button');
row.className = 'sub-seller-row';
avatar.className = 'record-avatar';
main.className = 'sub-seller-main';
title.className = 'record-title';
meta.className = 'record-meta';
balance.className = 'sub-seller-balance';
action.className = 'sub-seller-transfer';
action.type = 'button';
action.disabled =
!data.subSellerPermission.canTransfer ||
state.subSellerTransferSubmitting;
action.textContent = t('coinseller_center.transfer', 'Transfer');
renderAvatar(avatar, item);
title.textContent = item.name || '-';
meta.textContent = t(
'coinseller_center.uid_prefix',
'UID: {id}'
).replace('{id}', item.uid || '-');
balance.textContent =
t('coinseller_center.sub_seller_wallet', 'Wallet') +
': ' +
count(item.balance) +
' ' +
t('coinseller_center.coins_lower', 'coins');
action.addEventListener('click', function () {
openSubSellerTransfer(item);
});
main.appendChild(title);
main.appendChild(meta);
if (item.balance > 0) main.appendChild(balance);
row.appendChild(avatar);
row.appendChild(main);
if (data.subSellerPermission.canTransfer) row.appendChild(action);
return row;
}
function renderSubSellers() {
var section = $('subSellerSection');
if (!section) return;
var permission = data.subSellerPermission || emptySubSellerPermission();
section.hidden = !permission.visible;
$('inviteSubSellerButton').hidden = !permission.canInvite;
if (!permission.visible) return;
if (state.subSellerLoading) {
setStatus(
$('subSellerStatus'),
t('coinseller_center.loading', 'Loading...'),
''
);
} else {
setStatus(
$('subSellerStatus'),
data.subSellers.length
? ''
: t(
'coinseller_center.no_sub_sellers',
'No sub coin sellers yet.'
),
data.subSellers.length ? '' : 'warning'
);
}
replaceChildren(
$('subSellerList'),
data.subSellers.map(function (item) {
return createSubSellerRow(item);
})
);
}
function renderQuickAmounts() { function renderQuickAmounts() {
replaceChildren( replaceChildren(
$('quickAmounts'), $('quickAmounts'),
@ -917,6 +1222,15 @@
].join('-'); ].join('-');
} }
function subSellerCommandID(action) {
return [
'coin-seller-sub-seller',
action || 'action',
Date.now(),
Math.random().toString(36).slice(2, 8),
].join('-');
}
function buildTransferPayload(amount) { function buildTransferPayload(amount) {
return { return {
command_id: commandID(), command_id: commandID(),
@ -947,6 +1261,289 @@
}); });
} }
function appendSubSellerTransferRecord(amount, response) {
var target = state.subSellerTransferTarget;
if (!target) return;
var payload = unwrapData(response) || {};
var coins = toNumber(
firstValue(payload, ['amount', 'coin_amount', 'coinAmount'])
);
data.records.unshift({
id:
firstValue(payload, ['transaction_id', 'transactionId']) ||
'sub-seller-tx-' + Date.now(),
type: 'expenditure',
name: target.name,
uid: target.uid,
avatar: target.avatar,
coins: coins || amount,
amount: 0,
currency: '',
time: currentTimeLabel(),
});
}
function refreshSubSellerFromResponse(target, payload, amount) {
if (!target) return;
var balanceAfter = firstValue(payload, [
'sub_seller_balance_after',
'subSellerBalanceAfter',
'child_balance_after',
'childBalanceAfter',
'target_balance_after',
'targetBalanceAfter',
]);
data.subSellers = data.subSellers.map(function (item) {
if (String(item.uid) !== String(target.uid)) return item;
return Object.assign({}, item, {
balance:
balanceAfter !== '' && balanceAfter !== undefined
? toNumber(balanceAfter)
: item.balance + amount,
});
});
}
function inviteSubSeller() {
var input = $('inviteSubSellerInput');
var displayID = String(input.value || '').trim();
if (!data.subSellerPermission.canInvite) {
setStatus(
$('inviteSubSellerStatus'),
t('coinseller_center.invite_failed', 'Invite failed.'),
'warning'
);
return;
}
if (!displayID) {
setStatus(
$('inviteSubSellerStatus'),
t('coinseller_center.enter_user_id', 'Enter user ID'),
'warning'
);
return;
}
if (state.isMock) {
input.value = '';
closeModal('inviteSubSellerModal');
toast(
t(
'coinseller_center.invite_success',
'Official will review your invitation within 24 hours.'
)
);
return;
}
if (!hasAPI() || !window.HyAppAPI.wallet.inviteCoinSellerSubSeller) {
setStatus(
$('inviteSubSellerStatus'),
t('coinseller_center.invite_failed', 'Invite failed.'),
'warning'
);
return;
}
state.invitingSubSeller = true;
$('submitInviteSubSellerButton').disabled = true;
setStatus(
$('inviteSubSellerStatus'),
t('coinseller_center.loading', 'Loading...'),
''
);
// 邀请子币商现在只创建待审核申请command_id 保证移动端重试不会重复生成多个 pending 申请。
window.HyAppAPI.wallet
.inviteCoinSellerSubSeller({
command_id: subSellerCommandID('invite'),
target_display_user_id: displayID,
})
.then(function (response) {
input.value = '';
setStatus(
$('inviteSubSellerStatus'),
t(
'coinseller_center.invite_success',
'Official will review your invitation within 24 hours.'
),
'success'
);
// 申请提交成功不代表父子关系已经生效,列表必须继续以服务端 active 关系为准。
toast(
t(
'coinseller_center.invite_success',
'Official will review your invitation within 24 hours.'
)
);
closeModal('inviteSubSellerModal');
})
.catch(function (error) {
if (window.console && window.console.warn) {
window.console.warn(
'[coinseller-center] invite sub seller failed',
error
);
}
setStatus(
$('inviteSubSellerStatus'),
error && error.message
? error.message
: t(
'coinseller_center.invite_failed',
'Invite failed.'
),
'warning'
);
})
.finally(function () {
state.invitingSubSeller = false;
$('submitInviteSubSellerButton').disabled = false;
});
}
function openSubSellerTransfer(item) {
state.subSellerTransferTarget = item;
state.subSellerTransferAmount = '';
$('subSellerTransferAmountInput').value = '';
$('subSellerTransferTargetName').textContent =
t(
'coinseller_center.transfer_to_sub_seller',
'Transfer to sub seller'
) +
': ' +
item.name;
setStatus($('subSellerTransferStatus'), '', '');
renderSubSellerTransferState();
openModal('subSellerTransferModal');
window.setTimeout(function () {
$('subSellerTransferAmountInput').focus();
}, 80);
}
function parsedSubSellerTransferAmount() {
var input = $('subSellerTransferAmountInput');
if (input) {
var sanitized = String(input.value || '').replace(/[^\d]/g, '');
if (input.value !== sanitized) input.value = sanitized;
state.subSellerTransferAmount = sanitized;
}
return Number(state.subSellerTransferAmount || 0);
}
function renderSubSellerTransferState() {
var amount = parsedSubSellerTransferAmount();
$('submitSubSellerTransferButton').disabled =
state.loading ||
state.subSellerTransferSubmitting ||
!state.subSellerTransferTarget ||
amount <= 0;
}
function transferToSubSeller() {
var amount = parsedSubSellerTransferAmount();
var target = state.subSellerTransferTarget;
if (!target) {
setStatus(
$('subSellerTransferStatus'),
t(
'coinseller_center.select_sub_seller_first',
'Select a sub coin seller first.'
),
'warning'
);
return;
}
if (amount <= 0) {
setStatus(
$('subSellerTransferStatus'),
t(
'coinseller_center.valid_amount_required',
'Please enter a valid amount.'
),
'warning'
);
return;
}
if (amount > data.balance.coins) {
setStatus(
$('subSellerTransferStatus'),
t(
'coinseller_center.amount_exceeds_stock',
'Amount exceeds stock.'
),
'warning'
);
return;
}
if (state.isMock) {
data.balance.coins -= amount;
refreshSubSellerFromResponse(target, {}, amount);
appendSubSellerTransferRecord(amount, {});
finishSubSellerTransfer();
return;
}
if (
!hasAPI() ||
!window.HyAppAPI.wallet.transferCoinSellerToSubSeller
) {
setStatus($('subSellerTransferStatus'), '', '');
toast(t('coinseller_center.transfer_failed', 'Transfer failed.'));
return;
}
state.subSellerTransferSubmitting = true;
setStatus(
$('subSellerTransferStatus'),
t('coinseller_center.loading', 'Loading...'),
''
);
renderSubSellerTransferState();
// 子币商转账目标是对方的币商专用钱包不是玩家普通金币payload 单独命名,避免误走老的玩家充值接口。
window.HyAppAPI.wallet
.transferCoinSellerToSubSeller({
command_id: subSellerCommandID('transfer'),
target_display_user_id: String(target.uid || '').trim(),
amount: amount,
reason: 'sub coin seller stock transfer',
})
.then(function (response) {
var payload = unwrapData(response) || {};
var balanceAfter = firstValue(payload, [
'parent_balance_after',
'parentBalanceAfter',
'seller_balance_after',
'sellerBalanceAfter',
]);
data.balance.coins =
balanceAfter !== '' && balanceAfter !== undefined
? toNumber(balanceAfter)
: data.balance.coins - amount;
refreshSubSellerFromResponse(target, payload, amount);
appendSubSellerTransferRecord(amount, response);
finishSubSellerTransfer();
})
.catch(function (error) {
if (window.console && window.console.warn) {
window.console.warn(
'[coinseller-center] sub seller transfer failed',
error
);
}
setStatus($('subSellerTransferStatus'), '', '');
toast(
error && error.message
? error.message
: t(
'coinseller_center.transfer_failed',
'Transfer failed.'
)
);
})
.finally(function () {
state.subSellerTransferSubmitting = false;
renderSubSellerTransferState();
renderSubSellers();
});
}
function rechargeNow() { function rechargeNow() {
var amount = parsedCoinAmount(); var amount = parsedCoinAmount();
if (!state.target) { if (!state.target) {
@ -1058,6 +1655,23 @@
toast(t('coinseller_center.recharge_success', 'Recharge success.')); toast(t('coinseller_center.recharge_success', 'Recharge success.'));
} }
function finishSubSellerTransfer() {
state.subSellerTransferAmount = '';
$('subSellerTransferAmountInput').value = '';
setStatus(
$('subSellerTransferStatus'),
t('coinseller_center.transfer_success', 'Transfer success.'),
'success'
);
renderBalance();
renderSubSellers();
renderRecent();
renderSubSellerTransferState();
if (!$('historyModal').hidden) renderHistory();
closeModal('subSellerTransferModal');
toast(t('coinseller_center.transfer_success', 'Transfer success.'));
}
function currentTimeLabel() { function currentTimeLabel() {
var now = new Date(); var now = new Date();
return ( return (
@ -1112,6 +1726,14 @@
$('helpButton').addEventListener('click', function () { $('helpButton').addEventListener('click', function () {
openModal('helpModal'); openModal('helpModal');
}); });
$('inviteSubSellerButton').addEventListener('click', function () {
$('inviteSubSellerInput').value = '';
setStatus($('inviteSubSellerStatus'), '', '');
openModal('inviteSubSellerModal');
window.setTimeout(function () {
$('inviteSubSellerInput').focus();
}, 80);
});
document document
.querySelectorAll('[data-close-modal]') .querySelectorAll('[data-close-modal]')
.forEach(function (node) { .forEach(function (node) {
@ -1153,6 +1775,26 @@
event.preventDefault(); event.preventDefault();
rechargeNow(); rechargeNow();
}); });
$('inviteSubSellerForm').addEventListener('submit', function (event) {
event.preventDefault();
inviteSubSeller();
});
$('subSellerTransferAmountInput').addEventListener(
'input',
function () {
state.subSellerTransferAmount = $(
'subSellerTransferAmountInput'
).value.replace(/[^\d]/g, '');
$('subSellerTransferAmountInput').value =
state.subSellerTransferAmount;
setStatus($('subSellerTransferStatus'), '', '');
renderSubSellerTransferState();
}
);
$('subSellerTransferForm').addEventListener('submit', function (event) {
event.preventDefault();
transferToSubSeller();
});
document.querySelectorAll('[data-history-tab]').forEach(function (tab) { document.querySelectorAll('[data-history-tab]').forEach(function (tab) {
tab.addEventListener('click', function () { tab.addEventListener('click', function () {
state.historyTab = tab.getAttribute('data-history-tab'); state.historyTab = tab.getAttribute('data-history-tab');
@ -1180,6 +1822,8 @@
if (event.key !== 'Escape') return; if (event.key !== 'Escape') return;
closeModal('historyModal'); closeModal('historyModal');
closeModal('helpModal'); closeModal('helpModal');
closeModal('inviteSubSellerModal');
closeModal('subSellerTransferModal');
}); });
} }
@ -1189,6 +1833,8 @@
renderQuickAmounts(); renderQuickAmounts();
renderTarget(); renderTarget();
renderRechargeState(); renderRechargeState();
renderSubSellers();
renderSubSellerTransferState();
renderRecent(); renderRecent();
} }

View File

@ -15,6 +15,7 @@
} }
.balance-card, .balance-card,
.sub-seller-card,
.search-card, .search-card,
.recent-card { .recent-card {
padding: 16px; padding: 16px;
@ -118,6 +119,7 @@
} }
.search-card, .search-card,
.sub-seller-card,
.recent-card { .recent-card {
display: grid; display: grid;
gap: 14px; gap: 14px;
@ -205,6 +207,10 @@
display: none; display: none;
} }
.sub-seller-card[hidden] {
display: none;
}
.target-avatar, .target-avatar,
.record-avatar { .record-avatar {
display: flex; display: flex;
@ -373,6 +379,54 @@
gap: 10px; gap: 10px;
} }
.sub-seller-list {
display: grid;
gap: 10px;
}
.sub-seller-row {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-width: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfcfc;
}
.sub-seller-main {
min-width: 0;
}
.sub-seller-balance {
margin-top: 5px;
overflow: hidden;
color: var(--primary-deep);
font-size: 12px;
font-weight: 900;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub-seller-transfer {
min-height: 38px;
padding: 0 12px;
border-radius: 8px;
background: var(--button);
color: #fff;
font-size: 13px;
font-weight: 900;
white-space: nowrap;
}
.sub-seller-transfer:disabled {
background: var(--button-disabled);
color: var(--muted);
}
.record-row { .record-row {
display: grid; display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto; grid-template-columns: 42px minmax(0, 1fr) auto;
@ -515,6 +569,52 @@
padding: 16px 18px 22px; padding: 16px 18px 22px;
} }
.sub-seller-form {
display: grid;
gap: 12px;
padding: 16px 18px calc(18px + env(safe-area-inset-bottom));
}
.sub-seller-form p {
margin: 0;
color: var(--muted);
font-size: 13px;
font-weight: 850;
line-height: 1.35;
}
.sub-seller-form input {
width: 100%;
min-width: 0;
height: 52px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(251, 249, 255, 0.9);
color: var(--text);
font-size: 15px;
font-weight: 800;
}
.sub-seller-form input::placeholder {
color: #b5b0bd;
}
.sub-seller-form button {
width: 100%;
min-height: 56px;
border-radius: 28px;
background: var(--button);
color: #fff;
font-size: 15px;
font-weight: 900;
}
.sub-seller-form button:disabled {
background: var(--button-disabled);
color: var(--muted);
}
.help-content h3, .help-content h3,
.help-content p { .help-content p {
margin: 0; margin: 0;
@ -565,4 +665,13 @@ body.modal-open .coinseller-center {
.history-search-form { .history-search-form {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.sub-seller-row {
grid-template-columns: 42px minmax(0, 1fr);
}
.sub-seller-transfer {
grid-column: 2;
justify-self: start;
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -6,17 +6,17 @@
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/> />
<title>Control Panel</title> <title>Manage Center</title>
<link rel="stylesheet" href="../../common/theme.css?v=20260608" />
<link rel="stylesheet" href="../common/center.css?v=20260608" />
<link <link
rel="stylesheet" rel="stylesheet"
href="./style.css?v=20260618-manager-permissions" href="../../common/theme.css?v=20260709-skeleton-mascot"
/> />
<link rel="stylesheet" href="../common/center.css?v=20260608" />
<link rel="stylesheet" href="./style.css?v=20260708-manager-actions" />
</head> </head>
<body> <body>
<div class="manager-center" aria-label="Control Panel"> <div class="manager-center" aria-label="Manage Center">
<div class="hero-bg" aria-hidden="true"></div> <div class="hero-bg" aria-hidden="true"></div>
<nav class="title-bar"> <nav class="title-bar">
@ -28,10 +28,12 @@
data-i18n-aria="manager_center.back" data-i18n-aria="manager_center.back"
> >
<svg viewBox="0 0 32 32" aria-hidden="true"> <svg viewBox="0 0 32 32" aria-hidden="true">
<path d="M20 8 12 16l8 8" /> <path
d="M20.3 6.8a2 2 0 0 1 0 2.8L14 15.9c-.1.1-.1.2 0 .3l6.3 6.3a2 2 0 1 1-2.8 2.8L9.8 17.6a2.3 2.3 0 0 1 0-3.2l7.7-7.7a2 2 0 0 1 2.8.1Z"
/>
</svg> </svg>
</button> </button>
<h1 data-i18n="manager_center.title">Control Panel</h1> <h1 data-i18n="manager_center.title">Manage Center</h1>
<div class="language-switcher"> <div class="language-switcher">
<button <button
class="language-button" class="language-button"
@ -39,10 +41,16 @@
aria-label="Change language" aria-label="Change language"
aria-expanded="false" aria-expanded="false"
data-language-toggle data-language-toggle
data-current-lang
data-i18n-aria="manager_center.change_language" data-i18n-aria="manager_center.change_language"
> >
EN <svg viewBox="0 0 32 32" aria-hidden="true">
<path
d="M16 11.2a4.8 4.8 0 1 0 0 9.6 4.8 4.8 0 0 0 0-9.6Z"
/>
<path
d="M26.2 17.7v-3.4l-3.2-.7a8 8 0 0 0-.8-1.9l1.8-2.8-2.4-2.4-2.8 1.8c-.6-.3-1.2-.6-1.9-.8L16.3 4h-3.4l-.7 3.5c-.7.2-1.3.5-1.9.8L7.5 6.5 5.1 8.9l1.8 2.8c-.3.6-.6 1.2-.8 1.9l-3.3.7v3.4l3.3.7c.2.7.5 1.3.8 1.9l-1.8 2.8 2.4 2.4 2.8-1.8c.6.3 1.2.6 1.9.8l.7 3.5h3.4l.7-3.5c.7-.2 1.3-.5 1.9-.8l2.8 1.8 2.4-2.4-1.8-2.8c.3-.6.6-1.2.8-1.9l3.2-.7Z"
/>
</svg>
</button> </button>
<div class="language-menu" data-language-menu hidden> <div class="language-menu" data-language-menu hidden>
<button type="button" data-lang-option="en">EN</button> <button type="button" data-lang-option="en">EN</button>
@ -56,262 +64,447 @@
</nav> </nav>
<main class="content"> <main class="content">
<section class="card manager-panel"> <section class="card manager-profile-card">
<div class="manager-grid" aria-label="Manager tools"> <div class="manager-avatar-shell">
<img
class="manager-avatar-image"
id="managerAvatar"
src=""
alt=""
hidden
/>
<div
class="manager-avatar-fallback"
id="managerAvatarFallback"
aria-hidden="true"
></div>
</div>
<div class="manager-profile-copy">
<strong id="managerName"></strong>
<span class="manager-role-badge">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 9 4 4 3-7 3 7 4-4-2 9H7Z" />
</svg>
<span data-i18n="manager_center.role_manager"
>Manager</span
>
</span>
<span class="manager-id-line">
<span>ID:</span>
<span id="managerDisplayId"></span>
<button
class="manager-copy-button"
type="button"
aria-label="Copy manager ID"
data-i18n-aria="manager_center.copy_id"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M8.5 7.2h9.1c1.2 0 2.2 1 2.2 2.2v9.1c0 1.2-1 2.2-2.2 2.2H8.5c-1.2 0-2.2-1-2.2-2.2V9.4c0-1.2 1-2.2 2.2-2.2Z"
/>
<path
d="M5.2 3.3h8.7c1 0 1.8.8 1.8 1.8v.8H7.8c-1.6 0-2.9 1.3-2.9 2.9v7.9H4c-1 0-1.8-.8-1.8-1.8V6.3c0-1.7 1.3-3 3-3Z"
/>
</svg>
</button>
</span>
</div>
</section>
<section class="manager-kpi-grid" aria-label="Manager summary">
<article class="card manager-kpi-card kpi-wallet">
<span class="manager-kpi-icon" aria-hidden="true">
<svg viewBox="0 0 36 36">
<path
d="M8 13.5h18.2a4.2 4.2 0 0 1 4.2 4.2v10.6H8Z"
/>
<path d="M8.2 12.8 23.8 7l2.2 6.5Z" />
<path
class="icon-cutout"
d="M24 19.5h6.4v5.4H24a2.7 2.7 0 0 1 0-5.4Z"
/>
<circle
class="icon-dot"
cx="24.3"
cy="22.2"
r="1.1"
/>
</svg>
</span>
<div class="manager-kpi-copy">
<span data-i18n="manager_center.this_month"
>This Month</span
>
<strong id="managerThisMonthSalary"></strong>
<em data-i18n="manager_center.team_salary"
>Team Salary</em
>
</div>
<i class="manager-bars" aria-hidden="true"></i>
</article>
<article class="card manager-kpi-card kpi-report">
<span class="manager-kpi-icon" aria-hidden="true">
<svg viewBox="0 0 36 36">
<path d="M9.5 5.5h15.4l4.6 4.6v20.4h-20Z" />
<path class="icon-cutout" d="M24 6.5v5h5Z" />
<path
class="icon-cutout"
d="M15 15h9v2.2h-9ZM15 20h6.8v2.2H15Z"
/>
<path
class="icon-cutout"
d="m15.3 27.3 3.4-3.4 2.9 2.8 5-7 1.9 1.4-6.6 9.1-3.1-3-2.1 2.1Z"
/>
</svg>
</span>
<div class="manager-kpi-copy">
<span data-i18n="manager_center.last_month"
>Last Month</span
>
<strong id="managerLastMonthSalary"></strong>
<em data-i18n="manager_center.team_salary"
>Team Salary</em
>
</div>
<i class="manager-bars" aria-hidden="true"></i>
</article>
<article class="card manager-kpi-card kpi-hosts">
<span class="manager-kpi-icon" aria-hidden="true">
<svg viewBox="0 0 36 36">
<circle cx="13.4" cy="12.3" r="5.5" />
<path
d="M4.8 30.5c1.3-6.4 4.2-9.6 8.6-9.6s7.3 3.2 8.6 9.6Z"
/>
<circle cx="24.7" cy="12.7" r="4.8" />
<path
d="M21.4 30.5c1.1-5.4 3.5-8.1 7.2-8.1 3 0 5.1 2 6.4 6.1v2Z"
/>
</svg>
</span>
<div class="manager-kpi-copy">
<span data-i18n="manager_center.active_hosts"
>Active Hosts</span
>
<strong id="managerActiveHosts"></strong>
<em data-i18n="manager_center.total_active"
>Total Active</em
>
</div>
<i class="manager-bars" aria-hidden="true"></i>
</article>
<article class="card manager-kpi-card kpi-growth">
<span class="manager-kpi-icon" aria-hidden="true">
<svg viewBox="0 0 36 36">
<path
d="M7.5 25.5 15.3 17.7l4.4 4.3 8.1-11h3.4v3.4L20.2 29 15.4 24.2 10 29.5Z"
/>
<path d="M23.2 9.6h8v8h-3v-5h-5Z" />
</svg>
</span>
<div class="manager-kpi-copy">
<span data-i18n="manager_center.growth"
>Growth</span
>
<strong id="managerGrowth"></strong>
<em data-i18n="manager_center.vs_last_month"
>vs Last Month</em
>
</div>
<i class="manager-bars" aria-hidden="true"></i>
</article>
</section>
<!-- <section class="card manager-assets-card">
<button class="manager-asset-item" type="button">
<span
class="manager-asset-icon wallet"
aria-hidden="true"
>
<svg viewBox="0 0 36 36">
<path d="M7 12h18.5a4 4 0 0 1 4 4v12H7Z" />
<path d="M7.4 11.4 24.5 7l1.9 5Z" />
<path
class="icon-cutout"
d="M23.4 18.3h6.1v5.6h-6.1a2.8 2.8 0 0 1 0-5.6Z"
/>
<circle
class="icon-dot"
cx="23.8"
cy="21.1"
r="1"
/>
</svg>
</span>
<span>
<strong data-i18n="manager_center.wallet"
>Wallet</strong
>
<em id="managerWallet"></em>
</span>
<i aria-hidden="true"></i>
</button>
<button class="manager-asset-item" type="button">
<span
class="manager-asset-icon bank"
aria-hidden="true"
>
<svg viewBox="0 0 36 36">
<path d="M6 11h24v16H6Z" />
<path
class="icon-cutout"
d="M6 15h24v3H6ZM10 22h7v2.4h-7Z"
/>
</svg>
</span>
<span>
<strong data-i18n="manager_center.bank_cards"
>Bank Cards</strong
>
<em id="managerBankCards"></em>
</span>
<i aria-hidden="true"></i>
</button>
</section> -->
<section class="card manager-actions-card">
<!-- 操作入口按 overview.permissions 显隐renderActionPermissions 按 data-action 对应的权限位隐藏未授权入口 -->
<div class="manager-action-grid" aria-label="Manager tools">
<button <button
class="manager-tile" class="manager-action-item action-send-frame"
type="button" type="button"
data-action="send-frame" data-action="send-frame"
> >
<span <span
class="tile-icon frame-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<path d="M10 10h28v28H10Z" />
<path d="M16 16h16v16H16Z" />
<path <path
d="M10 18h6M32 18h6M10 30h6M32 30h6" d="M9 4.5h18A4.5 4.5 0 0 1 31.5 9v18a4.5 4.5 0 0 1-4.5 4.5H9A4.5 4.5 0 0 1 4.5 27V9A4.5 4.5 0 0 1 9 4.5Z"
/>
<circle
class="icon-cutout"
cx="18"
cy="14.6"
r="4.8"
/>
<path
class="icon-cutout"
d="M10.4 28c1.2-5.1 3.8-7.6 7.6-7.6s6.4 2.5 7.6 7.6Z"
/> />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.send_frame"
class="tile-title"
data-i18n="manager_center.send_frame"
>Send Frame</span >Send Frame</span
> >
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-send-car"
type="button" type="button"
data-action="send-car" data-action="send-car"
> >
<span class="tile-icon car-icon" aria-hidden="true"> <span
<svg viewBox="0 0 48 48"> class="manager-action-icon"
<path d="M9 28h30l-4-10H13Z" /> aria-hidden="true"
<path d="M13 28v7M35 28v7" /> >
<svg viewBox="0 0 36 36">
<path <path
d="M15 35a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z" d="m6.1 16 2.2-6a3.2 3.2 0 0 1 3-2.1h13.4a3.2 3.2 0 0 1 3 2.1l2.2 6c1.6.5 2.7 1.8 2.7 3.5v6.1a1.8 1.8 0 0 1-1.8 1.8h-1.6a1.8 1.8 0 0 1-1.8-1.8v-1.1H8.6v1.1a1.8 1.8 0 0 1-1.8 1.8H5.2a1.8 1.8 0 0 1-1.8-1.8v-6.1c0-1.7 1.1-3 2.7-3.5Z"
/> />
<path <path
d="M33 35a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z" class="icon-cutout"
d="m9.4 15.3 1.5-4.3c.2-.6.8-1 1.4-1h11.4c.6 0 1.2.4 1.4 1l1.5 4.3Z"
/>
<circle
class="icon-cutout"
cx="9.9"
cy="20.3"
r="1.7"
/>
<circle
class="icon-cutout"
cx="26.1"
cy="20.3"
r="1.7"
/> />
<path d="M18 18h12" />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.send_car"
class="tile-title"
data-i18n="manager_center.send_car"
>Send Car</span >Send Car</span
> >
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-send-badge"
type="button" type="button"
data-action="send-badge" data-action="send-badge"
> >
<span <span
class="tile-icon badge-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<path <path
d="M24 7 37 13v10c0 9-5.2 15-13 18-7.8-3-13-9-13-18V13Z" d="m12.6 20.9-2.8 10 5.3-2.3 2.9 3.9 2.9-3.9 5.3 2.3-2.8-10Z"
/> />
<circle cx="18" cy="13.4" r="9.4" />
<path <path
d="m24 16 2.5 5.1 5.6.8-4.1 4 .9 5.7-4.9-2.7-4.9 2.7.9-5.7-4.1-4 5.6-.8Z" class="icon-cutout"
d="m18 8.4 1.6 3.1 3.4.5-2.5 2.4.6 3.4-3.1-1.6-3.1 1.6.6-3.4-2.5-2.4 3.4-.5Z"
/> />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.send_badge"
class="tile-title"
data-i18n="manager_center.send_badge"
>Send Badge</span >Send Badge</span
> >
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-send-vip"
type="button" type="button"
data-action="send-vip" data-action="send-vip"
> >
<span class="tile-icon vip-icon" aria-hidden="true"> <span
<svg viewBox="0 0 48 48"> class="manager-action-icon"
aria-hidden="true"
>
<svg viewBox="0 0 36 36">
<path <path
d="m9 18 8 8 7-14 7 14 8-8-4 20H13Z" d="m4.8 11.4 6.4 4.9L18 7.6l6.8 8.7 6.4-4.9-2.6 14.3a2.6 2.6 0 0 1-2.6 2.1H10a2.6 2.6 0 0 1-2.6-2.1Z"
/>
<circle
class="icon-cutout"
cx="18"
cy="21.4"
r="2"
/> />
<path d="M13 38h22" />
<path d="M20 29h8" />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.send_vip"
class="tile-title"
data-i18n="manager_center.send_vip"
>Send VIP</span >Send VIP</span
> >
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-update-level"
type="button" type="button"
data-action="update-user-level" data-action="update-user-level"
> >
<span <span
class="tile-icon level-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<path d="M5.5 29.5V20h5.4v9.5Z" />
<path d="M15.3 29.5V13.8h5.4v15.7Z" />
<path d="M25.1 29.5V7.5h5.4v22Z" />
<path <path
d="M24 7a10 10 0 1 0 0 20 10 10 0 0 0 0-20Z" d="M6.2 13.2 12.9 6.5H8.5V3.7h9v9h-2.8V8.5l-6.6 6.6Z"
/> />
<path
d="M8 42c2.4-8 8.2-12 16-12s13.6 4 16 12"
/>
<path d="M35 10v7M31.5 13.5h7" />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.user_level"
class="tile-title" >User Level</span
data-i18n="manager_center.update_user_level"
>Update User Level</span
> >
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-bd"
type="button" type="button"
data-action="add-bd-leader" data-action="add-bd-leader"
> >
<span <span
class="tile-icon bd-leader-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<circle cx="15" cy="11.5" r="6" />
<path <path
d="M24 7a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z" d="M5.5 30.5c1.4-7.1 4.6-10.6 9.5-10.6 4.2 0 7.1 2.5 8.8 7.5v3.1Z"
/> />
<circle cx="27" cy="24.8" r="5.8" />
<path <path
d="M9 42c2.3-8 7.4-12 15-12s12.7 4 15 12" class="icon-cutout"
d="M26 20.8h2v3h3v2h-3v3h-2v-3h-3v-2h3Z"
/> />
<path d="M36 8v10M31 13h10" />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.bd_leader"
class="tile-title" >BD Leader</span
data-i18n="manager_center.add_bd_leader"
>Add BD Leader</span
> >
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-invite-admin"
type="button" type="button"
data-action="add-admin" data-action="add-admin"
> >
<span <span
class="tile-icon admin-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<circle cx="15" cy="11.5" r="6" />
<path <path
d="M24 7a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z" d="M5.5 30.5c1.4-7.1 4.6-10.6 9.5-10.6s8.1 3.5 9.5 10.6Z"
/> />
<circle cx="26.5" cy="22.8" r="5.4" />
<path <path
d="M9 42c2.3-8 7.4-12 15-12s12.7 4 15 12" class="icon-cutout"
/> d="m26.5 19.3 1 2 2.2.3-1.6 1.5.4 2.2-2-1-2 1 .4-2.2-1.6-1.5 2.2-.3Z"
<path
d="m35 9 2.2 4.4 4.8.7-3.5 3.4.8 4.8-4.3-2.3-4.3 2.3.8-4.8-3.5-3.4 4.8-.7Z"
/> />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.admin">Admin</span>
class="tile-title"
data-i18n="manager_center.add_admin"
>Add Admin</span
>
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-super"
type="button" type="button"
data-action="add-superadmin" data-action="add-superadmin"
> >
<span <span
class="tile-icon superadmin-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<path <path
d="M24 7a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z" d="M18 5 29 10v8.2c0 7-4.3 10.8-11 13.3-6.7-2.5-11-6.3-11-13.3V10Z"
/> />
<path <path
d="M9 42c2.3-8 7.4-12 15-12s12.7 4 15 12" class="icon-cutout"
d="m18 11 1.8 3.6 4 .6-2.9 2.8.7 4-3.6-1.9-3.6 1.9.7-4-2.9-2.8 4-.6Z"
/> />
<path
d="m34 8 2.5 5 5.5.8-4 3.9.9 5.5-4.9-2.6-4.9 2.6.9-5.5-4-3.9 5.5-.8Z"
/>
<path d="M34 12v8M30 16h8" />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.super_admin"
class="tile-title" >Super Admin</span
data-i18n="manager_center.add_superadmin"
>Add Superadmin</span
> >
</button> </button>
<button <button
class="manager-tile manager-tile-wide" class="manager-action-item action-block-users"
type="button"
data-action="transfer-user-country"
>
<span
class="tile-icon country-icon"
aria-hidden="true"
>
<svg viewBox="0 0 48 48">
<path
d="M24 42c8-8 13-15 13-23A13 13 0 1 0 11 19c0 8 5 15 13 23Z"
/>
<path d="M18 19h12" />
<path d="M24 13v12" />
</svg>
</span>
<span
class="tile-title"
data-i18n="manager_center.transfer_user_country"
>Transfer User Country</span
>
</button>
<button
class="manager-tile manager-tile-wide"
type="button" type="button"
data-action="block-user" data-action="block-user"
> >
<span <span
class="tile-icon block-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<circle cx="16" cy="11.5" r="5.8" />
<path <path
d="M18 22a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z" d="M7.2 28.8c1.3-6.9 4.2-10.3 8.8-10.3s7.5 3.4 8.8 10.3Z"
/>
<circle
class="icon-cutout"
cx="26"
cy="23.5"
r="7.4"
/> />
<path <path
d="M5 42c1.8-9.2 6.2-13.8 13-13.8 4.3 0 7.6 1.8 10 5.3" d="M21.4 18.9a7.4 7.4 0 0 1 9.2 9.2Z"
/> />
<path
d="M35 23a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z"
/>
<path d="m29 29 12 12" />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.block_users"
class="tile-title" >Block Users</span
data-i18n="manager_center.block_user"
>Block User</span
> >
</button> </button>
</div> </div>
@ -519,10 +712,12 @@
</div> </div>
<script src="../../common/toast.js"></script> <script src="../../common/toast.js"></script>
<script src="../../common/api.js?v=20260624-manager-badge-country"></script> <script src="../../common/loading.js?v=20260709-lalu-loading"></script>
<script src="../../common/skeleton.js?v=20260709-skeleton-mascot"></script>
<script src="../../common/api.js?v=20260710-manager-resource-pages"></script>
<script src="../../common/params.js?v=20260608-manager-real"></script> <script src="../../common/params.js?v=20260608-manager-real"></script>
<script src="../../common/jsbridge.js"></script> <script src="../../common/jsbridge.js"></script>
<script src="../../common/i18n.js?v=20260608"></script> <script src="../../common/i18n.js?v=20260608"></script>
<script src="./script.js?v=20260705-manager-vip-permission"></script> <script src="./script.js?v=20260710-manager-resource-pages"></script>
</body> </body>
</html> </html>

View File

@ -50,6 +50,7 @@
overview: null, overview: null,
permissions: {}, permissions: {},
resourcesByType: {}, resourcesByType: {},
resourceRequestsByType: {},
countries: [], countries: [],
}; };
var blockState = { var blockState = {
@ -70,7 +71,7 @@
resourceType: '', resourceType: '',
title: '', title: '',
searchedUser: null, searchedUser: null,
selectedResourceId: '', selectedResourceIds: [],
selectedResourceDurationMs: DEFAULT_RESOURCE_GRANT_DURATION_MS, selectedResourceDurationMs: DEFAULT_RESOURCE_GRANT_DURATION_MS,
selectedVipLevel: 0, selectedVipLevel: 0,
submitting: false, submitting: false,
@ -148,6 +149,46 @@
return user.avatar || user.target_avatar || ''; return user.avatar || user.target_avatar || '';
} }
function resourceImageUrl(resource) {
return (
resource.preview_url ||
resource.previewUrl ||
resource.asset_url ||
resource.assetUrl ||
resource.icon_url ||
resource.iconUrl ||
''
);
}
function resourceKindLabel() {
if (grantState.action === 'send-frame') return 'Frame';
if (grantState.action === 'send-car') return 'Car';
if (grantState.action === 'send-badge') return 'Badge';
return 'Resource';
}
function currentVip(user) {
return user.current_vip || user.currentVip || user.vip || null;
}
function currentVipLevel(user) {
var vip = currentVip(user);
if (!vip || !vip.active) return 0;
return Number(vip.level) || 0;
}
function currentVipStatusText(user) {
var vip = currentVip(user);
if (!vip || !vip.active || !vip.level) return 'Current VIP: None';
var label = vip.name || 'VIP ' + vip.level;
var expiresAt = Number(vip.expires_at_ms || vip.expiresAtMs || 0);
if (expiresAt > 0) {
return label + ' active · Expires ' + formatDateTime(expiresAt);
}
return label + ' active';
}
function initial(text) { function initial(text) {
return String(text || 'M') return String(text || 'M')
.trim() .trim()
@ -175,6 +216,246 @@
node.textContent = initial(userName(user) || userDisplayId(user)); node.textContent = initial(userName(user) || userDisplayId(user));
} }
function textValue(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
return String(value);
}
function isLocalPreviewError(error) {
var host = window.location && window.location.hostname;
var isLocalHost =
host === 'localhost' ||
host === '127.0.0.1' ||
host === '::1' ||
host === '';
var message = (error && error.message) || '';
return isLocalHost && /failed to fetch|network/i.test(message);
}
function firstValue(source, keys) {
source = source || {};
for (var i = 0; i < keys.length; i += 1) {
var key = keys[i];
if (
Object.prototype.hasOwnProperty.call(source, key) &&
source[key] !== undefined &&
source[key] !== null &&
source[key] !== ''
) {
return source[key];
}
}
return undefined;
}
function firstNestedValue(sources, keys) {
for (var i = 0; i < sources.length; i += 1) {
var value = firstValue(sources[i], keys);
if (value !== undefined) return value;
}
return undefined;
}
function formatMoney(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
if (typeof value === 'string' && value.indexOf('$') >= 0) {
return value;
}
var number = Number(String(value).replace(/,/g, ''));
if (!Number.isFinite(number)) return String(value);
return (
'$' +
number.toLocaleString('en-US', {
maximumFractionDigits: number % 1 === 0 ? 0 : 2,
minimumFractionDigits: number % 1 === 0 ? 0 : 2,
})
);
}
function formatPercent(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
if (typeof value === 'string' && value.indexOf('%') >= 0) {
// 服务端已带符号下发;只对正数补 +,零和负数原样透传,避免出现 "+0%"。
var parsed = parseFloat(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return value;
}
return '+' + value.replace(/^\+/, '');
}
var number = Number(value);
if (!Number.isFinite(number)) return String(value);
if (Math.abs(number) > 0 && Math.abs(number) <= 1) {
number *= 100;
}
return (number > 0 ? '+' : '') + Math.round(number) + '%';
}
function setText(id, value) {
var node = $(id);
if (node) node.textContent = value;
}
function updateManagerAvatar(profile) {
var image = $('managerAvatar');
var fallback = $('managerAvatarFallback');
if (!image || !fallback) return;
// 没有头像时展示首字母占位,不再使用演示头像假数据。
var url = avatarUrl(profile);
if (url) {
image.src = url;
image.hidden = false;
fallback.hidden = true;
return;
}
image.hidden = true;
fallback.hidden = false;
fallback.textContent = initial(
userName(profile) || userDisplayId(profile)
);
}
function dashboardSources() {
var overview = pageState.overview || {};
return [
overview.dashboard,
overview.summary,
overview.metrics,
overview.statistics,
overview.wallet,
overview,
];
}
function renderDashboard() {
var overview = pageState.overview || {};
var profile =
overview.user ||
overview.profile ||
overview.manager ||
overview.current_user ||
overview.currentUser ||
overview;
updateManagerAvatar(profile || {});
setText(
'managerName',
textValue(
firstValue(profile || {}, [
'username',
'name',
'nickname',
'target_username',
]),
'--'
)
);
setText(
'managerDisplayId',
textValue(
userDisplayId(profile || {}) || userId(profile || {}),
'--'
)
);
var sources = dashboardSources();
// 页面不再内置任何设计假数据:加载中由骨架屏占位,渲染时字段缺失(聚合降级/请求失败/无 API
// 一律显示中性占位符,避免把演示数值误当真实团队工资。
var fallback = {
thisMonth: '--',
lastMonth: '--',
hosts: '--',
growth: '--',
wallet: '--',
bankCards: '--',
};
setText(
'managerThisMonthSalary',
formatMoney(
firstNestedValue(sources, [
'this_month_salary',
'thisMonthSalary',
'current_month_salary',
'currentMonthSalary',
'team_salary_this_month',
'teamSalaryThisMonth',
]),
fallback.thisMonth
)
);
setText(
'managerLastMonthSalary',
formatMoney(
firstNestedValue(sources, [
'last_month_salary',
'lastMonthSalary',
'previous_month_salary',
'previousMonthSalary',
'team_salary_last_month',
'teamSalaryLastMonth',
]),
fallback.lastMonth
)
);
setText(
'managerActiveHosts',
textValue(
firstNestedValue(sources, [
'active_hosts',
'activeHosts',
'total_active_hosts',
'totalActiveHosts',
'host_count',
'hostCount',
]),
fallback.hosts
)
);
setText(
'managerGrowth',
formatPercent(
firstNestedValue(sources, [
'growth',
'growth_rate',
'growthRate',
'salary_growth',
'salaryGrowth',
'month_growth',
'monthGrowth',
]),
fallback.growth
)
);
setText(
'managerWallet',
formatMoney(
firstNestedValue(sources, [
'wallet',
'wallet_balance',
'walletBalance',
'balance',
]),
fallback.wallet
)
);
setText(
'managerBankCards',
textValue(
firstNestedValue(sources, [
'bank_cards',
'bankCards',
'bank_card_count',
'bankCardCount',
]),
fallback.bankCards
)
);
}
function formatDateTime(ms) { function formatDateTime(ms) {
if (!ms) return 'Never'; if (!ms) return 'Never';
var date = new Date(Number(ms)); var date = new Date(Number(ms));
@ -187,10 +468,17 @@
} }
function setPageLoading(loading) { function setPageLoading(loading) {
// 首屏加载态用页面级骨架屏整层覆盖common/skeleton.js数据就绪后整层淡出
// is-loading 状态类保留给页面内其他降级样式使用。
pageState.loading = loading; pageState.loading = loading;
var container = root(); var container = root();
if (!container) return; if (container) container.classList.toggle('is-loading', loading);
container.classList.toggle('is-loading', loading); if (!window.HyAppSkeleton) return;
if (loading) {
window.HyAppSkeleton.show();
} else {
window.HyAppSkeleton.hide();
}
} }
function setDenied(denied) { function setDenied(denied) {
@ -201,6 +489,7 @@
} }
function normalizeUser(item) { function normalizeUser(item) {
var vip = item.current_vip || item.currentVip || item.vip || null;
return { return {
user_id: item.user_id || item.userId || item.target_user_id || '', user_id: item.user_id || item.userId || item.target_user_id || '',
display_user_id: display_user_id:
@ -212,6 +501,16 @@
avatar: item.avatar || item.target_avatar || '', avatar: item.avatar || item.target_avatar || '',
country: item.country || '', country: item.country || '',
levels: item.levels || {}, levels: item.levels || {},
current_vip: vip
? {
level: Number(vip.level) || 0,
name: vip.name || '',
active: Boolean(vip.active),
started_at_ms: vip.started_at_ms || vip.startedAtMs || 0,
expires_at_ms: vip.expires_at_ms || vip.expiresAtMs || 0,
updated_at_ms: vip.updated_at_ms || vip.updatedAtMs || 0,
}
: null,
}; };
} }
@ -322,16 +621,21 @@
if (!api || !api.searchUsers) { if (!api || !api.searchUsers) {
return Promise.reject(new Error('API not available.')); return Promise.reject(new Error('API not available.'));
} }
return api.searchUsers(keyword, 20, scope, action).then(function (data) { return api
var items = (data && data.items) || []; .searchUsers(keyword, 20, scope, action)
return items.map(normalizeUser); .then(function (data) {
}); var items = (data && data.items) || [];
return items.map(normalizeUser);
});
} }
function loadOverview() { function loadOverview() {
if (!api || !api.overview) { if (!api || !api.overview) {
setDenied(true); // 无 API 注入(本地纯静态预览等)时立即渲染一次,把骨架屏收敛成中性占位符。
toast('Permission denied.'); pageState.permissions = normalizePermissions({});
renderDashboard();
renderActionPermissions();
setDenied(false);
return Promise.resolve(); return Promise.resolve();
} }
setPageLoading(true); setPageLoading(true);
@ -342,6 +646,7 @@
pageState.permissions = normalizePermissions( pageState.permissions = normalizePermissions(
pageState.overview.permissions || pageState.overview pageState.overview.permissions || pageState.overview
); );
renderDashboard();
renderActionPermissions(); renderActionPermissions();
setDenied(false); setDenied(false);
return canUseAction('block-user') return canUseAction('block-user')
@ -349,7 +654,16 @@
: Promise.resolve(); : Promise.resolve();
}) })
.catch(function (error) { .catch(function (error) {
if (isLocalPreviewError(error)) {
pageState.permissions = normalizePermissions({});
renderDashboard();
renderActionPermissions();
setDenied(false);
return;
}
// 接口失败也要渲染一次:把骨架屏收敛成中性占位符,页面不残留加载态。
setDenied(true); setDenied(true);
renderDashboard();
showError(error); showError(error);
}) })
.finally(function () { .finally(function () {
@ -389,6 +703,11 @@
copy.appendChild( copy.appendChild(
createNode('div', 'searched-meta', 'UID: ' + userDisplayId(user)) createNode('div', 'searched-meta', 'UID: ' + userDisplayId(user))
); );
if (currentVip(user)) {
copy.appendChild(
createNode('div', 'searched-vip', currentVipStatusText(user))
);
}
var button = createNode('button', 'user-block-button', actionText); var button = createNode('button', 'user-block-button', actionText);
button.type = 'button'; button.type = 'button';
button.addEventListener('click', onAction); button.addEventListener('click', onAction);
@ -681,7 +1000,8 @@
toast('Enter user ID.'); toast('Enter user ID.');
return; return;
} }
apiSearchUser(value, '', grantState.action) // 封禁搜索只校验 Block User 权限;不能借用赠送弹窗状态,否则会按资源/VIP 赠送权限误判。
apiSearchUser(value, '', 'block-user')
.then(function (items) { .then(function (items) {
if (!items.length) { if (!items.length) {
blockState.searchedUser = null; blockState.searchedUser = null;
@ -733,7 +1053,8 @@
toast('Enter user ID.'); toast('Enter user ID.');
return; return;
} }
apiSearchUser(value) // 等级调整搜索按自己的能力开关校验,避免资源赠送关闭时误挡等级操作。
apiSearchUser(value, '', 'update-user-level')
.then(function (items) { .then(function (items) {
if (!items.length) { if (!items.length) {
levelState.searchedUser = null; levelState.searchedUser = null;
@ -815,7 +1136,7 @@
grantState.resourceType = RESOURCE_TYPES[action] || ''; grantState.resourceType = RESOURCE_TYPES[action] || '';
grantState.title = ACTION_LABELS[action] || 'Send'; grantState.title = ACTION_LABELS[action] || 'Send';
grantState.searchedUser = null; grantState.searchedUser = null;
grantState.selectedResourceId = ''; grantState.selectedResourceIds = [];
grantState.selectedResourceDurationMs = grantState.selectedResourceDurationMs =
DEFAULT_RESOURCE_GRANT_DURATION_MS; DEFAULT_RESOURCE_GRANT_DURATION_MS;
grantState.selectedVipLevel = 0; grantState.selectedVipLevel = 0;
@ -845,13 +1166,23 @@
renderGrantOptions(); renderGrantOptions();
return; return;
} }
api.listResources(resourceType, 20) if (pageState.resourceRequestsByType[resourceType]) return;
// 同一资源类型只保留一个全量分页请求;全部页面成功后才缓存,任一页失败时允许下次打开弹窗重新获取。
pageState.resourceRequestsByType[resourceType] = api
.listAllResources(resourceType, 20)
.then(function (data) { .then(function (data) {
pageState.resourcesByType[resourceType] = pageState.resourcesByType[resourceType] =
(data && data.items) || []; (data && data.items) || [];
renderGrantOptions(); delete pageState.resourceRequestsByType[resourceType];
if (grantState.resourceType === resourceType) {
renderGrantOptions();
}
}) })
.catch(showError); .catch(function (error) {
delete pageState.resourceRequestsByType[resourceType];
showError(error);
});
} }
function searchGrantUser() { function searchGrantUser() {
@ -860,7 +1191,8 @@
toast('Enter user ID.'); toast('Enter user ID.');
return; return;
} }
apiSearchUser(value) // 搜索入口也必须携带当前赠送动作Send VIP 只校验 VIP 赠送权限,不能落回资源赠送权限导致误拒。
apiSearchUser(value, '', grantState.action)
.then(function (items) { .then(function (items) {
if (!items.length) { if (!items.length) {
grantState.searchedUser = null; grantState.searchedUser = null;
@ -890,6 +1222,10 @@
var grid = $('grantOptionGrid'); var grid = $('grantOptionGrid');
if (!grid) return; if (!grid) return;
grid.innerHTML = ''; grid.innerHTML = '';
grid.classList.toggle(
'is-resource-grid',
grantState.action !== 'send-vip'
);
var durationTitle = $('grantDurationTitle'); var durationTitle = $('grantDurationTitle');
var durationGrid = $('grantDurationGrid'); var durationGrid = $('grantDurationGrid');
if (durationTitle) { if (durationTitle) {
@ -905,24 +1241,55 @@
} }
if (grantState.action === 'send-vip') { if (grantState.action === 'send-vip') {
$('grantOptionTitle').textContent = 'Select VIP level'; $('grantOptionTitle').textContent = 'Select VIP level';
var activeVipLevel = currentVipLevel(grantState.searchedUser || {});
if (
grantState.selectedVipLevel &&
activeVipLevel > grantState.selectedVipLevel
) {
grantState.selectedVipLevel = 0;
}
// VIP grants must stay on the explicit product allow-list so the UI never sends a level the backend has not opened. // VIP grants must stay on the explicit product allow-list so the UI never sends a level the backend has not opened.
VIP_GRANT_LEVELS.forEach(function (level) { VIP_GRANT_LEVELS.forEach(function (level) {
var button = createNode('button', '', 'VIP ' + level); var button = createNode('button', '', 'VIP ' + level);
var blockedByCurrentVip = activeVipLevel > level;
button.type = 'button'; button.type = 'button';
button.disabled = blockedByCurrentVip;
if (blockedByCurrentVip) {
button.title = 'Current VIP is higher';
}
button.classList.toggle( button.classList.toggle(
'is-selected', 'is-selected',
grantState.selectedVipLevel === level grantState.selectedVipLevel === level
); );
button.addEventListener('click', function () { button.addEventListener('click', function () {
if (blockedByCurrentVip) return;
grantState.selectedVipLevel = level; grantState.selectedVipLevel = level;
renderGrantOptions(); renderGrantOptions();
}); });
grid.appendChild(button); grid.appendChild(button);
}); });
if (activeVipLevel > 1) {
grid.appendChild(
createNode(
'div',
'vip-grant-note',
activeVipLevel > Math.max.apply(null, VIP_GRANT_LEVELS)
? 'Current VIP is higher than manager-center gift levels.'
: 'Lower VIP levels are disabled because the user already has a higher VIP.'
)
);
}
} else { } else {
$('grantOptionTitle').textContent = 'Select resource'; $('grantOptionTitle').textContent = 'Select ' + resourceKindLabel();
var resources = var resources =
pageState.resourcesByType[grantState.resourceType] || []; pageState.resourcesByType[grantState.resourceType] || [];
var selectedResourceSet = grantState.selectedResourceIds.reduce(
function (result, resourceId) {
result[String(resourceId)] = true;
return result;
},
{}
);
if (!resources.length) { if (!resources.length) {
grid.appendChild( grid.appendChild(
createNode('div', 'blocked-empty', 'No resource available.') createNode('div', 'blocked-empty', 'No resource available.')
@ -930,20 +1297,56 @@
} }
resources.forEach(function (resource) { resources.forEach(function (resource) {
var button = createNode('button', 'grant-resource-option'); var button = createNode('button', 'grant-resource-option');
var imageURL = resourceImageUrl(resource);
var resourceID = resource.resource_id;
var resourceKey = String(resourceID);
button.type = 'button'; button.type = 'button';
button.setAttribute(
'aria-label',
'Select ' + resourceKindLabel()
);
button.classList.toggle( button.classList.toggle(
'is-selected', 'is-selected',
String(grantState.selectedResourceId) === !!selectedResourceSet[resourceKey]
String(resource.resource_id)
); );
button.textContent = if (imageURL) {
resource.name || 'Resource #' + resource.resource_id; var media = createNode('span', 'grant-resource-media');
var img = document.createElement('img');
img.src = imageURL;
img.alt = '';
media.appendChild(img);
button.appendChild(media);
} else {
button.appendChild(
createNode('span', 'grant-resource-fallback')
);
}
button.addEventListener('click', function () { button.addEventListener('click', function () {
grantState.selectedResourceId = resource.resource_id; // 资源赠送是素材发放,不是互斥装备选择;同一弹窗允许一次选择多个素材,由后端拆成独立 grant 命令保证可重试。
if (selectedResourceSet[resourceKey]) {
grantState.selectedResourceIds =
grantState.selectedResourceIds.filter(
function (selectedID) {
return String(selectedID) !== resourceKey;
}
);
} else {
grantState.selectedResourceIds =
grantState.selectedResourceIds.concat(resourceID);
}
renderGrantOptions(); renderGrantOptions();
}); });
grid.appendChild(button); grid.appendChild(button);
}); });
if (grantState.selectedResourceIds.length) {
grid.appendChild(
createNode(
'div',
'resource-selected-note',
grantState.selectedResourceIds.length + ' selected'
)
);
}
if (durationGrid) { if (durationGrid) {
RESOURCE_GRANT_DURATIONS.forEach(function (item) { RESOURCE_GRANT_DURATIONS.forEach(function (item) {
var durationButton = createNode('button', '', item.label); var durationButton = createNode('button', '', item.label);
@ -966,8 +1369,10 @@
!grantState.searchedUser || !grantState.searchedUser ||
grantState.submitting || grantState.submitting ||
(grantState.action === 'send-vip' (grantState.action === 'send-vip'
? !grantState.selectedVipLevel ? !grantState.selectedVipLevel ||
: !grantState.selectedResourceId || currentVipLevel(grantState.searchedUser) >
grantState.selectedVipLevel
: !grantState.selectedResourceIds.length ||
!grantState.selectedResourceDurationMs); !grantState.selectedResourceDurationMs);
} }
} }
@ -986,7 +1391,7 @@
payload.level = grantState.selectedVipLevel; payload.level = grantState.selectedVipLevel;
request = api.grantVip(payload); request = api.grantVip(payload);
} else { } else {
payload.resource_id = grantState.selectedResourceId; payload.resource_ids = grantState.selectedResourceIds.slice();
payload.duration_ms = grantState.selectedResourceDurationMs; payload.duration_ms = grantState.selectedResourceDurationMs;
request = api.grantResource(payload); request = api.grantResource(payload);
} }
@ -1065,7 +1470,8 @@
toast('Enter user ID.'); toast('Enter user ID.');
return; return;
} }
apiSearchUser(value) // BD/Admin/Superadmin 共用弹窗,搜索阶段先按当前按钮的能力开关过滤;写入口仍会二次校验真实落库能力。
apiSearchUser(value, '', bdLeaderState.action)
.then(function (items) { .then(function (items) {
if (!items.length) { if (!items.length) {
bdLeaderState.searchedUser = null; bdLeaderState.searchedUser = null;
@ -1257,7 +1663,8 @@
toast('Enter user ID.'); toast('Enter user ID.');
return; return;
} }
apiSearchUser(value, 'all') // 转国家是唯一允许跨国家搜索的入口,必须同时携带 scope=all 和动作,避免额外依赖资源赠送权限。
apiSearchUser(value, 'all', 'transfer-user-country')
.then(function (items) { .then(function (items) {
if (!items.length) { if (!items.length) {
countryState.searchedUser = null; countryState.searchedUser = null;
@ -1362,9 +1769,30 @@
}); });
} }
function copyManagerId() {
var value = $('managerDisplayId')
? $('managerDisplayId').textContent.trim()
: '';
if (!value) return;
if (window.navigator && window.navigator.clipboard) {
window.navigator.clipboard
.writeText(value)
.then(function () {
toast('Copied.');
})
.catch(function () {
toast(value);
});
return;
}
toast(value);
}
function bindEvents() { function bindEvents() {
var backButton = $('backButton'); var backButton = $('backButton');
if (backButton) backButton.addEventListener('click', goBack); if (backButton) backButton.addEventListener('click', goBack);
var copyButton = document.querySelector('.manager-copy-button');
if (copyButton) copyButton.addEventListener('click', copyManagerId);
document.querySelectorAll('[data-action]').forEach(function (button) { document.querySelectorAll('[data-action]').forEach(function (button) {
button.addEventListener('click', function () { button.addEventListener('click', function () {
var action = button.getAttribute('data-action'); var action = button.getAttribute('data-action');
@ -1453,6 +1881,7 @@
function init() { function init() {
bindEvents(); bindEvents();
renderBlockedList(); renderBlockedList();
// 首屏不预渲染仪表盘:骨架屏保持到 loadOverview 的任一分支调用 renderDashboard 收敛。
loadOverview(); loadOverview();
if (window.HyAppBridge && window.HyAppBridge.ready) { if (window.HyAppBridge && window.HyAppBridge.ready) {
window.HyAppBridge.ready({ page: 'manager-center' }); window.HyAppBridge.ready({ page: 'manager-center' });

View File

@ -1,3 +1,544 @@
.manager-center {
background:
radial-gradient(
circle at 15% 0,
rgba(222, 206, 255, 0.58),
rgba(251, 248, 255, 0) 28%
),
radial-gradient(
circle at 92% 18%,
rgba(229, 238, 255, 0.62),
rgba(251, 248, 255, 0) 31%
),
var(--page-bg);
}
.manager-center .hero-bg {
display: none;
}
.manager-center .home-indicator {
display: none;
}
.manager-center .title-bar {
grid-template-columns: 52px minmax(0, 1fr) 52px;
height: 72px;
padding: 13px 18px 0;
}
.manager-center .title-bar h1 {
color: #11162e;
font-size: 22px;
font-weight: 820;
line-height: 1.1;
}
.manager-center .back-button,
.manager-center .language-button {
width: 44px;
height: 44px;
}
.manager-center .language-button {
border: 0;
border-radius: 50%;
background: transparent;
box-shadow: none;
color: #11162e;
}
.manager-center .language-button svg {
width: 32px;
height: 32px;
}
.manager-center .back-button path,
.manager-center .language-button path,
.manager-center .manager-copy-button path {
fill: currentColor;
stroke: none;
}
.manager-center .content {
gap: 12px;
padding: 10px 16px 30px;
}
.manager-center .card {
border: 1px solid rgba(125, 87, 199, 0.12);
border-radius: 16px;
background:
linear-gradient(
145deg,
rgba(255, 255, 255, 0.98),
rgba(255, 255, 255, 0.89)
),
var(--card-bg);
box-shadow:
0 18px 42px rgba(88, 66, 142, 0.075),
inset 0 1px 0 rgba(255, 255, 255, 0.96);
}
.manager-profile-card {
display: grid;
grid-template-columns: 82px minmax(0, 1fr) auto;
gap: 11px;
align-items: center;
min-height: 112px;
padding: 13px 12px;
}
.manager-avatar-shell {
position: relative;
width: 76px;
height: 76px;
border: 3px solid transparent;
border-radius: 50%;
background:
linear-gradient(#fff, #fff) padding-box,
linear-gradient(145deg, #f4eaff, #b894ff) border-box;
box-shadow: 0 8px 22px rgba(125, 87, 199, 0.2);
}
.manager-avatar-image,
.manager-avatar-fallback {
width: 100%;
height: 100%;
border-radius: 50%;
}
.manager-avatar-image {
display: block;
object-fit: cover;
}
.manager-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #f7f0ff, #e3d5ff);
color: var(--primary-deep);
font-size: 32px;
font-weight: 950;
}
.manager-profile-copy {
display: grid;
min-width: 0;
gap: 7px;
}
.manager-profile-copy strong {
overflow: hidden;
color: #151936;
font-size: 24px;
font-weight: 820;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.manager-role-badge {
display: inline-flex;
width: max-content;
max-width: 100%;
height: 24px;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: 7px;
background: linear-gradient(135deg, #a678ff, #8b5ce8);
color: #fff;
font-size: 14px;
font-weight: 780;
line-height: 24px;
box-shadow: 0 8px 18px rgba(139, 92, 232, 0.22);
}
.manager-role-badge svg {
width: 14px;
height: 14px;
}
.manager-role-badge path {
fill: currentColor;
}
.manager-id-line {
display: flex;
min-width: 0;
align-items: center;
gap: 5px;
color: #837ba3;
font-size: 14px;
font-weight: 650;
line-height: 1.1;
}
.manager-id-line > span:nth-child(2) {
overflow: hidden;
flex: 0 0 auto;
text-overflow: ellipsis;
white-space: nowrap;
}
.manager-copy-button {
display: flex;
flex: 0 0 22px;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
color: #837ba3;
}
.manager-copy-button svg {
width: 18px;
height: 18px;
}
.manager-kpi-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.manager-kpi-card {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
min-height: 128px;
overflow: hidden;
padding: 18px 13px 16px;
}
.manager-kpi-icon {
position: absolute;
top: 18px;
left: 13px;
z-index: 1;
display: flex;
width: 48px;
height: 48px;
align-items: center;
justify-content: center;
border-radius: 50%;
pointer-events: none;
}
.manager-kpi-icon svg {
width: 30px;
height: 30px;
}
.manager-kpi-icon path,
.manager-kpi-icon circle,
.manager-asset-icon path,
.manager-asset-icon circle {
fill: currentColor;
stroke: none;
}
.manager-kpi-icon .icon-cutout,
.manager-asset-icon .icon-cutout {
fill: #fff;
stroke: none;
}
.manager-kpi-icon .icon-dot {
fill: currentColor;
stroke: none;
}
.manager-asset-icon.wallet .icon-cutout,
.manager-asset-icon.wallet .icon-dot {
fill: #ff6c08;
}
.manager-asset-icon.bank .icon-cutout {
fill: #2f61ff;
}
.manager-kpi-copy {
position: relative;
z-index: 2;
display: grid;
align-content: center;
min-width: 0;
gap: 6px;
padding-left: 58px;
}
.manager-kpi-copy span,
.manager-kpi-copy em {
color: #827f99;
font-size: 14px;
font-style: normal;
font-weight: 540;
line-height: 1.12;
}
.manager-kpi-copy strong {
color: #ff660a;
font-size: 25px;
font-weight: 840;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
}
.kpi-hosts .manager-kpi-copy strong {
color: #171b35;
}
.kpi-growth .manager-kpi-copy strong {
color: #ff424d;
}
.kpi-wallet .manager-kpi-icon {
background: #f2e9ff;
color: #854fe8;
}
.kpi-report .manager-kpi-icon {
background: #eef4ff;
color: #3478f6;
}
.kpi-hosts .manager-kpi-icon {
background: #e8fbf8;
color: #10bda6;
}
.kpi-growth .manager-kpi-icon {
background: #fff0f4;
color: #ff3d52;
}
.manager-bars {
position: absolute;
right: 13px;
bottom: 16px;
width: 39px;
height: 34px;
opacity: 0.38;
background:
linear-gradient(currentColor, currentColor) 0 21px / 7px 13px no-repeat,
linear-gradient(currentColor, currentColor) 11px 14px / 7px 20px
no-repeat,
linear-gradient(currentColor, currentColor) 22px 7px / 7px 27px
no-repeat,
linear-gradient(currentColor, currentColor) 33px 0 / 7px 34px no-repeat;
}
.kpi-wallet .manager-bars {
color: #eadcff;
}
.kpi-report .manager-bars {
color: #d9e9ff;
}
.kpi-hosts .manager-bars {
color: #d9fbf5;
}
.kpi-growth .manager-bars {
color: #ffe0e7;
}
.manager-assets-card {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-height: 88px;
margin-top: 12px;
overflow: hidden;
padding: 0;
}
.manager-asset-item {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) 14px;
gap: 10px;
align-items: center;
min-width: 0;
min-height: 88px;
padding: 16px 13px;
text-align: left;
}
.manager-asset-item + .manager-asset-item {
border-left: 1px solid rgba(125, 87, 199, 0.1);
}
.manager-asset-icon,
.manager-action-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.manager-asset-icon {
width: 48px;
height: 48px;
color: #fff;
}
.manager-asset-icon.wallet {
background: linear-gradient(145deg, #ff8a16, #ff5a00);
}
.manager-asset-icon.bank {
background: linear-gradient(145deg, #4d73ff, #215dff);
}
.manager-asset-icon svg {
width: 29px;
height: 29px;
}
.manager-asset-item strong,
.manager-action-item > span:last-child {
overflow-wrap: anywhere;
color: #1d2140;
font-size: 14px;
font-weight: 560;
line-height: 1.15;
}
.manager-asset-item strong {
white-space: nowrap;
}
.manager-action-item > span:last-child {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.manager-asset-item em {
display: block;
margin-top: 7px;
color: #ff660a;
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: 1;
}
.manager-asset-item:nth-child(2) em {
color: #858296;
font-weight: 540;
}
.manager-asset-item i {
color: #a8a4b7;
font-size: 30px;
font-style: normal;
font-weight: 300;
line-height: 1;
}
.manager-actions-card {
overflow: hidden;
margin-top: 8px;
padding: 0;
}
.manager-action-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.manager-action-item {
display: grid;
min-height: 114px;
align-content: center;
justify-items: center;
gap: 8px;
padding: 11px 7px;
text-align: center;
}
.manager-action-item:nth-child(n + 4) {
border-top: 1px solid rgba(125, 87, 199, 0.055);
}
.manager-action-item:not(:nth-child(3n + 1)) {
border-left: 1px solid rgba(125, 87, 199, 0.055);
}
.manager-action-icon {
width: 56px;
height: 56px;
}
.manager-action-icon svg {
width: 33px;
height: 33px;
}
.manager-action-icon path,
.manager-action-icon circle {
fill: currentColor;
stroke: none;
}
.manager-action-icon .icon-cutout {
fill: #fff;
stroke: none;
}
.action-admin .manager-action-icon,
.action-send-frame .manager-action-icon {
background: #f0e6ff;
color: #844dec;
}
.action-block-users .manager-action-icon {
background: #fff1f4;
color: #d95d72;
}
.action-bd .manager-action-icon,
.action-send-car .manager-action-icon {
background: #eef4ff;
color: #2f6df1;
}
.action-invite-admin .manager-action-icon,
.action-send-vip .manager-action-icon {
background: #ffe9ef;
color: #ff304a;
}
.action-invite-bd .manager-action-icon,
.action-update-level .manager-action-icon {
background: #e5fbf7;
color: #08bca6;
}
.action-super .manager-action-icon,
.action-send-badge .manager-action-icon {
background: #fff4db;
color: #ff9700;
}
.manager-center.is-loading .manager-profile-card,
.manager-center.is-loading .manager-kpi-card,
.manager-center.is-loading .manager-assets-card,
.manager-center.is-loading .manager-actions-card {
opacity: 0.72;
}
.manager-center.is-denied .manager-action-grid {
opacity: 0.46;
pointer-events: none;
}
.manager-panel { .manager-panel {
position: relative; position: relative;
display: grid; display: grid;
@ -392,6 +933,7 @@
} }
.searched-meta, .searched-meta,
.searched-vip,
.blocked-meta, .blocked-meta,
.blocked-time { .blocked-time {
margin-top: 3px; margin-top: 3px;
@ -404,6 +946,10 @@
white-space: nowrap; white-space: nowrap;
} }
.searched-vip {
color: #7d57c7;
}
.user-block-button { .user-block-button {
min-width: 70px; min-width: 70px;
height: 40px; height: 40px;
@ -486,14 +1032,114 @@
color: #fff; color: #fff;
} }
.duration-grid button:disabled,
.level-grid button:disabled,
.track-grid button:disabled,
.grant-option-grid button:disabled {
cursor: not-allowed;
border-color: rgba(125, 87, 199, 0.12);
background: #f3eef8;
color: #b5a9c5;
}
.grant-option-grid { .grant-option-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.grant-option-grid.is-resource-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.vip-grant-note {
grid-column: 1 / -1;
color: #7d6e90;
font-size: 12px;
font-weight: 800;
line-height: 1.3;
}
.resource-selected-note {
grid-column: 1 / -1;
color: #7d6e90;
font-size: 12px;
font-weight: 900;
line-height: 1.3;
}
.grant-resource-option { .grant-resource-option {
position: relative;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; display: grid;
white-space: nowrap; min-height: 96px;
place-items: center;
padding: 10px;
border-color: rgba(125, 87, 199, 0.14);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), #fff),
radial-gradient(
circle at 50% 24%,
rgba(125, 87, 199, 0.12),
transparent 58%
);
}
.grant-resource-option::after {
position: absolute;
top: 7px;
right: 7px;
width: 18px;
height: 18px;
content: '';
border: 2px solid rgba(125, 87, 199, 0.2);
border-radius: 999px;
background: #fff;
}
.grant-resource-option.is-selected {
border-color: var(--primary-deep);
background:
linear-gradient(180deg, rgba(248, 243, 255, 0.98), #fff),
radial-gradient(
circle at 50% 24%,
rgba(125, 87, 199, 0.2),
transparent 58%
);
color: var(--text);
box-shadow: inset 0 0 0 2px rgba(125, 87, 199, 0.22);
}
.grant-resource-option.is-selected::after {
border-color: var(--primary-deep);
background: var(--primary-deep);
box-shadow: inset 0 0 0 4px #fff;
}
.grant-resource-media {
display: flex;
width: 68px;
height: 68px;
align-items: center;
justify-content: center;
}
.grant-resource-media img {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.grant-resource-fallback {
width: 58px;
height: 58px;
border-radius: 50%;
background:
linear-gradient(
135deg,
rgba(125, 87, 199, 0.28),
rgba(78, 120, 215, 0.18)
),
#f6f0ff;
} }
.country-option { .country-option {

14
package-lock.json generated
View File

@ -8,9 +8,23 @@
"name": "hyapp-h5", "name": "hyapp-h5",
"version": "1.0.0", "version": "1.0.0",
"devDependencies": { "devDependencies": {
"playwright-core": "^1.61.1",
"prettier": "^3.0.0" "prettier": "^3.0.0"
} }
}, },
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.8.3", "version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",

View File

@ -3,10 +3,15 @@
"version": "1.0.0", "version": "1.0.0",
"description": "H5 project", "description": "H5 project",
"scripts": { "scripts": {
"dev:h5": "python3 scripts/h5_dev_server.py --host 127.0.0.1 --port 8788 --directory .",
"dev:manager-center": "python3 scripts/h5_dev_server.py --host 127.0.0.1 --port 8788 --directory .",
"aslan:webview": "node scripts/aslan_flutter_webview.js",
"test:manager-center": "node scripts/test_manager_center_resource_pagination.js",
"format": "prettier --write \"**/*.{js,html,css,json,md}\"", "format": "prettier --write \"**/*.{js,html,css,json,md}\"",
"format:check": "prettier --check \"**/*.{js,html,css,json,md}\"" "format:check": "prettier --check \"**/*.{js,html,css,json,md}\""
}, },
"devDependencies": { "devDependencies": {
"playwright-core": "^1.61.1",
"prettier": "^3.0.0" "prettier": "^3.0.0"
} }
} }

511
scripts/aslan_flutter_webview.js Executable file
View File

@ -0,0 +1,511 @@
#!/usr/bin/env node
'use strict';
const childProcess = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const readline = require('node:readline/promises');
const { stdin, stdout } = require('node:process');
const { chromium } = require('playwright-core');
const PROJECT_ROOT = path.resolve(__dirname, '..');
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_PORT = 8788;
const DEFAULT_API_BASE = 'https://api.atuchat.com/';
const DEFAULT_URL =
'/aslan/recharge-activity/index.html?app_code=aslan&codex_webview=1';
const DEFAULT_CHROME_PATHS = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
];
function parseArgs(argv) {
const options = {
account: '',
password: '',
token: '',
url: '',
apiBase: DEFAULT_API_BASE,
host: DEFAULT_HOST,
port: DEFAULT_PORT,
origin: 'ATYOU',
originChild: '',
lang: 'en',
chrome: '',
headless: false,
devtools: false,
disableWebSecurity: true,
navigationPrompt: true,
closeAfter: 0,
screenshot: '',
};
const aliases = {
'-a': 'account',
'--account': 'account',
'-p': 'password',
'--password': 'password',
'--pwd': 'password',
'--token': 'token',
'-u': 'url',
'--url': 'url',
'--api-base': 'apiBase',
'--host': 'host',
'--port': 'port',
'--origin': 'origin',
'--origin-child': 'originChild',
'--lang': 'lang',
'--chrome': 'chrome',
'--close-after': 'closeAfter',
'--screenshot': 'screenshot',
};
for (let i = 0; i < argv.length; i += 1) {
const raw = argv[i];
const [flag, inlineValue] = raw.split(/=(.*)/s);
if (flag === '--help' || flag === '-h') {
options.help = true;
continue;
}
if (flag === '--headless') {
options.headless = true;
continue;
}
if (flag === '--devtools') {
options.devtools = true;
continue;
}
if (flag === '--no-disable-web-security') {
options.disableWebSecurity = false;
continue;
}
if (flag === '--no-nav-prompt') {
options.navigationPrompt = false;
continue;
}
const key = aliases[flag];
if (!key) {
throw new Error(`Unknown argument: ${raw}`);
}
const value = inlineValue !== undefined ? inlineValue : argv[++i];
if (value === undefined) throw new Error(`Missing value for ${raw}`);
options[key] =
key === 'port' || key === 'closeAfter' ? Number(value) : value;
}
options.originChild = options.originChild || options.origin;
return options;
}
function usage() {
return [
'Usage:',
' npm run aslan:webview',
' npm run aslan:webview -- --account 6666 --password 123456 --url /aslan/recharge-activity/index.html?app_code=aslan',
'',
'Options:',
' -a, --account <value> Aslan login account or special ID',
' -p, --password <value> Aslan login password',
' --token <value> Existing Aslan token; skips account password login',
' -u, --url <value> Target H5 URL. Relative paths use the local H5 dev server.',
' --api-base <url> API base, default https://api.atuchat.com/',
' --origin <value> Req-Sys-Origin origin, default ATYOU',
' --origin-child <value> Req-Sys-Origin originChild, default same as origin',
' --lang <value> Req-Lang, default en',
' --chrome <path> Chrome executable path',
' --headless Run browser headless',
' --devtools Open DevTools',
' --no-disable-web-security Keep browser web security enabled',
' --no-nav-prompt Do not prompt for more URLs after opening',
' --close-after <seconds> Auto close, useful for smoke tests',
' --screenshot <path> Save a screenshot after initial load',
].join('\n');
}
function normalizeBaseURL(value) {
return String(value || DEFAULT_API_BASE).replace(/\/+$/, '');
}
function resolveChromePath(explicitPath) {
if (explicitPath) return explicitPath;
return (
DEFAULT_CHROME_PATHS.find((candidate) => fs.existsSync(candidate)) || ''
);
}
function normalizeTargetURL(input, host, port) {
const value = String(input || DEFAULT_URL).trim() || DEFAULT_URL;
if (/^https?:\/\//i.test(value)) return value;
const cleanPath = value.startsWith('/')
? value
: `/${value.replace(/^\.?\//, '')}`;
return `http://${host}:${port}${cleanPath}`;
}
function isLocalTarget(url, host, port) {
try {
const parsed = new URL(url);
return (
parsed.hostname === host &&
String(parsed.port || 80) === String(port)
);
} catch (_) {
return false;
}
}
function healthURL(host, port) {
return `http://${host}:${port}/__h5_dev_server__/health`;
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function requestHealth(url) {
return new Promise((resolve) => {
const req = http.get(url, (res) => {
res.resume();
resolve(res.statusCode === 200);
});
req.on('error', () => resolve(false));
req.setTimeout(800, () => {
req.destroy();
resolve(false);
});
});
}
async function ensureLocalServer(host, port) {
const url = healthURL(host, port);
if (await requestHealth(url)) return null;
const server = childProcess.spawn(
'python3',
[
path.join(PROJECT_ROOT, 'scripts/h5_dev_server.py'),
'--host',
host,
'--port',
String(port),
'--directory',
PROJECT_ROOT,
],
{
cwd: PROJECT_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
}
);
server.stdout.on('data', (chunk) =>
process.stdout.write(`[h5-server] ${chunk}`)
);
server.stderr.on('data', (chunk) =>
process.stderr.write(`[h5-server] ${chunk}`)
);
for (let i = 0; i < 30; i += 1) {
if (await requestHealth(url)) return server;
await wait(200);
}
server.kill();
throw new Error(`Failed to start local H5 server: ${url}`);
}
async function askRequired(rl, label, currentValue) {
if (currentValue) return currentValue;
const value = (await rl.question(label)).trim();
if (!value)
throw new Error(`${label.replace(/[:]\s*$/, '')} is required`);
return value;
}
async function askPassword(rl, currentValue) {
if (currentValue) return currentValue;
if (!stdin.isTTY) return askRequired(rl, 'Password: ', currentValue);
return new Promise((resolve, reject) => {
let value = '';
stdout.write('Password: ');
stdin.setRawMode(true);
stdin.resume();
const onData = (buffer) => {
const text = buffer.toString('utf8');
if (text === '\u0003') {
stdin.setRawMode(false);
stdin.off('data', onData);
reject(new Error('Cancelled'));
return;
}
if (text === '\r' || text === '\n') {
stdin.setRawMode(false);
stdin.off('data', onData);
stdout.write('\n');
resolve(value);
return;
}
if (text === '\u007f') {
if (value.length > 0) {
value = value.slice(0, -1);
stdout.write('\b \b');
}
return;
}
value += text;
stdout.write('*');
};
stdin.on('data', onData);
});
}
function buildReqAppIntel(model, channel, imei) {
return [
'build=1',
'version=1.0.0',
`model=${model}`,
'sysVersion=web',
`channel=${channel}`,
`Req-Imei=${imei}`,
].join(';');
}
async function login(options) {
const imei = 'codex-aslan-webview';
const response = await fetch(
`${normalizeBaseURL(options.apiBase)}/auth/account/login`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
'req-lang': options.lang,
'req-client': 'Android',
'req-imei': imei,
'req-app-intel': buildReqAppIntel(
'CodexWebViewLogin',
'Codex',
imei
),
'req-sys-origin': `origin=${options.origin};originChild=${options.originChild}`,
'req-version': 'V2',
'req-zone': 'Asia/Shanghai',
'user-agent': 'curl/8.7.1',
},
body: JSON.stringify({
account: options.account,
pwd: options.password,
}),
}
);
const text = await response.text();
let payload;
try {
payload = text ? JSON.parse(text) : {};
} catch (_) {
throw new Error(
`Login returned non-JSON response: HTTP ${response.status} ${text.slice(0, 200)}`
);
}
if (
!response.ok ||
payload.status === false ||
!payload.body ||
!payload.body.token
) {
throw new Error(
`Login failed: HTTP ${response.status} ${payload.errorMsg || payload.message || text.slice(0, 200)}`
);
}
return {
token: payload.body.token,
profile: payload.body.userProfile || {},
};
}
function buildInitScriptPayload(options, token) {
const imei = 'codex-aslan-webview';
return {
token,
lang: options.lang,
origin: options.origin,
originChild: options.originChild,
reqAppIntel: buildReqAppIntel('CodexFlutterWebView', 'Codex', imei),
};
}
async function openWebView(options, token, firstURL) {
const chromePath = resolveChromePath(options.chrome);
if (!chromePath) {
throw new Error(
'Chrome executable not found. Pass --chrome /path/to/chrome.'
);
}
const launchArgs = ['--window-size=390,844'];
if (options.disableWebSecurity) {
launchArgs.push('--disable-web-security');
launchArgs.push('--disable-features=IsolateOrigins,site-per-process');
}
const browser = await chromium.launch({
executablePath: chromePath,
headless: options.headless,
devtools: options.devtools,
args: launchArgs,
});
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
userAgent:
'Mozilla/5.0 (Linux; Android 14; Aslan Flutter WebView) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0.0.0 Mobile Safari/537.36',
});
await context.addInitScript(
(config) => {
window.FlutterApp = window.FlutterApp || {
postMessage(data) {
console.log('[FlutterApp.postMessage]', data);
},
};
window.FlutterPageControl = window.FlutterPageControl || {
postMessage(data) {
console.log('[FlutterPageControl.postMessage]', data);
},
};
window.app = window.app || {};
window.app.getAccessOrigin = function getAccessOrigin() {
return JSON.stringify({
Authorization: `Bearer ${config.token}`,
'Req-Lang': config.lang,
'Req-App-Intel': config.reqAppIntel,
'Req-Sys-Origin': `origin=${config.origin};originChild=${config.originChild}`,
});
};
window.app.getAuth = function getAuth() {
return window.app.getAccessOrigin();
};
window.app.sendToFlutter = function sendToFlutter(data) {
window.FlutterApp.postMessage(data);
};
},
buildInitScriptPayload(options, token)
);
const page = await context.newPage();
page.on('console', (message) => {
const type = message.type();
if (type === 'error' || type === 'warning') {
console.log(`[webview:${type}] ${message.text()}`);
}
});
await page.goto(firstURL, {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await page
.waitForLoadState('networkidle', { timeout: 15000 })
.catch(() => {});
return { browser, page };
}
async function promptNavigationLoop(rl, page, options) {
console.log(
'输入新的 URL 回车可跳转;输入 q 退出。相对路径会走本地 H5 服务。'
);
while (!page.isClosed()) {
const next = (await rl.question('WebView URL> ')).trim();
if (!next) continue;
if (next.toLowerCase() === 'q' || next.toLowerCase() === 'quit') break;
const targetURL = normalizeTargetURL(next, options.host, options.port);
if (isLocalTarget(targetURL, options.host, options.port)) {
await ensureLocalServer(options.host, options.port);
}
console.log(`Navigate: ${targetURL}`);
await page.goto(targetURL, {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await page
.waitForLoadState('networkidle', { timeout: 15000 })
.catch(() => {});
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
console.log(usage());
return;
}
options.account = options.account || process.env.ASLAN_ACCOUNT || '';
options.password = options.password || process.env.ASLAN_PASSWORD || '';
options.token = options.token || process.env.ASLAN_TOKEN || '';
options.url = options.url || process.env.ASLAN_WEBVIEW_URL || '';
const rl = readline.createInterface({ input: stdin, output: stdout });
let localServer = null;
let browser = null;
try {
if (!options.token) {
options.account = await askRequired(
rl,
'Account: ',
options.account
);
options.password = await askPassword(rl, options.password);
}
options.url =
options.url ||
(await rl.question(`URL [${DEFAULT_URL}]: `)).trim() ||
DEFAULT_URL;
const targetURL = normalizeTargetURL(
options.url,
options.host,
options.port
);
if (isLocalTarget(targetURL, options.host, options.port)) {
localServer = await ensureLocalServer(options.host, options.port);
}
const loginResult = options.token
? { token: options.token, profile: { account: 'token' } }
: await login(options);
const displayAccount =
loginResult.profile.account ||
loginResult.profile.id ||
options.account;
console.log(
`${options.token ? 'Token loaded' : 'Login OK'}: ${loginResult.profile.userNickname || '-'} / ${displayAccount}`
);
console.log(`Open WebView: ${targetURL}`);
const result = await openWebView(options, loginResult.token, targetURL);
browser = result.browser;
if (options.screenshot) {
await result.page.screenshot({
path: path.resolve(options.screenshot),
fullPage: true,
});
console.log(
`Screenshot saved: ${path.resolve(options.screenshot)}`
);
}
if (options.closeAfter > 0) {
await wait(options.closeAfter * 1000);
return;
}
if (options.navigationPrompt && !options.headless) {
await promptNavigationLoop(rl, result.page, options);
return;
}
await result.page.waitForEvent('close').catch(() => {});
} finally {
rl.close();
if (browser) await browser.close().catch(() => {});
if (localServer) localServer.kill();
}
}
main().catch((error) => {
console.error(error.message || error);
process.exit(1);
});

View File

@ -12,6 +12,7 @@ import urllib.request
PROXIES = { PROXIES = {
"/__api_prod__/": "https://api.global-interaction.com/",
"/__api_test__/": "https://api-test.global-interaction.com/", "/__api_test__/": "https://api-test.global-interaction.com/",
"/__api_local__/": "http://127.0.0.1:13000/", "/__api_local__/": "http://127.0.0.1:13000/",
} }
@ -105,6 +106,8 @@ class H5DevHandler(http.server.SimpleHTTPRequestHandler):
length = int(self.headers.get("Content-Length", "0") or "0") length = int(self.headers.get("Content-Length", "0") or "0")
body = self.rfile.read(length) if length > 0 else b"" body = self.rfile.read(length) if length > 0 else b""
# 代理只转发业务相关 headerHost、Connection 等 hop-by-hop header 必须由 urllib 按目标域重建,
# 否则生产网关会看到本地 Host 或错误的连接语义,排查时会像业务接口异常。
headers = { headers = {
key: value key: value
for key, value in self.headers.items() for key, value in self.headers.items()
@ -179,7 +182,7 @@ class ReusableTCPServer(socketserver.ThreadingTCPServer):
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=30000) parser.add_argument("--port", type=int, default=8788)
parser.add_argument("--directory", default=os.getcwd()) parser.add_argument("--directory", default=os.getcwd())
args = parser.parse_args() args = parser.parse_args()
@ -190,6 +193,8 @@ def main() -> None:
) )
with ReusableTCPServer((args.host, args.port), handler) as server: with ReusableTCPServer((args.host, args.port), handler) as server:
print(f"Serving H5 at http://{args.host}:{args.port}/") print(f"Serving H5 at http://{args.host}:{args.port}/")
print(f"Manager center: http://{args.host}:{args.port}/gonghui/manager-center/")
print("API proxy: /__api_prod__/ -> https://api.global-interaction.com/")
print("API proxy: /__api_test__/ -> https://api-test.global-interaction.com/") print("API proxy: /__api_test__/ -> https://api-test.global-interaction.com/")
print("API proxy: /__api_local__/ -> http://127.0.0.1:13000/") print("API proxy: /__api_local__/ -> http://127.0.0.1:13000/")
server.serve_forever() server.serve_forever()

View File

@ -0,0 +1,116 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const requestedPages = [];
const responsesByPage = {
1: {
items: Array.from({ length: 20 }, (_, index) => ({
resource_id: String(1000 - index),
name: `Resource ${index + 1}`,
})),
page: 1,
page_size: 20,
total: 26,
},
2: {
items: [
{ resource_id: '202', name: 'Rose' },
{ resource_id: '197', name: 'Stellar Aura' },
{ resource_id: '192', name: 'Bugatti' },
{ resource_id: '191', name: 'Cupid' },
{ resource_id: '175', name: 'SuperAdmin' },
{ resource_id: '165', name: 'Admin' },
],
page: 2,
page_size: 20,
total: 26,
},
};
function response(payload) {
return {
headers: { get: () => '' },
ok: true,
status: 200,
statusText: 'OK',
text: () =>
Promise.resolve(
JSON.stringify({ code: 'OK', message: 'ok', data: payload })
),
};
}
async function main() {
const storage = new Map();
const window = {
document: { documentElement: { setAttribute: () => {} } },
fetch: (rawURL) => {
const url = new URL(rawURL);
assert.equal(
url.pathname,
'/api/v1/manager-center/resource-grants/resources'
);
assert.equal(url.searchParams.get('resource_type'), 'avatar_frame');
assert.equal(url.searchParams.get('page_size'), '20');
const page = Number(url.searchParams.get('page'));
requestedPages.push(page);
return Promise.resolve(response(responsesByPage[page]));
},
localStorage: {
getItem: (key) => storage.get(key) || null,
removeItem: (key) => storage.delete(key),
setItem: (key, value) => storage.set(key, String(value)),
},
location: {
hash: '',
hostname: 'h5.global-interaction.com',
href: 'https://h5.global-interaction.com/gonghui/manager-center/',
origin: 'https://h5.global-interaction.com',
pathname: '/gonghui/manager-center/',
search: '',
},
};
const context = vm.createContext({
FormData,
JSON,
Math,
Number,
Object,
Promise,
String,
URL,
URLSearchParams,
Uint32Array,
window,
});
const apiSource = fs.readFileSync(
path.join(__dirname, '..', 'common', 'api.js'),
'utf8'
);
vm.runInContext(apiSource, context, { filename: 'common/api.js' });
const result = await window.HyAppAPI.managerCenter.listAllResources(
'avatar_frame',
20
);
assert.deepEqual(requestedPages, [1, 2]);
assert.equal(result.total, 26);
assert.equal(result.items.length, 26);
assert.ok(
result.items.some(
(item) => item.resource_id === '175' && item.name === 'SuperAdmin'
)
);
assert.ok(
result.items.some(
(item) => item.resource_id === '165' && item.name === 'Admin'
)
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});