feat: 初始化画个标仓库 - AI驱动设计系统(BYOK模式) - 224源/371504图标+25海报源+130技能
This commit is contained in:
+37
@@ -0,0 +1,37 @@
|
||||
# 画个标 .gitignore
|
||||
|
||||
# 临时/测试产物(output/ 体积 185MB,_design-test/logo/ 为示例产物)
|
||||
output/
|
||||
_design-test/logo/png/
|
||||
_design-test/logo/fonts/
|
||||
_design-test/logo/svg/
|
||||
_design-test/logo/index.html
|
||||
_design-test/logo/*.html
|
||||
_design-test/logo/*.json
|
||||
_design-test/logo/*.js
|
||||
_design-test/logo/*.py
|
||||
_design-test/*.json
|
||||
_design-test/*.log
|
||||
_design-test/*.txt
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# 敏感文件
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
@@ -0,0 +1,288 @@
|
||||
# 画个标 · 项目规则(Trae Rules)
|
||||
|
||||
> 本文档 = 项目完整说明 + 操作规范,覆盖四套工作区(LOGO / 头像 / 海报 / 动态表情)、两套资源库、三套技能库与全量质检流程。素材或技能变更后按「版本号联动」与「验证流程」执行。
|
||||
|
||||
## 一、整体架构(四工作区 · 三资源体系)
|
||||
|
||||
纯前端静态应用(HTML/CSS/原生 JS,零依赖,无构建流程),直接静态服务器托管;智能设计为 **AI 驱动**(DeepSeek API):AI 输出设计决策 JSON,系统负责渲染与导出,**禁止**以模板随机组合作为主流程;AI 失败自动降级为内置引擎,保证页面不空白。
|
||||
|
||||
### 1. 工作区与页面清单
|
||||
> 各库规模数字为最近快照,构建素材/技能后以 `js/data/manifest/BASELINE.md`(脚本自动生成)为准,无需手动维护本表数字。
|
||||
|
||||
| 工作区 | 入口 | 资源库 | 技能库 |
|
||||
|---|---|---|---|
|
||||
| LOGO 设计 | index.html | 共享图标库(icon-sources.js,222 源 / 370,268 图标) | 共享技能(54 个) |
|
||||
| 头像设计 | avatar.html | 复用共享图标库(**无独立素材源**,avatarGen.js 扩充素材池) | 共享技能(54 个,自动停用字体类) |
|
||||
| 海报设计 | poster.html | 海报素材库(poster-sources.js,24 源 / 1581 素材) | 共享技能(54)+ 海报技能(64) |
|
||||
| 动态表情 | emoji.html | 复用共享图标库(底图 = 头像/类头像图形,emojiGen.js 动画化) | 表情技能(sticker-skills,12 个,motion/emotion) |
|
||||
|
||||
配套页面:素材库 materials.html(行式列表预览 LOGO/头像/海报素材,搜索 + 「查看更多」展开);素材中心 sources.html(图标)/ poster-sources.html(海报);技能中心 skills.html(共享)/ poster-skills.html(海报);表情技能在 emoji.html 预览弹窗自动全跑(追加即生效);审计页 audit-pixels.html(图标像素)/ audit-posters.html(海报像素)/ audit-emojis.html(表情逐帧)/ audit-render.html(通用渲染,已挂表情审计)。工作区通过 `window.HGB_WORKSPACE`(logo/avatar/poster/emoji)切换隔离。
|
||||
|
||||
### 5. 可读出口(manifest,供外部项目/AI 直接读取)
|
||||
- 纯前端懒加载 + 单行大索引(builtin-icons.js 等)对「外部读取」不友好(单行 2.4MB 一读就撞截断)。因此构建脚本输出**机器可读 JSON manifest** 作为对外出口,位置 `js/data/manifest/`:`icons.json`(build-icons.js 生成)/ `posters.json`(build-posters.js 生成)/ `skills.json`(`node scripts/build-skill-manifest.js` 生成,shared + poster + sticker 三技能库元数据,含 step/kind/workspaces)+ `README.md`(AI 读取指南:目录结构 / id 规范 / 数据文件位置 / 字段说明)
|
||||
- **读取约定**:外部项目或 AI 复用本库时,先读 `js/data/manifest/README.md` → 对应 manifest(有哪些源/数量/协议/数据文件路径)→ 按 manifest 的 `file` 字段定向读 `js/data/sources/{id}.js`(`window['HGB_Source_{id}'] = {icons:[...]}`)或 `js/data/posters/{id}.js`,勿直接读单行大索引
|
||||
- **前端页面同源**:sources.html / poster-sources.html 优先 fetch manifest(带 `?v=`),fetch 失败自动回退内置配置(window.HGB_IconSources / HGB_PosterSources);skills 页面直接读技能 API(HGB_Skills,与 manifest 同源)
|
||||
|
||||
### 2. LOGO 图标资源库(js/data/icon-sources.js,222 源 / 370,268 图标)
|
||||
- **type 三分类**:`icon` 单色 163 / `emoji` 多色 27 / `brand` 品牌 32(每个源、每个图标必带 type)
|
||||
- **拉取方式**:`format: 'iconify'` 199 个(@iconify-json 集合,`scaleMode: 'body'` 坐标归一化到 24)/ `dir-files` 12(npm 目录枚举)/ `gh-files` 6(GitHub 直拉)/ sprite 3 / nodes 1 / remix-files 1
|
||||
- **产物**:js/data/builtin-icons.js(轻量索引,`const HGB_BuiltinIcons` 为全局词法绑定、**非 window 属性**,须 typeof 探测)+ js/data/sources/{id}.js 按源懒加载(统一 `window['HGB_Source_{id}'] =` 赋值,**id 含连字符必须方括号**)+ js/data/sources/dicebear-{style}.js(29 风格头像,`window['HGB_Dicebear_xxx']`)
|
||||
- 图标 ID 规范:内置库「源:名称」(lucide:graduation-cap);行业图标裸 id
|
||||
- 品牌名回退顺序:设计面板品牌名 → 顶栏项目名 → 行业默认名
|
||||
|
||||
### 3. 头像资源体系(复用图标库,无独立素材源)
|
||||
- avatarGen.js 的 generateAvatars:先按风格 + 布局白名单(AVATAR_LAYOUTS,仅纯图形 Mark + avatarOnly 布局 fullbleed/concentric/centroid)生成基础方案,再按行业图标 id 拆分英文词根从共享图标库模糊匹配扩充素材池
|
||||
- 头像 vs LOGO 差异:布局白名单互斥(头像排除 monogram/ribbon 文字布局;LOGO 全局布局池排除 avatarOnly)
|
||||
- 导出走 exporter.js exportAvatarPngs:微信 300 / QQ 640 / 抖音 400 / YouTube 800 / App 1024 / Favicon 64 + 自定义 + 圆形裁切 circleCropCanvas
|
||||
|
||||
### 4. 海报素材资源库(js/data/poster-sources.js,24 源 / 1581 素材)
|
||||
- **type 8 分类**:palette 5 / font 12 / bg 3 / decoration 4(illustration / template / mockup / photo 暂无合规静态源,继续观测)
|
||||
- **拉取方式**:`format: 'gh-files'` 11(GitHub 直拉)/ `format: 'dir-files'` 13(npm 目录枚举:字体 woff2 / 配色 JSON)
|
||||
- **产物**:js/data/poster-builtin.js(轻量索引,window.HGB_PosterBuiltin,每源含 count)+ js/data/posters/{id}.js(`window['HGB_PosterSource_{id}'] = {items:[...]}`)+ js/data/posters/{id}/files/(位图/字体二进制按 file 引用直读)
|
||||
- palette 数据形态:.json 内联为 item.data(数组或扁平对象均可,冒烟 `typeof it.data === 'object'`);font 分类固定 `cat='font'`(避免名称误命中场景词表)
|
||||
- 素材 id = 「源:相对路径」;**海报技能 8 类**:layout/color/typography/grid/hierarchy/print/creative/ai(各 8 个)
|
||||
|
||||
### 6. 动态表情模块(emoji.html,复用图标库,无独立素材源)
|
||||
- **核心交互**:`generateEmojiSet`(行业/图标 → 一张静态底图)→ `expandAnimations`(**同图 × 全部动画类型** = 一组表情包);AI 编排决策插入首位变体;AI 失败自动降级内置池
|
||||
- **动画引擎 HGB_Motion**:`ANIMS` 注册表(11 种:bounce/pulse/wiggle/float/spin/fade/twirl/heartbeat/shake/breath/hopspin);`apply(el,t,p)` 帧函数返回 {tx,ty,rot,s,opacity} + `elementT` 元素级分层错峰 + `normalize` 补参;**预览 rAF 与导出逐帧采样共用同一套 apply**(预览 = 导出)
|
||||
- **表情技能库 HGB_StickerSkills**(js/sticker-skills/,12 个,分类 motion/emotion):预览弹窗自动全跑 motion 类技能(追加即生效)
|
||||
- **导出**:GIF89a 手写编码器(零依赖,LCT 255 色 + LZW)+ WebM + PNG;尺寸 240/320/512
|
||||
- **AI 编排 HGB_EmojiAI**:`suggest` → 结构化决策 JSON(布局/风格/动画参数)→ `validate` 白名单修正,非法参数回退默认值;无 HGB_AI/无 Key/异常静默降级
|
||||
|
||||
## 二、设计体系(技能库)
|
||||
|
||||
### 1. 共享技能库(js/skills/,54 个,7 类)
|
||||
- 架构:js/skills/core.js(HGB_Skills 注册表核心 + utils + 5 步流程 API)+ 7 分类文件(layout/color/typography/icon/ai/presentation/export),index.html 按 core→layout→color→typography→icon→ai→presentation→export 顺序加载;skills.html 动态渲染 `HGB_Skills.all()`,追加即自动生效
|
||||
- 分布:layout 10 / color 9 / typography 8 / icon 8 / export 8 / ai 6 / presentation 5
|
||||
- **流程组织**(core.js DESIGN_STEPS,用户视角的「设计流水线」):① 品牌洞察 5 → ② 方案生成 11 → ③ 方案评审 26 → ④ 迭代优化 1 → ⑤ 交付导出 11;`kind` 分布 check 28 / gen 17 / deliver 9;auditPlan 按 REVIEW_RUNNERS 适配器跑 26 项评审
|
||||
- 头像模式自动停用字体类(typography 分类 + slogan-gen)并重渲染技能区
|
||||
|
||||
### 2. 海报技能库(js/poster-skills/,64 个,8 类 × 8)
|
||||
- 架构:core.js(HGB_PosterSkills 注册表,workspaces 统一 ['poster'])+ skills.js(全部技能定义);通用技能复用 HGB_Skills,海报专属技能收本库
|
||||
- 分类与分布:layout/color/typography/grid/hierarchy/print/creative/ai 各 8 个
|
||||
- 技能标准字段:`id / name / category / emoji / scene / summary / params / source / run / test`(run 纯函数;test 自跑供技能中心「测试运行」)
|
||||
|
||||
### 3. 表情技能库(js/sticker-skills/,12 个,分类 motion/emotion)
|
||||
- 架构:core.js(HGB_StickerSkills 注册表,DEFAULT_WORKSPACES 统一 ['emoji'])+ skills.js(全部技能定义,纯函数)
|
||||
- 分类与分布:motion 11 / emotion 1(节奏/物理/循环/克制/情绪传达/风格匹配/体积/平台/分层协调/幅度/缓动 + 情绪-动画映射)
|
||||
- 生效路径:注册即自动进 manifest(build-skill-manifest.js 已并入)+ emoji.html 预览弹窗自动全跑 motion 类技能 + audit-emojis.html 审计
|
||||
|
||||
## 三、素材与技能拓展流程
|
||||
|
||||
> 任何素材/技能构建完成后,运行 `node scripts/build-baseline.js` 自动更新 `js/data/manifest/BASELINE.md`(基线快照),指令一~四即读最新基线。
|
||||
|
||||
### 新增图标源(LOGO 库 → icon-sources.js)
|
||||
1. 末尾追加源配置对象,**必须**带:唯一 `id`、`name`(英文)、`nameCn`(中文)、`desc`(中文一句话风格)、`type`(icon/emoji/brand)、`license` + `licenseNote`(中文)、`home`、`github`、`added`、`coordText`、`variants`
|
||||
2. 拉取方式:
|
||||
- Iconify 集合:`npm: '@iconify-json/<id>'`、`format: 'iconify'`、`scaleMode: 'body'`(坐标各异自动归一化到 24)
|
||||
- npm 目录枚举:`format: 'dir-files'`、`dirs: [...]`、可选 `weightSuffix`(子集后缀)/ `skipNameRe`(跳过命名不合规)/ `wrapOpen/wrapClose`(坐标系非 0 起点 `<g transform>` 包装)
|
||||
- GitHub 直拉:`format: 'gh-files'`、`gh: 'owner/repo'`、`ghBranch`、`dirs: [...]`;品牌 Logo viewBox 各异用 `scaleMode: 'wrap'`(逐图标 viewBox 归一化)
|
||||
3. 新源入库前必须确认「viewBox 坐标系 + 子集命名」四件套(scale / weightSuffix / skipNameRe / wrap),并抽查含连字符 id 数据文件可 eval
|
||||
4. 许可证过滤:排除 `BY-SA / BY-NC / BY-ND / GPL / -NC-`;接受 MIT / Apache-2.0 / ISC / CC BY 4.0 / OFL 等
|
||||
5. 运行 `node scripts/build-icons.js`(构建 + 增量缓存 + 批次下载质量校验)→ bump 版本号
|
||||
|
||||
### 新增海报源(海报库 → poster-sources.js)
|
||||
1. 末尾追加源配置对象(结构同 icon-sources 追加 `workspaces: ['poster']`,type 8 分类之一)
|
||||
2. 拉取方式:gh-files(GitHub 直拉)或 dir-files(npm 目录枚举,ext 过滤 .svg/.json/.woff2 等);`.json` 自动内联 data,font 固定 cat='font'
|
||||
3. 许可证过滤同上;超大源用 `maxItems` 限量、`exclude` 正则(匹配完整路径)剔除近重复变体
|
||||
4. 运行 `node scripts/build-posters.js` → bump 版本号
|
||||
5. 常见排除项:运行时生成器(无静态 SVG,如 svgwave/inkjet)、主题色存于扩展仓库(dracula/rose-pine)、jsDelivr 未索引(mrmrs/patterns)、重复源(与既有源同字体/同集合)
|
||||
|
||||
### 新增设计技能
|
||||
1. 共享技能:编辑 `js/skills/{分类}.js`,在 `HGB_Skills.register([...])` 数组末尾追加技能对象;分类 7 类(见上)
|
||||
2. 海报技能:编辑 `js/poster-skills/skills.js`,追加到 register 数组末尾;分类 8 类(见上)
|
||||
3. 表情技能:编辑 `js/sticker-skills/skills.js`,追加到 register 数组末尾;分类 motion/emotion(见上)
|
||||
4. 字段与约束:标准 10 字段;`run(args)` 纯函数返回结果;`test()` 必须能自跑;若用工具需自行 `const U = HGB_Skills.utils;`
|
||||
5. 追加即自动生效,无需改页面(技能中心动态渲染;表情技能自动进 manifest + emoji.html 预览弹窗自动全跑)
|
||||
|
||||
## 四、版本号联动(缓存 busting)
|
||||
|
||||
素材/技能变更后,同步 bump 版本号(格式 `YYYYMMDD` + 后缀),否则浏览器强缓存旧文件导致「改了没生效/整页崩溃」:
|
||||
- **LOGO 线**:`js/core/iconlib.js` 的 `VERSION`(控制懒加载 sources/{id}.js?v=)+ `js/core/dicebear.js` 的 `VERSION`(dicebear 文件变更时)+ index.html/sources.html/skills.html 中 `builtin-icons.js?v=` 与相关 `?v=`
|
||||
- **海报线**:`js/core/posterlib.js` 的 `VERSION` + poster.html/poster-sources.html/poster-skills.html/audit-posters.html 中 `poster-builtin.js?v=` 与相关 `?v=`
|
||||
- **表情线**:`js/core/motion.js` / `emojiGen.js` / `emojiAi.js` / `gifEncoder.js` / `js/sticker-skills/*.js` 变更 → emoji.html / audit-emojis.html 中相关 `?v=` 同步(无集中 VERSION,逐文件带参)
|
||||
- 验证时强制刷新(Ctrl+F5)或带查询参数(如 `?x=N` 递增绕过磁盘缓存)
|
||||
|
||||
## 五、验证流程
|
||||
|
||||
### 1. 构建端批次质量校验(两构建脚本均自动执行)
|
||||
- **数量完整性**:对比官方索引数与最终内置数,通过率 = 内置/官方,**< 80% 判异常**(下载损坏/文件缺失/过滤过度),构建结束输出「🔍 批次下载质量校验」汇总报告并列出异常源
|
||||
- **分辨率校验**:统计原始数据非法 viewBox(≤0 / 非有限 / 超 1e5),构建兜底归一化
|
||||
- **格式校验**:剔除空 body / 无几何 / 悬空引用(url(#id) 无定义),剔除数与通过率一并报告
|
||||
- **内容级像素校验**(构建端自动化,仅本次实际下载源):bodyBBox 仿射矩阵计算内容 bbox,检测「内容不在视口内」与「面积过小」两类渲染空白风险;疑似空白占比 ≥ 5% 判异常;须配合浏览器像素审计最终确认
|
||||
- **海报特有**:sha256 字节 + 格式魔数 + 分辨率参数 + SVG 内容校验;palette/font 内联与分类抽查
|
||||
|
||||
### 2. 浏览器像素级质量测试(每批次下载完成后)
|
||||
- 图标:audit-pixels.html(支持 `?only=源1,源2&max=N` 定向),canvas 渲染逐图标检测非透明像素,空白图标 = 渲染故障须归零
|
||||
- 海报:audit-posters.html(font/transform 源跳过像素渲染,走构建端校验),空白 0 / 失败 0 为通过
|
||||
- 表情:audit-emojis.html(逐帧渲染:每帧非透明像素 > 0 无空白帧 + 帧间确有变化 + GIF 头校验),空白 0 / 静态 0 为通过;audit-render.html 已 iframe 挂表情审计
|
||||
- 浏览器访问 http://localhost:55501 强制刷新,确认素材/技能中心无 JS 报错
|
||||
|
||||
### 3. Node 无头验证
|
||||
- `node --check js/skills/*.js`、`node --check js/poster-skills/skills.js` 与 `node --check js/sticker-skills/skills.js`(语法校验)
|
||||
- Node `vm` 冒烟测试:拼接 core + 数据文件 + 技能文件,逐个跑 `HGB_Skills.test(id)` / `HGB_PosterSkills.test(id)` / `HGB_StickerSkills.test(id)` 确认 0 失败;技能总数/分类白名单/每类下限断言;素材三方对账(配置 ↔ 索引 ↔ 按源数据文件)+ 全库 id 无重复
|
||||
- 表情专项冒烟:`node scripts/emoji-smoke.js`(motion 帧函数/确定性、gifEncoder LZW、emojiGen 产物、emojiAi 降级、sticker-skills test 0 失败)
|
||||
- 注意:Node 无头环境缺浏览器全局(HGB_AI/HGB_Industries/document 等)属环境分级容错,不计失败;顶层 `const HGB_*` 不挂 sandbox,需方括号取 window['...'] 或拼接进同一 vm 作用域
|
||||
|
||||
## 六、安全提醒
|
||||
- **不内置任何 API Key**:`js/core/ai.js` 的 `DEFAULT_KEY` 恒为空串;使用者在「🤖 AI 设置」填写自己的 DeepSeek Key,仅存本机 localStorage、仅发给 api.deepseek.com(BYOK 模式,站点侧读不到)
|
||||
- 源码 / 仓库 / 部署产物中不得出现任何 Key;推 Gitea 或上线前先 `grep -rn "sk-" .` 自查
|
||||
|
||||
## 七、教训
|
||||
- 浏览器强缓存旧 JS:验证修改时必须强制刷新(Ctrl+F5)或带查询参数访问
|
||||
- 构建脚本解析 sprite 时只剥离已知库前缀(lucide-/feather-/tabler-),避免误截断图标名
|
||||
- 用户核心诉求是「AI 智能化设计」,不是模板组合或传统拖拽编辑
|
||||
- SVG path 缩放禁止 toFixed() 固定小数位(0 → 0.00 非法语法),必须去尾零;弧命令 flags(0/1)不得缩放
|
||||
- 新增源后检查「id 含连字符」数据文件(`window['HGB_Source_xxx']` 方括号)能否 eval;来源按钮中文名靠索引 nameCn 自动生效,无需维护 SOURCE_CN
|
||||
- 新增技能必须提供 run/test;技能类 API 改动必须给 js/skills/*.js 加 ?v= 版本参数
|
||||
- 海报重复源教训:同一字体/集合先查既有源(如 Playfair Display = fontsource-playfair),避免重复收录
|
||||
|
||||
## 八、AI 指令库(可复用 Prompt,直接复制发给 AI 执行)
|
||||
|
||||
> 用途:持续扩充系统能力。五条指令各司其职、可独立下发:**指令一~三** 对应三条采集管线(图标源 / 海报源 / 设计技能,技能含共享/海报/表情三库),互相独立、各自完整闭环(探测→构建→版本联动→验证→收口更新基线),**可分开跑、分开收口**;**指令四** 在新增技能/素材后自查设计流水线优化空间(含表情动画/编排管线);**指令五发给「外部项目」的 AI 使用**(调取本库能力,只读)。共性铁律:**价值门槛**(拒同质堆量)+ **去重/协议合规**(防污染)+ **构建质量校验**(≥80% 通过率)+ **版本联动**(防缓存失效)+ **双端验证**(Node 无头 + 浏览器像素)。所有指令的基线一律读 `js/data/manifest/BASELINE.md`(构建后 `node scripts/build-baseline.js` 自动更新),指令文本本身无需维护任何数字。
|
||||
|
||||
### 指令一:新增图标源(LOGO/头像共享图标库)
|
||||
|
||||
```text
|
||||
你是「画个标」项目的素材采集工程师,任务是为共享图标库新增 1 个合规、高质量、不重复的图标源,并完成构建、版本联动与质检闭环。
|
||||
|
||||
【先读基线(构建自动生成,勿手算)】读 js/data/manifest/BASELINE.md ① 图标库节:当前源数/分类/总数。权威清单 js/data/icon-sources.js(末尾追加源配置)。构建 scripts/build-icons.js(@cfg hash 增量缓存)。去重以 icon-sources.js 现有 id 为准。产物约定:builtin-icons.js 索引(const HGB_BuiltinIcons 非 window 属性)+ js/data/sources/{id}.js(统一 window['HGB_Source_{id}'] 方括号赋值,id 含连字符必须方括号)+ dicebear-{style}.js。
|
||||
|
||||
【硬性门槛,任一不过即放弃】
|
||||
1. 价值门槛:新源须带来新覆盖(新主题/新风格/新品牌),与既有源同质重复的排除;先 Grep 查 id 是否已收录。
|
||||
2. 协议合规:排除 BY-SA/BY-NC/BY-ND/GPL/商业自定义条款;接受 MIT/Apache-2.0/ISC/CC BY 4.0/OFL/CC0/Unlicense/BSD/MPL。
|
||||
3. 可拉取性:jsDelivr 对 >50MB 大仓库 403(cncf/artwork 等);scoped npm 包 unpkg 元数据 302 需跟随;raw.githubusercontent 证书问题用 rejectUnauthorized:false 探测,下载走 jsDelivr CDN。
|
||||
|
||||
【执行步骤】
|
||||
1. 探测:优先 Iconify collections.json 未收录集合;其次 npm 目录(unpkg `目录/?meta` 枚举 + 单文件 head 取样);再次 GitHub(data.jsdelivr.com 枚举文件树)。国产 UI 框架优先 npm 包(semi-icons 成功,arco/vant/nutui 无 SVG 弃)。
|
||||
2. 确定「viewBox 坐标系 + 子集命名」四件套:scale(非 24 坐标数值比例,如 512→24);weightSuffix(子集是否自带后缀,Phosphor fill 文件自带 -fill 需剥除);skipNameRe(正则滤命名不合规如纯数字);wrapOpen/wrapClose(坐标系非 0 起点用 <g transform> 包装,勿改 path 坐标)。品牌 Logo 逐图标 viewBox 各异用 format:'gh-files' + scaleMode:'wrap'。
|
||||
3. 配置:在 icon-sources.js 末尾追加对象,必带 id(唯一)/type/name/nameCn/desc(中文)/style/license/licenseNote(中文)/home/github/npm/format/urls/dirs/scale/weightSuffix/skipNameRe/wrapOpen/wrapClose/coordText/variants/added;海报也用才加 workspaces:['poster']。图标级 type 由构建脚本注入,无需手写。
|
||||
4. 构建:node scripts/build-icons.js,确认批次质量校验通过率 ≥80%、无「内容不在视口/面积过小」空白风险源。
|
||||
5. 版本联动:bump js/core/iconlib.js VERSION(如 20260823b)→ index.html/sources.html/skills.html 全部 ?v= 同步;dicebear 文件变更才 bump dicebear.js VERSION。
|
||||
6. 验证:Node eval 数据文件(含连字符 id 方括号、无 .00 残留、索引三方对账);浏览器 http://localhost:55501 强制刷新,audit-pixels.html?only=新源 像素审计空白 0;sources.html 来源按钮中文名自动生效(索引 nameCn)。
|
||||
7. 收口:node scripts/build-baseline.js 更新基线,确认新增源在 BASELINE.md ① 图标库节就位(源数/总数/type 分布变化),本轮独立收口完成;如需留痕可 git 提交本次改动。
|
||||
|
||||
【坑位提醒】禁止 toFixed() 缩放 path(0→0.00 非法语法,用去尾零);弧命令 flags 不得缩放;id 重复录入会导致构建覆盖数据混淆(carbon 教训);licence 存疑源(如 prime 商业协议)标记备查不收录。
|
||||
```
|
||||
|
||||
### 指令二:新增海报源(海报素材库)
|
||||
|
||||
```text
|
||||
你是「画个标」项目的海报素材采集工程师,任务是为海报素材库新增 1 个合规、高质量、不重复的海报素材源(palette/font/bg/decoration 优先,illustration/template/mockup/photo 暂无合规静态源需继续观测)。
|
||||
|
||||
【先读基线(构建自动生成,勿手算)】读 js/data/manifest/BASELINE.md ② 海报素材库节:当前源数/分类/总数。权威清单 js/data/poster-sources.js(末尾追加源配置,type 8 分类)。构建 scripts/build-posters.js(BUILD_REV='2')。去重以 poster-sources.js 现有 id 为准。产物约定:poster-builtin.js(window.HGB_PosterBuiltin,每源 count)+ js/data/posters/{id}.js(window['HGB_PosterSource_{id}'])+ {id}/files/ 位图/字体二进制直读。
|
||||
|
||||
【硬性门槛,任一不过即放弃】
|
||||
1. 价值门槛:与既有源同字体/同集合/同配色重复的排除(先查:Playfair Display 与 fontsource-playfair 重复教训);主题色存于扩展仓库(dracula/rose-pine)、jsDelivr 未索引(mrmrs/patterns)、运行时生成器(svgwave/inkjet,无静态 SVG 违反静态素材铁律)一律排除。
|
||||
2. 协议合规:同图标源(排除 BY-SA/BY-NC/BY-ND/GPL)。
|
||||
3. 可拉取性:jsDelivr 超 50MB 拒绝索引(403)不可用(fxemoji/noto-emoji/Montserrat 教训);仓库仅 .glyphs/UFO 源文件无成品字体不可用。
|
||||
|
||||
【执行步骤】
|
||||
1. 探测:gh-files 走 data.jsdelivr.com 枚举文件树 + cdn.jsdelivr.net/gh/ 下载;dir-files 走 npm 目录枚举(ext 过滤 .svg/.json/.woff2 等)。
|
||||
2. 分类:确定 type 8 分类之一;palette 数据 .json 内联为 item.data(数组/扁平对象均可);font 固定 cat='font' 避免名称误命中场景词表。
|
||||
3. 配置:poster-sources.js 末尾追加,必带 id(唯一)/type/name/nameCn/desc(中文)/license/licenseNote(中文)/home/github/format/gh+ghBranch+dirs 或 npm+dirs+ext/scaleMode/coordText/variants/added/workspaces:['poster'];超大源用 maxItems 限量、exclude 正则(匹配完整路径)剔除近重复变体。
|
||||
4. 构建:node scripts/build-posters.js,确认 sha256/格式魔数/分辨率/数量 ≥80% 全部通过。
|
||||
5. 版本联动:bump js/core/posterlib.js VERSION → poster.html/poster-sources.html/poster-skills.html/audit-posters.html 全部 ?v= 同步。
|
||||
6. 验证:Node 审计(内联与分类抽查)+ 浏览器强制刷新 audit-posters.html(font/transform 源跳过像素渲染,空白 0/失败 0)+ poster-sources.html console 零报错。
|
||||
7. 收口:node scripts/build-baseline.js 更新基线,确认新增源在 BASELINE.md ② 海报素材库节就位(源数/总数/分类变化),本轮独立收口完成;如需留痕可 git 提交本次改动。
|
||||
|
||||
【坑位提醒】先 Grep 查重复源再录入;exclude 正则锚定完整路径(twemoji 国旗段教训);探测排除项记录在源文件注释备查,避免重复踩坑。
|
||||
```
|
||||
|
||||
### 指令三:新增设计技能(共享 + 海报 + 表情)
|
||||
|
||||
```text
|
||||
你是「画个标」项目的技能研发工程师,任务是新增设计技能。技能是系统的「智能能力」,新增必须解决真实设计问题、可自测、自动生效。
|
||||
|
||||
【先读基线(构建自动生成,勿手算)】读 js/data/manifest/BASELINE.md ③ 技能库节:共享/海报/表情技能数、分类分布、四区覆盖(头像向最少则本批优先补头像;表情向看表情技能分类分布)。去重:Grep 技能 id,共享/海报/表情三库不得互相重复。
|
||||
|
||||
【硬性门槛,任一不过即重写】
|
||||
1. 标准 10 字段:id/name/category/emoji/scene/summary/params/source/run/test;可选 workspaces(不标=四区通用,typography 类不含头像)。
|
||||
2. 四区覆盖:每批至少 1 个头像向技能(workspaces 含 'avatar' 或 scene 明确头像场景)与至少 1 个表情向技能(category ∈ motion/emotion 或 scene 明确表情/动画场景);头像纯图形无文字(typography/slogan-gen 自动停用);表情技能纯函数勿依赖浏览器全局。
|
||||
3. run 纯函数返回 {ok/tip/...},无浏览器全局依赖;test 必须能自跑断言。
|
||||
4. 共享技能补 core.js 的 SKILL_STEPS(未映射默认步骤 2);海报技能 workspaces 统一 ['poster'];表情技能 workspaces 统一 ['emoji'](sticker-skills core.js DEFAULT_WORKSPACES)。
|
||||
|
||||
【执行与验证】
|
||||
1. 注册:共享技能追加到 js/skills/{分类}.js 的 register 数组末尾;海报技能追加到 js/poster-skills/skills.js register 末尾;表情技能追加到 js/sticker-skills/skills.js register 末尾。
|
||||
2. 冒烟:node --check 各文件;vm 拼接 core+数据依赖逐个跑 test 0 失败;表情技能跑 node scripts/emoji-smoke.js;断言分类白名单与四区均衡(头像 ≥ LOGO 60%)。
|
||||
3. 版本联动:bump 改动技能文件 ?v=(core 改动必须 bump)→ 相关页面同步(表情线见「四、版本号联动」)。
|
||||
4. 浏览器验证:强制刷新,技能中心有新卡、🔬测试通过、console 零报错;avatar.html 无 typography 泄漏;emoji.html 预览弹窗自动全跑新增表情技能。
|
||||
5. 收口:node scripts/build-skill-manifest.js 更新 skills.json(含 sticker 分支)→ node scripts/build-baseline.js 更新基线,确认技能数在 BASELINE.md ③ 技能库节就位(三库总数/分类分布/覆盖变化),本轮独立收口完成;如需留痕可 git 提交本次改动。
|
||||
|
||||
【坑位提醒】用 HGB_Skills.utils 自行声明 U;忘了 ?v= 会被缓存整页崩;头像技能勿依赖文字;只服务部分工作区的共享技能必须显式标 workspaces;表情技能 test 须自跑(audit-emojis.html 会全量断言)。
|
||||
```
|
||||
|
||||
### 指令四:设计流程优化(新增技能/素材后,自查流水线优化空间)
|
||||
|
||||
> 场景:新增技能/素材后、或收到用户对设计体验的反馈时,检查设计流水线是否有优化空间(如新技能可吸收进生成前置、评审规则可注入生成阶段等)。与指令一~三配合:采集是「加零件」,本条是「调装配线」。
|
||||
|
||||
```text
|
||||
你是「画个标」项目的流程优化工程师,任务是检查设计流水线(DESIGN_STEPS 五步 + SKILL_STEPS 映射 + auditPlan 评审 + AI prompt 编排 + 表情动画/编排管线)是否有可落地的优化空间,并实施、验证、回归。
|
||||
|
||||
【先读基线】读 js/data/manifest/BASELINE.md ③ 技能库节 + 读 js/skills/core.js(DESIGN_STEPS / SKILL_STEPS / REVIEW_RUNNERS / workspacesOf)、js/poster-skills/core.js 与 js/core/motion.js + emojiGen.js + emojiAi.js(表情 ANIMS 注册表 / expandAnimations / AI 编排),了解当前流水线组织。
|
||||
|
||||
【找优化空间(从这些入口自查)】
|
||||
1. 新技能吸收:最近新增的 gen/check 技能,是否只挂在「评审」步骤而未进入「方案生成」前置?若技能能在生成阶段前置约束(先审后生成),应调整 AI prompt 编排或 SKILL_STEPS。表情锚点:motion.js 新动画类型注册进 ANIMS 即被 expandAnimations 自动展开(生成前置,无需再接线)。
|
||||
2. 评审闭环:auditPlan 跑的评审项与「迭代优化」步骤是否闭环——评审发现的失败高频项是否有对应优化(生成侧规避 or 评审侧新增检查)?表情锚点:emoji.html renderStickerAudit 自动全跑 motion 类技能(追加即生效);emojiAi 动画决策已注入生成前置。
|
||||
3. 用户反馈:评审分数低/风格雷同/速度慢等反馈,是否有流程层根因(prompt 编排、步骤顺序、降级路径)?表情锚点:AI 编排降级率 / 表情技能检查通过率 / GIF 导出体积与耗时。
|
||||
4. 工作区过滤:头像自动停用字体类等 workspacesOf/forWorkspace 规则是否随新技能正确收编。表情锚点:HGB_StickerSkills.workspacesOf 统一 ['emoji'],勿让表情技能漏进其他工作区。
|
||||
5. 版本与验证:新增技能/素材是否都走「版本联动 + 冒烟 + 浏览器验证」闭环。表情锚点:emoji-smoke.js + audit-emojis.html + 浏览器实测。
|
||||
|
||||
【硬性门槛,任一不过即不改】
|
||||
1. 不破坏架构铁律:AI 输出结构化设计决策 JSON + 系统负责渲染/导出;AI 失败自动降级内置引擎;禁止改为模板随机组合。
|
||||
2. 可验证:任何流程改动必须通过 Node vm 冒烟(HGB_Skills.test / HGB_PosterSkills.test / HGB_StickerSkills.test 0 失败)+ 浏览器真实走一遍设计流程(index/avatar/poster/emoji 四工作区各一)。
|
||||
3. 兼容:不改坏既有技能注册 API(run/test 签名、utils、REVIEW_RUNNERS 适配器归一化)与动画 API(HGB_Motion.apply/elementT 签名,预览=导出一致)。
|
||||
4. 变更即 bump:改动 core.js/分类文件/动画文件/ai prompt 相关文件 → 对应 ?v= 全部同步(表情线逐文件带参),否则浏览器缓存旧文件整页崩。
|
||||
|
||||
【执行步骤】
|
||||
1. 分析并写出一句话结论:优化点是什么、解决什么问题、怎么验证。
|
||||
2. 改动 js/skills/core.js(SKILL_STEPS/DESIGN_STEPS/REVIEW_RUNNERS/prompt 编排)、js/poster-skills/ 或 js/core/motion.js + emojiGen.js + emojiAi.js;纯 prompt 优化则改 ai 相关文件。
|
||||
3. 冒烟:node --check 改动文件;vm 拼接 core+依赖逐个跑 test 0 失败;断言流程步骤计数/分类白名单不变或按预期变化。
|
||||
4. 版本联动:bump 改动文件 ?v= → 相关页面同步。
|
||||
5. 浏览器验证:四工作区各真实生成一次,确认流程走通、评审项计数正确、console 零报错。
|
||||
|
||||
【坑位提醒】「先审后生成」类优化只动 prompt 编排不动技能本体;流程步骤变化会改变技能中心区块计数,验证时核对;表情动画优化必须保持预览与导出逐帧采样用同一套 apply(预览 = 导出);优化失败回滚只 revert 本次改动文件(技能注册表与素材互不牵连,风险隔离)。
|
||||
```
|
||||
|
||||
### 指令五:跨项目调取本库能力(发给外部项目 AI 使用)
|
||||
|
||||
> 当别的项目要「调取画个标的能力」时,把下面整段发给那个项目的 AI。只读复用,禁止修改画个标代码。入口永远走 manifest(先读 README → manifest → 按 file 定向读数据文件),此即解决「读不准」的既定路径。
|
||||
|
||||
```text
|
||||
你是外部项目 AI,需要调取「画个标」设计系统的素材库与技能库能力(只读复用,禁止修改画个标代码)。
|
||||
|
||||
【定位方式(二选一,自行判断可用性)】
|
||||
- 本地路径:d:\SGP_KF\DrawLogo\(本机 git 仓库,直接读文件)
|
||||
- HTTP 部署:{baseUrl}/js/data/manifest/...(如 http://localhost:55501)
|
||||
|
||||
【读取步骤(顺序固定,勿跳步)】
|
||||
1. 先读 js/data/manifest/README.md —— 目录结构 / id 规范 / 字段说明 / 数据形态
|
||||
2. 再读目标 manifest(纯 JSON,分行可读,含每个源的协议/数量/数据文件路径;数量一律以 manifest 实际值为准,勿依赖记忆数字):
|
||||
- icons.json —— 共享图标库(type: icon 单色 / emoji 多色 / brand 品牌;dicebear 字段含头像风格)
|
||||
- posters.json —— 海报素材库(type: palette/font/bg/decoration…)
|
||||
- skills.json —— 技能库(shared + poster + sticker;含 step 流水线步骤 / kind 性质 / workspaces 工作区)
|
||||
3. 按 manifest 里 source 的 file 字段定向读数据文件(勿猜路径):
|
||||
- js/data/sources/{id}.js → window['HGB_Source_{id}'] = { icons: [{ id, name, categoryCn, svg, type }] }
|
||||
- js/data/posters/{id}.js → window['HGB_PosterSource_{id}'] = { items: [...] }
|
||||
- 二进制(字体/位图)→ js/data/posters/{id}/files/ 按 file 引用直读
|
||||
|
||||
【数据使用规则】
|
||||
- 图标 svg 是归一化到 24 viewBox 的 body 片段:单色 icon 渲染套 <svg viewBox="0 0 24 24" fill="currentColor">;emoji/brand 多色保留原样
|
||||
- 图标 id 规范:内置库「源:名称」(lucide:graduation-cap);行业图标裸 id
|
||||
- 技能:元数据(scene/summary/params/source)可直接当设计知识/评审规则;run/test 是函数实现,如需执行须加载 js/skills/*.js(含数据依赖)后调用 HGB_Skills.run(id, args)
|
||||
- 协议合规:BY-SA/BY-NC/BY-ND/GPL 源不可商用;引用素材须标注来源与协议
|
||||
- 图标筛选质量规则:按 name 匹配用「词边界 + 意象白名单」而非子串(否则混入 door-open/baguette 等噪音);排除 -off/-alert/-error/-forbidden 等负面变体;做 LOGO/品牌用途时避开品牌标志图形(instagram/mickey 等专有图形,有商标风险)
|
||||
|
||||
【反模式(禁止)】
|
||||
- 勿读单行大索引 builtin-icons.js / poster-builtin.js(单行 2.4MB,读不动)——manifest 已含全部所需元信息
|
||||
- 勿 eval 数据文件(它是 window 赋值,需作为 JS 加载后取 window['HGB_Source_xxx'] 或正则提取对象)
|
||||
- 勿猜文件名——一切以 manifest 的 file 字段为准
|
||||
- 勿整读大文件:sources/{id}.js 单个几百 KB~数 MB,超过 ~200KB 应用脚本/正则采样提取目标图标(如 Node vm 加载后按 id 取),勿用 Read 整读进上下文
|
||||
- 勿在画个标仓库里写代码/加素材;产出(设计稿/脚本)放外部项目或临时目录(如 _design-test/),不写进画个标源码树(扩库请回画个标项目,按其「AI 指令库 指令一~三」执行闭环)
|
||||
|
||||
【能力边界】
|
||||
- 能:参考素材风格/配色、复用技能设计方法与评审规则、按协议引用图标/字体
|
||||
- 不能:调用 DeepSeek AI 设计接口(Key 内置于画个标前端,外部不可复用);不能跨项目执行画个标渲染/导出管线(纯前端,需在其页面内运行)
|
||||
|
||||
【最小示例】从 icons.json 找 nameCn 含「学校」的源 → 读其 file 指向的数据文件 → 在 icons 里用 name 词边界匹配 education/grad/graduation(或按 categoryCn 过滤)→ 取 graduation-cap 的 svg → 包 <svg viewBox="0 0 24 24"> 渲染。注意:图标级 name/categoryCn 在数据文件里,不在 manifest。
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>手机样式图标候选 · 小米橙 · 透明底</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: "PingFang SC", "Microsoft YaHei", sans-serif; background: #fff; color: #111; padding: 28px; }
|
||||
h1 { font-size: 18px; margin-bottom: 4px; }
|
||||
p.tip { font-size: 13px; color: #6b7280; margin-bottom: 20px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; background: #fff; }
|
||||
.stage {
|
||||
height: 120px; display: flex; align-items: center; justify-content: center;
|
||||
background-image:
|
||||
linear-gradient(45deg, #f1f5f9 25%, transparent 25%, transparent 75%, #f1f5f9 75%),
|
||||
linear-gradient(45deg, #f1f5f9 25%, #fff 25%, #fff 75%, #f1f5f9 75%);
|
||||
background-size: 16px 16px; background-position: 0 0, 8px 8px; /* 棋盘格 = 透明区域示意,非底色 */
|
||||
}
|
||||
.stage svg { width: 72px; height: 72px; display: block; }
|
||||
.cap { font-size: 13px; padding: 8px 10px 2px; }
|
||||
.meta { font-size: 11px; color: #9ca3af; padding: 0 10px 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>手机样式 App 图标候选 · 小米橙 #FF6900(透明底,棋盘区域 = 透明)</h1>
|
||||
<p class="tip">10 个候选,挑好后把编号发我。小米橙渲染,可再微调。</p>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="stage"><svg viewBox="0 0 24 24" fill="#FF6900"><path d="M17,19H7V5H17M17,1H7C5.89,1 5,1.89 5,3V21A2,2 0 0,0 7,23H17A2,2 0 0,0 19,21V3C19,1.89 18.1,1 17,1Z" /></svg></div>
|
||||
<div class="cap"><b>1</b> 纯手机(实心)</div>
|
||||
<div class="meta">mdi:cellphone</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="stage"><svg viewBox="0 0 24 24" fill="none" stroke="#FF6900" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2" ry="2" /> <path d="M12 18h.01" /></svg></div>
|
||||
<div class="cap"><b>2</b> 纯手机(线条)</div>
|
||||
<div class="meta">lucide:smartphone</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="stage"><svg viewBox="0 0 24 24" fill="#FF6900"><path d="M14.54 23H7C5.9 23 5 22.11 5 21V3C5 1.89 5.89 1 7 1H17C18.1 1 19 1.89 19 3V13C18.3 13 17.63 13.13 17 13.35V5H7V19H13C13 20.54 13.58 21.94 14.54 23M17.75 22.16L15 19.16L16.16 18L17.75 19.59L21.34 16L22.5 17.41L17.75 22.16" /></svg></div>
|
||||
<div class="cap"><b>3</b> 手机+对勾(办结)</div>
|
||||
<div class="meta">mdi:cellphone-check</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="stage"><svg viewBox="0 0 24 24" fill="#FF6900"><path d="M9.82,12.5C9.84,12.33 9.86,12.17 9.86,12C9.86,11.83 9.84,11.67 9.82,11.5L10.9,10.69C11,10.62 11,10.5 10.96,10.37L9.93,8.64C9.87,8.53 9.73,8.5 9.62,8.53L8.34,9.03C8.07,8.83 7.78,8.67 7.47,8.54L7.27,7.21C7.27,7.09 7.16,7 7.03,7H5C4.85,7 4.74,7.09 4.72,7.21L4.5,8.53C4.21,8.65 3.92,8.83 3.65,9L2.37,8.5C2.25,8.47 2.12,8.5 2.06,8.63L1.03,10.36C0.97,10.5 1,10.61 1.1,10.69L2.18,11.5C2.16,11.67 2.15,11.84 2.15,12C2.15,12.17 2.17,12.33 2.19,12.5L1.1,13.32C1,13.39 1,13.53 1.04,13.64L2.07,15.37C2.13,15.5 2.27,15.5 2.38,15.5L3.66,15C3.93,15.18 4.22,15.34 4.53,15.47L4.73,16.79C4.74,16.91 4.85,17 5,17H7.04C7.17,17 7.28,16.91 7.29,16.79L7.5,15.47C7.8,15.35 8.09,15.17 8.36,15L9.64,15.5C9.76,15.53 9.89,15.5 9.95,15.37L11,13.64C11.04,13.53 11,13.4 10.92,13.32L9.82,12.5M6,13.75C5,13.75 4.2,12.97 4.2,12C4.2,11.03 5,10.25 6,10.25C7,10.25 7.8,11.03 7.8,12C7.8,12.97 7,13.75 6,13.75M17,1H7A2,2 0 0,0 5,3V6H7V4H17V20H7V18H5V21A2,2 0 0,0 7,23H17A2,2 0 0,0 19,21V3A2,2 0 0,0 17,1Z" /></svg></div>
|
||||
<div class="cap"><b>4</b> 手机+齿轮(设置)</div>
|
||||
<div class="meta">mdi:cellphone-cog</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="stage"><svg viewBox="0 0 24 24" fill="#FF6900"><path d="M11,17V7H4V17H11M11,3A2,2 0 0,1 13,5V19A2,2 0 0,1 11,21H4C2.89,21 2,20.1 2,19V5A2,2 0 0,1 4,3H11M16.5,3H21.5A1.5,1.5 0 0,1 23,4.5V7.5A1.5,1.5 0 0,1 21.5,9H18L15,12V9L15,4.5A1.5,1.5 0 0,1 16.5,3Z" /></svg></div>
|
||||
<div class="cap"><b>5</b> 手机+聊天(沟通)</div>
|
||||
<div class="meta">mdi:cellphone-message</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="stage"><svg viewBox="0 0 24 24" fill="#FF6900"><path d="M19.1,8.7C20.9,10.5 20.9,13.3 19.1,15.2L20.1,16.2C22.6,13.9 22.6,10.1 20.1,7.7L19.1,8.7M18,9.8L17,10.8C17.5,11.5 17.5,12.4 17,13.1L18,14.1C19.2,12.9 19.2,11.1 18,9.8M14,1H4A2,2 0 0,0 2,3V21A2,2 0 0,0 4,23H14A2,2 0 0,0 16,21V3A2,2 0 0,0 14,1M14,20H4V4H14V20Z" /></svg></div>
|
||||
<div class="cap"><b>6</b> 手机+声波(通知)</div>
|
||||
<div class="meta">mdi:cellphone-sound</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="stage"><svg viewBox="0 0 24 24" fill="none" stroke="#FF6900" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 3h10v8h-3l-4 2v-2h-3l0 -8"/><path d="M15 16v4a1 1 0 0 1 -1 1h-8a1 1 0 0 1 -1 -1v-14a1 1 0 0 1 1 -1h2"/><path d="M10 18v.01"/></svg></div>
|
||||
<div class="cap"><b>7</b> 手机+消息(线条)</div>
|
||||
<div class="meta">tabler:device-mobile-message</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="stage"><svg viewBox="0 0 24 24" fill="none" stroke="#FF6900" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21h-4a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v6"/><path d="M15 18a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M20.2 20.2l1.8 1.8"/><path d="M11 4h2"/><path d="M12 17v.01"/></svg></div>
|
||||
<div class="cap"><b>8</b> 手机+搜索</div>
|
||||
<div class="meta">tabler:device-mobile-search</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="stage"><svg viewBox="0 0 24 24" fill="none" stroke="#FF6900" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 21h-3a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v5"/><path d="M11 4h2"/><path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138"/></svg></div>
|
||||
<div class="cap"><b>9</b> 手机+星标</div>
|
||||
<div class="meta">tabler:device-mobile-star</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="stage"><svg viewBox="0 0 24 24" fill="none" stroke="#FF6900" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2" ry="2" /> <path d="M12.667 8 10 12h4l-2.667 4" /></svg></div>
|
||||
<div class="cap"><b>10</b> 手机+闪电</div>
|
||||
<div class="meta">lucide:smartphone-charging</div>
|
||||
</div></div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
// 甄房换肤 · Electron 桌面外壳 + docs 独立工具页
|
||||
// 注意:这些文件是「深色外壳」上下文,同一个旧色在这里的含义与 WEB 不同 ——
|
||||
// 例如 #FFB31A 在 WEB 是警告色(→ #E76500),在外壳里是「品牌浅色」(→ #4D9BF6),
|
||||
// 所以不能复用 WEB 的映射表,必须单独一套。
|
||||
// 用法: node _design-test/apply-electron-colors.js (预演)
|
||||
// node _design-test/apply-electron-colors.js --write (真写)
|
||||
const fs = require('fs');
|
||||
|
||||
const WRITE = process.argv.includes('--write');
|
||||
const ZF = 'e:/KF_7/ZhenOS-ZF';
|
||||
|
||||
const FILES = [
|
||||
`${ZF}/xiaohuju/resources/splash/splash.html`,
|
||||
`${ZF}/xiaohuju/resources/offline/offline.html`,
|
||||
`${ZF}/xiaohuju/resources/blocked/blocked.html`,
|
||||
`${ZF}/xiaohuju/src/common/constants.js`,
|
||||
`${ZF}/xiaohuju/src/preload/titlebar.js`,
|
||||
`${ZF}/docs/财务策算测算器-v2.html`,
|
||||
`${ZF}/docs/财务策算测算器.html`
|
||||
];
|
||||
|
||||
// 十六进制(大小写不敏感)
|
||||
const HEX = [
|
||||
['#ff822d', '#0070F2'], // blocked.html 里的品牌亮色
|
||||
['#ff7a1a', '#0070F2'], // 品牌亮色 → 交互蓝
|
||||
['#ff9a4d', '#4D9BF6'],
|
||||
['#ff8a3d', '#4D9BF6'],
|
||||
['#ffb31a', '#4D9BF6'], // 外壳里的「品牌浅色」→ 浅蓝(非警告橙)
|
||||
['#ffc48a', '#A6CFFF'],
|
||||
['#1a130d', '#071527'], // 最深底 → 甄房最深墨蓝
|
||||
['#2e241a', '#0C2340'],
|
||||
['#241c14', '#0A1D35'], // 标题栏底
|
||||
['#33281d', '#0C2340'],
|
||||
['#f1e4d2', '#EAF0F6'], // 标题栏符号(暖白 → 冷白)
|
||||
['#f6ead8', '#F0F4F8'],
|
||||
['#2a2118', '#131E29'],
|
||||
['#3a322a', '#556B82'],
|
||||
['#756a5e', '#5F7590'],
|
||||
['#d9481c', '#AA0808'],
|
||||
['#2e9e5b', '#256F3A'],
|
||||
['#ffb31a', '#4D9BF6']
|
||||
];
|
||||
|
||||
// rgb()/rgba() 三元组(保留 rgb/rgba 前缀与后面的 alpha)
|
||||
const RGB = [
|
||||
[/255,\s*122,\s*26/g, '0, 112, 242'],
|
||||
[/255,\s*130,\s*45/g, '0, 112, 242'],
|
||||
[/255,\s*179,\s*26/g, '77, 155, 246'],
|
||||
[/241,\s*228,\s*210/g, '240, 244, 248'], // 暖白带透明
|
||||
[/58,\s*50,\s*42/g, '12, 35, 64'],
|
||||
[/26,\s*19,\s*13/g, '7, 21, 39'],
|
||||
[/42,\s*33,\s*24/g, '19, 30, 41'],
|
||||
[/51,\s*40,\s*29/g, '12, 35, 64']
|
||||
];
|
||||
|
||||
let grand = 0, touched = 0;
|
||||
for (const f of FILES) {
|
||||
if (!fs.existsSync(f)) { console.log(' 跳过(不存在): ' + f.split('/').pop()); continue; }
|
||||
let txt = fs.readFileSync(f, 'utf8');
|
||||
const orig = txt;
|
||||
const hits = [];
|
||||
for (const [from, to] of HEX) {
|
||||
const re = new RegExp(from, 'gi');
|
||||
const n = (txt.match(re) || []).length;
|
||||
if (n) { txt = txt.replace(re, to); hits.push(from + '→' + to + ' ×' + n); grand += n; }
|
||||
}
|
||||
for (const [re, to] of RGB) {
|
||||
const n = (txt.match(re) || []).length;
|
||||
if (n) { txt = txt.replace(re, to); hits.push('rgb元组 ×' + n); grand += n; }
|
||||
}
|
||||
if (txt !== orig) { touched++; console.log(' ' + f.replace(ZF + '/', '') + '\n ' + hits.join(' ')); }
|
||||
if (WRITE && txt !== orig) fs.writeFileSync(f, txt);
|
||||
}
|
||||
console.log('\n涉及 ' + touched + ' 个文件 / 替换 ' + grand + ' 处 ' + (WRITE ? '(已写入)' : '(预演,加 --write 生效)'));
|
||||
@@ -0,0 +1,63 @@
|
||||
// 把样式层剩余的字面量旧色值替换为甄房新色板(精确 1:1 映射,大小写不敏感)
|
||||
// 只作用于指定的两个文件,不碰任何 .vue 业务页面
|
||||
// 用法: node _design-test/apply-scss-colors.js (预演,只报告不写)
|
||||
// node _design-test/apply-scss-colors.js --write (真写)
|
||||
const fs = require('fs');
|
||||
|
||||
const WRITE = process.argv.includes('--write');
|
||||
|
||||
const MAP = [
|
||||
['#ff7a1a', '#0070F2'], // 品牌橙 → 交互蓝
|
||||
['#e86a0c', '#0064D9'],
|
||||
['#d96408', '#0057D2'],
|
||||
['#ff8a3d', '#4D9BF6'],
|
||||
['#ffc48a', '#A6CFFF'],
|
||||
['#ffe8d1', '#EBF8FF'],
|
||||
['#fff6ec', '#F0F6FE'],
|
||||
['#f6ead8', '#EAECEE'],
|
||||
['#faf7f2', '#F5F6F7'], // 暖白 → 冷白
|
||||
['#e9d3b0', '#758CA4'], // 暖边框 → 冷边框
|
||||
['#efe7dc', '#E5E5E5'],
|
||||
['#fbf5ea', '#F5F6F7'],
|
||||
['#2a2118', '#131E29'], // 暖墨 → 冷墨
|
||||
['#3a322a', '#556B82'],
|
||||
['#756a5e', '#5F7590'],
|
||||
['#9a8e80', '#5F7590'],
|
||||
['#b8ac9e', '#A9B4BE'],
|
||||
['#8a7f73', '#758CA4'],
|
||||
['#c9beb0', '#BBC1CA'],
|
||||
['#ffb31a', '#E76500'], // 琥珀 → 警告橙
|
||||
['#fff6e0', '#FDF1E8'],
|
||||
['#d9481c', '#AA0808'], // 红橙 → 官方 Negative
|
||||
['#fdeee8', '#FBF0F0'],
|
||||
['#2e9e5b', '#256F3A'], // 绿 → 官方 Positive
|
||||
['#ecf7f0', '#F1F7F3'],
|
||||
['#1f8a5b', '#256F3A'],
|
||||
['#ea580c', '#B44F00'],
|
||||
['#f08060', '#D98A8A'],
|
||||
['#fbbf24', '#E76500'],
|
||||
['#2f2820', '#0A1D35'],
|
||||
['#4a4036', '#1E2A36'],
|
||||
['#d3dce6', '#223040'],
|
||||
['#99a9bf', '#6B7F94']
|
||||
];
|
||||
|
||||
const FILES = [
|
||||
'e:/KF_7/ZhenOS-ZF/ruoyi-ui/src/assets/styles/ruoyi.scss',
|
||||
'e:/KF_7/ZhenOS-ZF/ruoyi-ui/src/assets/styles/btn.scss'
|
||||
];
|
||||
|
||||
let grand = 0;
|
||||
for (const f of FILES) {
|
||||
let txt = fs.readFileSync(f, 'utf8');
|
||||
const hits = [];
|
||||
for (const [from, to] of MAP) {
|
||||
const re = new RegExp(from.replace('#', '#'), 'gi');
|
||||
const n = (txt.match(re) || []).length;
|
||||
if (n) { hits.push(from + ' -> ' + to + ' x' + n); txt = txt.replace(re, to); grand += n; }
|
||||
}
|
||||
console.log('\n' + f.split('/').pop() + ' 共 ' + hits.length + ' 种色值');
|
||||
hits.forEach(h => console.log(' ' + h));
|
||||
if (WRITE && hits.length) fs.writeFileSync(f, txt);
|
||||
}
|
||||
console.log('\n合计替换 ' + grand + ' 处 ' + (WRITE ? '(已写入)' : '(预演,未写入;加 --write 生效)'));
|
||||
@@ -0,0 +1,135 @@
|
||||
// 甄房换肤 · WEB 业务层替换器
|
||||
// 用法: node _design-test/apply-vue-colors.js (预演,报告每条规则的命中数)
|
||||
// node _design-test/apply-vue-colors.js --write (真写)
|
||||
//
|
||||
// 为什么不能只做单色 1:1 替换:
|
||||
// 1) ECharts 的 10 色分类色板若逐色映射,会与已有的 #3b82f6 / #ef4444 撞色,序列失去区分度
|
||||
// 2) 同一个渐变串 linear-gradient(135deg,#f08060,#d9481c) 同时用于「房源等级 A」和「客源热度 H」,
|
||||
// 但 A 现在要改成品牌金、H 仍是红 —— 必须按选择器区分
|
||||
// 所以规则分两类:先按「选择器/整串」精确处理,再对剩余单色做通用映射。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WRITE = process.argv.includes('--write');
|
||||
// 两个前端:WEB(ruoyi-ui)与 H5(zhenfang-uni)
|
||||
const ROOTS = [
|
||||
{ root: 'e:/KF_7/ZhenOS-ZF/ruoyi-ui/src', skip: new Set(['assets/styles']) },
|
||||
{ root: 'e:/KF_7/ZhenOS-ZF/zhenfang-uni/src', skip: new Set() }
|
||||
];
|
||||
const EXT = new Set(['.vue', '.js', '.ts', '.html']);
|
||||
|
||||
// ── 新的 10 色分类色板(Fiori 官方 Category/Accent 色 + 品牌金,色相拉开 + 明度错位)──
|
||||
const PALETTE10 = ['#0057D2', '#E76500', '#188918', '#DF1278', '#07838F', '#D27700', '#7858FF', '#D20A0A', '#5B738B', '#C6A15B'];
|
||||
const OLD10 = ['#ff7a1a', '#2e9e5b', '#ffb31a', '#f08060', '#0F766E', '#756a5e', '#7c3aed', '#14B8A6', '#ef4444', '#3b82f6'];
|
||||
const OLD6 = ['#ff7a1a', '#2e9e5b', '#ffb31a', '#f08060', '#0F766E', '#756a5e'];
|
||||
|
||||
const arr = a => '[' + a.map(c => "'" + c + "'").join(', ') + ']';
|
||||
|
||||
// ── 规则(按顺序执行)──
|
||||
const RULES = [
|
||||
// 1. 图表 10 色分类色板(4 处)
|
||||
['图表色板 · 10 色', arr(OLD10).replace(/'/g, "'"), arr(PALETTE10), 'literal'],
|
||||
// 2. 图表 6 色(1 处)
|
||||
['图表色板 · 6 色', arr(OLD6).replace(/'/g, "'"), arr(PALETTE10.slice(0, 6)), 'literal'],
|
||||
|
||||
// 3. 房源等级 A → 品牌金
|
||||
['等级 A 渐变 → 品牌金', /(&\.level-A\s*\{[^}]*?linear-gradient\()135deg,\s*#f08060,\s*#d9481c/g,
|
||||
'$1135deg, #E3CB99, #C6A15B', 'regex'],
|
||||
// 4. 客源热度 H(热)→ 保留红系
|
||||
['热度 H 渐变 → 红', /(&\.level-H\s*\{[^}]*?linear-gradient\()135deg,\s*#f08060,\s*#d9481c/g,
|
||||
'$1135deg, #E06B6B, #AA0808', 'regex'],
|
||||
// 5. 等级 B → 警告橙
|
||||
['等级 B 渐变 → 橙', /(&\.level-B\s*\{[^}]*?linear-gradient\()135deg,\s*#FBBF24,\s*#ffb31a/g,
|
||||
'$1135deg, #D27700, #E76500', 'regex'],
|
||||
// 6. 等级 C → 中性
|
||||
['等级 C 渐变 → 中性', /(&\.level-C\s*\{[^}]*?linear-gradient\()135deg,\s*#9a8e80,\s*#756a5e/g,
|
||||
'$1135deg, #A9B4BE, #788FA6', 'regex'],
|
||||
|
||||
// 7. 统计卡 warning 渐变
|
||||
['warning 渐变', /linear-gradient\((90|135)deg,\s*#ffb31a,\s*#FBBF24\)/g,
|
||||
'linear-gradient($1deg, #E76500, #D27700)', 'regex'],
|
||||
// 8. 统计卡 danger 渐变
|
||||
['danger 渐变', /linear-gradient\((90|135)deg,\s*#f08060,\s*#ef4444\)/g,
|
||||
'linear-gradient($1deg, #E76500, #D20A0A)', 'regex'],
|
||||
|
||||
// 9. el-rate 星级(单一琥珀色 ×3)
|
||||
['星级 · 单色', "['#ffb31a', '#ffb31a', '#ffb31a']", "['#D27700', '#D27700', '#D27700']", 'literal'],
|
||||
['星级 · 同色系', "['#2e9e5b', '#2e9e5b', '#2e9e5b']", "['#188918', '#188918', '#188918']", 'literal'],
|
||||
// 10. el-rate 五档评价(差→极佳)
|
||||
['评价五档', "['#d9481c', '#ffb31a', '#FBBF24', '#ff7a1a', '#2e9e5b']",
|
||||
"['#D20A0A', '#E76500', '#D27700', '#C6A15B', '#188918']", 'literal'],
|
||||
// 11. ThemePicker 预置色
|
||||
['主题预置色', "['#ff7a1a', '#ff8a3d', '#304156','#212121','#11a983', '#13c2c2', '#6959CD', '#f5222d', ]",
|
||||
"['#0070F2', '#4D9BF6', '#0C2340','#131E29','#188918', '#07838F', '#7858FF', '#D20A0A', ]", 'literal'],
|
||||
|
||||
// 12. rgb()/rgba() 三元组(保留 rgb/rgba 与后面的 alpha)
|
||||
['rgb 三元组 · 橙', /(rgba?)\(\s*255,\s*122,\s*26\b/g, '$1(0, 112, 242', 'regex'],
|
||||
['rgb 三元组 · 浅橙', /(rgba?)\(\s*255,\s*138,\s*61\b/g, '$1(77, 155, 246', 'regex'],
|
||||
['rgb 三元组 · 琥珀', /(rgba?)\(\s*255,\s*179,\s*26\b/g, '$1(231, 101, 0', 'regex'],
|
||||
['rgb 三元组 · 亮琥珀', /(rgba?)\(\s*251,\s*191,\s*36\b/g, '$1(210, 119, 0', 'regex'],
|
||||
['rgb 三元组 · 暖墨', /(rgba?)\(\s*42,\s*33,\s*24\b/g, '$1(19, 30, 41', 'regex']
|
||||
];
|
||||
|
||||
// ── 通用单色映射(处理剩余的所有字面量)──
|
||||
const GENERIC = [
|
||||
['#ff7a1a', '#0070F2'], ['#e86a0c', '#0064D9'], ['#d96408', '#0057D2'],
|
||||
['#ff8a3d', '#4D9BF6'], ['#ffc48a', '#A6CFFF'], ['#ffe8d1', '#EBF8FF'], ['#fff6ec', '#F0F6FE'],
|
||||
['#f6ead8', '#EAECEE'], ['#faf7f2', '#F5F6F7'], ['#fbf5ea', '#F5F6F7'],
|
||||
['#e9d3b0', '#758CA4'], ['#efe7dc', '#E5E5E5'],
|
||||
['#2a2118', '#131E29'], ['#3a322a', '#556B82'], ['#756a5e', '#5F7590'],
|
||||
['#9a8e80', '#5F7590'], ['#8a7f73', '#758CA4'], ['#b8ac9e', '#A9B4BE'], ['#c9beb0', '#BBC1CA'],
|
||||
['#ffb31a', '#E76500'], ['#fff6e0', '#FDF1E8'],
|
||||
['#d9481c', '#AA0808'], ['#fdeee8', '#FBF0F0'],
|
||||
['#2e9e5b', '#256F3A'], ['#ecf7f0', '#F1F7F3'], ['#1f8a5b', '#256F3A'], ['#ea580c', '#B44F00'],
|
||||
['#f08060', '#D98A8A'], ['#fbbf24', '#D27700'],
|
||||
['#2f2820', '#0A1D35'], ['#4a4036', '#1E2A36'], ['#d3dce6', '#223040'], ['#99a9bf', '#6B7F94'],
|
||||
// 图表/其它残留的 Tailwind 色
|
||||
['#ef4444', '#D20A0A'], ['#3b82f6', '#0057D2'], ['#0f766e', '#07838F'],
|
||||
['#14b8a6', '#07838F'], ['#7c3aed', '#7858FF'], ['#f5222d', '#D20A0A'],
|
||||
// H5 专有
|
||||
['#36cfc9', '#4DA37A'], ['#ff9a3d', '#4D9BF6'], ['#10b981', '#188918']
|
||||
];
|
||||
|
||||
const files = [];
|
||||
for (const { root, skip } of ROOTS) {
|
||||
(function walk(d) {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const p = path.join(d, e.name);
|
||||
const rel = path.relative(root, p).replace(/\\/g, '/');
|
||||
if (e.isDirectory()) { if (!skip.has(rel)) walk(p); continue; }
|
||||
if (EXT.has(path.extname(e.name).toLowerCase())) files.push(p);
|
||||
}
|
||||
})(root);
|
||||
}
|
||||
|
||||
const tally = new Map();
|
||||
let changedFiles = 0;
|
||||
|
||||
for (const f of files) {
|
||||
let txt = fs.readFileSync(f, 'utf8');
|
||||
const orig = txt;
|
||||
for (const [label, pat, rep, kind] of RULES) {
|
||||
const n = kind === 'literal'
|
||||
? (txt.split(pat).length - 1)
|
||||
: (txt.match(pat) || []).length;
|
||||
if (!n) continue;
|
||||
txt = kind === 'literal' ? txt.split(pat).join(rep) : txt.replace(pat, rep);
|
||||
tally.set(label, (tally.get(label) || 0) + n);
|
||||
}
|
||||
for (const [from, to] of GENERIC) {
|
||||
const n = (txt.match(new RegExp(from, 'gi')) || []).length;
|
||||
if (!n) continue;
|
||||
txt = txt.replace(new RegExp(from, 'gi'), to);
|
||||
const k = '单色 ' + from + ' -> ' + to;
|
||||
tally.set(k, (tally.get(k) || 0) + n);
|
||||
}
|
||||
if (txt !== orig) { changedFiles++; if (WRITE) fs.writeFileSync(f, txt); }
|
||||
}
|
||||
|
||||
console.log('=== 规则命中统计 ===');
|
||||
[...tally.entries()].sort((a, b) => b[1] - a[1]).forEach(([k, v]) => {
|
||||
console.log(' ' + String(v).padStart(5) + ' ' + k);
|
||||
});
|
||||
const total = [...tally.values()].reduce((a, b) => a + b, 0);
|
||||
console.log('\n涉及文件 ' + changedFiles + ' 个 / 替换合计 ' + total + ' 处');
|
||||
console.log(WRITE ? '已写入' : '预演,未写入(加 --write 生效)');
|
||||
@@ -0,0 +1,95 @@
|
||||
// 基座影响审计:逐行验证本次换肤的改动是否「只改颜色」
|
||||
// 做法:解析 git diff -U0,把每段 hunk 的「删除行」与「新增行」都做颜色归一化
|
||||
// (#RRGGBB / rgb() / rgba() → 占位符),再比对两侧是否等价。
|
||||
// 等价 = 纯换色;不等价 = 有非颜色改动,单独列出待人工判断。
|
||||
// 用法: node _design-test/audit-color-only.js
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const ZF = 'e:/KF_7/ZhenOS-ZF';
|
||||
const raw = execSync('git -c core.quotepath=false diff -U0 --no-color', {
|
||||
cwd: ZF, maxBuffer: 1 << 28, encoding: 'utf8'
|
||||
});
|
||||
|
||||
const CO = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|\bhsl\([^)]*\)/g;
|
||||
const norm = s => s.replace(CO, '@C@').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const isComment = s => {
|
||||
const t = s.trim();
|
||||
return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') ||
|
||||
t.startsWith('<!--') || t.startsWith('{#') || t === '' ;
|
||||
};
|
||||
|
||||
let curFile = '';
|
||||
const offenders = [];
|
||||
let hunks = 0, colorOnly = 0, commentOnly = 0, removed = 0, added = 0;
|
||||
|
||||
const lines = raw.split('\n');
|
||||
let bucket = null;
|
||||
const flush = () => {
|
||||
if (!bucket) return;
|
||||
const { file, oldL, newL } = bucket;
|
||||
hunks++;
|
||||
removed += oldL.length; added += newL.length;
|
||||
const a = oldL.map(norm).sort().join('\n');
|
||||
const b = newL.map(norm).sort().join('\n');
|
||||
if (a === b) { colorOnly++; return; }
|
||||
// 只有注释行不同 → 视为注释改动(不影响行为)
|
||||
const o = oldL.filter(l => !isComment(l)), n2 = newL.filter(l => !isComment(l));
|
||||
if (o.map(norm).sort().join('\n') === n2.map(norm).sort().join('\n')) { commentOnly++; return; }
|
||||
offenders.push({ file, oldL, newL });
|
||||
bucket = null;
|
||||
};
|
||||
|
||||
for (const ln of lines) {
|
||||
if (ln.startsWith('diff --git ')) { flush(); bucket = null; curFile = ln.split(' b/').pop(); continue; }
|
||||
if (ln.startsWith('+++ ') || ln.startsWith('--- ') || ln.startsWith('index ') ||
|
||||
ln.startsWith('new file') || ln.startsWith('deleted file') || ln.startsWith('similarity')) continue;
|
||||
if (ln.startsWith('@@')) { flush(); bucket = { file: curFile, oldL: [], newL: [] }; continue; }
|
||||
if (!bucket) continue;
|
||||
if (ln.startsWith('-')) bucket.oldL.push(ln.slice(1));
|
||||
else if (ln.startsWith('+')) bucket.newL.push(ln.slice(1));
|
||||
}
|
||||
flush();
|
||||
|
||||
console.log('=== 换肤改动审计(git diff -U0 逐段比对)===');
|
||||
console.log(' 变更行数 新增 ' + added + ' / 删除 ' + removed);
|
||||
console.log(' 代码段(hunk) 共 ' + hunks);
|
||||
console.log(' · 纯颜色替换 ' + colorOnly);
|
||||
console.log(' · 仅注释差异 ' + commentOnly);
|
||||
console.log(' · 需要人工复核 ' + offenders.length);
|
||||
|
||||
// 本次「整文件重写」的样式文件 —— 它们的差异里必然含注释/结构变化,属已知改动,单列
|
||||
const REWRITTEN = new Set([
|
||||
'ruoyi-ui/src/assets/styles/element-variables.scss',
|
||||
'ruoyi-ui/src/assets/styles/theme.scss',
|
||||
'ruoyi-ui/src/assets/styles/element-ui.scss',
|
||||
'ruoyi-ui/src/assets/styles/variables.scss',
|
||||
'ruoyi-ui/src/assets/styles/ruoyi.scss',
|
||||
'ruoyi-ui/src/assets/styles/sidebar.scss',
|
||||
'ruoyi-ui/src/assets/styles/btn.scss',
|
||||
'ruoyi-ui/src/assets/styles/mixin.scss',
|
||||
'zhenfang-uni/src/uni.scss'
|
||||
]);
|
||||
|
||||
const byFile = {};
|
||||
offenders.forEach(o => { (byFile[o.file] = byFile[o.file] || []).push(o); });
|
||||
const keys = Object.keys(byFile);
|
||||
const rewritten = keys.filter(k => REWRITTEN.has(k));
|
||||
const others = keys.filter(k => !REWRITTEN.has(k));
|
||||
|
||||
console.log('\n ── 重写类文件(已知改动,含注释/结构重排):' + rewritten.length + ' 个');
|
||||
rewritten.forEach(k => console.log(' ' + String(byFile[k].length).padStart(3) + ' 段 ' + k));
|
||||
|
||||
console.log('\n ── 其它文件的疑似非颜色改动:' + others.length + ' 个文件');
|
||||
if (!others.length) {
|
||||
console.log(' 无 —— 业务页面/组件/后端相关文件全部只改了颜色');
|
||||
} else {
|
||||
others.forEach(k => {
|
||||
console.log('\n ' + k + ' (' + byFile[k].length + ' 段)');
|
||||
byFile[k].slice(0, 4).forEach(o => {
|
||||
o.oldL.slice(0, 3).forEach(l => console.log(' - ' + l.trim()));
|
||||
o.newL.slice(0, 3).forEach(l => console.log(' + ' + l.trim()));
|
||||
console.log(' ---');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# 生成 Electron 两个资源:
|
||||
# 1) build/icons/icon.icns —— 真正的 ICNS 容器(原文件其实是改了扩展名的 PNG,属坏文件)
|
||||
# 2) resources/app.png —— 启动背景稿,按甄房品牌重做
|
||||
# 用法: python _design-test/build-electron-assets.py (预演)
|
||||
# python _design-test/build-electron-assets.py --write (真写)
|
||||
import os, sys, struct, io
|
||||
from PIL import Image
|
||||
|
||||
SRC = r'D:\SGP_KF\DrawLogo\_design-test\logo\png'
|
||||
ZF = r'e:\KF_7\ZhenOS-ZF'
|
||||
WRITE = '--write' in sys.argv
|
||||
ICNS_OUT = f'{ZF}/xiaohuju/build/icons/icon.icns'
|
||||
APP_OUT = f'{ZF}/xiaohuju/resources/app.png'
|
||||
|
||||
# ── 1. ICNS:容器 = 'icns' + 总长度 + [type(4) + 长度(4, 含头) + PNG 数据] ──
|
||||
# 类型码为 Apple 官方定义;PNG 载荷自 macOS 10.7 起原生支持
|
||||
ICNS_TYPES = [
|
||||
('icp4', 16), ('icp5', 32), ('icp6', 64),
|
||||
('ic07', 128), ('ic08', 256), ('ic09', 512), ('ic10', 1024),
|
||||
('ic11', 32), ('ic12', 64), ('ic13', 256), ('ic14', 512),
|
||||
]
|
||||
|
||||
base = Image.open(os.path.join(SRC, 'appicon-1024.png')).convert('RGBA')
|
||||
chunks = []
|
||||
for tag, size in ICNS_TYPES:
|
||||
buf = io.BytesIO()
|
||||
base.resize((size, size), Image.LANCZOS).save(buf, format='PNG', optimize=True)
|
||||
data = buf.getvalue()
|
||||
chunks.append(tag.encode('ascii') + struct.pack('>I', len(data) + 8) + data)
|
||||
body = b''.join(chunks)
|
||||
icns = b'icns' + struct.pack('>I', len(body) + 8) + body
|
||||
print(' icon.icns %d 个尺寸 %d 字节 容器合法=%s'
|
||||
% (len(ICNS_TYPES), len(icns), icns[:4] == b'icns' and struct.unpack('>I', icns[4:8])[0] == len(icns)))
|
||||
|
||||
# ── 2. app.png:启动背景稿重做 ──
|
||||
# 原稿是 3.2MB 的满幅蓝色装饰图(2208x2048,全宽 + 上下白边),且全仓库零代码引用(孤儿资源)。
|
||||
# 重做思路:与 splash.html / 应用图标统一到「墨蓝深色外壳」语言 —— 墨蓝渐变底 + 顶部品牌蓝辉光 + 居中白色横版锁定标。
|
||||
W, H = 2208, 2048
|
||||
|
||||
# 底色:墨蓝渐变(#0C2340 → #071527)
|
||||
bg = Image.new('RGB', (W, H))
|
||||
top, bot = (0x0C, 0x23, 0x40), (0x07, 0x15, 0x27)
|
||||
for y in range(H):
|
||||
k = y / (H - 1)
|
||||
bg.paste(tuple(round(top[i] + (bot[i] - top[i]) * k) for i in range(3)), (0, y, W, y + 1))
|
||||
|
||||
# 顶部品牌蓝辉光(呼应 splash.html 的 .splash::before)—— 1/8 分辨率算完再放大,纯 Python 逐像素太慢
|
||||
gw, gh = W // 8, H // 8
|
||||
glow = Image.new('L', (gw, gh), 0)
|
||||
gp = glow.load()
|
||||
cx0, cy0, rx, ry = gw / 2, gh * 0.28, gw * 0.62, gh * 0.34
|
||||
for y in range(gh):
|
||||
dy = (y - cy0) / ry
|
||||
row = dy * dy
|
||||
for x in range(gw):
|
||||
dx = (x - cx0) / rx
|
||||
d = (row + dx * dx) ** 0.5
|
||||
if d < 1:
|
||||
gp[x, y] = int(255 * (1 - d) ** 1.6)
|
||||
glow = glow.resize((W, H), Image.BILINEAR)
|
||||
bg = Image.composite(Image.new('RGB', (W, H), (0x00, 0x70, 0xF2)), bg, glow.point(lambda v: v // 6))
|
||||
|
||||
# 居中横版锁定标(反白),宽度占画布 42%
|
||||
lock = Image.open(os.path.join(SRC, 'logo-reverse-800.png')).convert('RGBA')
|
||||
lb = lock.getchannel('A').getbbox()
|
||||
lock = lock.crop(lb)
|
||||
k = (W * 0.42) / lock.width
|
||||
lock = lock.resize((round(lock.width * k), round(lock.height * k)), Image.LANCZOS)
|
||||
|
||||
canvas = bg.convert('RGBA')
|
||||
canvas.paste(lock, ((W - lock.width) // 2, (H - lock.height) // 2), lock)
|
||||
out = canvas.convert('RGB')
|
||||
print(' app.png 新稿 %dx%d 锁定标 %dx%d 居中' % (W, H, lock.width, lock.height))
|
||||
|
||||
if WRITE:
|
||||
open(ICNS_OUT, 'wb').write(icns)
|
||||
out.save(APP_OUT, optimize=True)
|
||||
print('\n已写入 icon.icns / app.png')
|
||||
else:
|
||||
print('\n预演,未写入(加 --write 生效)')
|
||||
@@ -0,0 +1,56 @@
|
||||
# 生成甄房 logo 位图替换资源
|
||||
# 关键:旧 logo 的墨迹占画布约 90%,而甄房 mark 只占 69% —— 直接替换会让 logo 视觉缩小 25%。
|
||||
# 本脚本把 mark 的墨迹等比缩放到占画布 90%,保持与原资源等大,替换后不改变版式。
|
||||
# 用法: python _design-test/build-logos.py (预演)
|
||||
# python _design-test/build-logos.py --write (真写)
|
||||
import os, sys, shutil
|
||||
from PIL import Image
|
||||
|
||||
SRC = r'D:\SGP_KF\DrawLogo\_design-test\logo\png'
|
||||
ZF = r'e:\KF_7\ZhenOS-ZF'
|
||||
WRITE = '--write' in sys.argv
|
||||
INK_RATIO = 0.90 # 墨迹占画布比例,与原资源对齐
|
||||
|
||||
|
||||
def fitted(src_name, canvas, ratio=INK_RATIO):
|
||||
"""把 src 的墨迹等比缩放到占 canvas 的 ratio,居中放到透明画布上"""
|
||||
im = Image.open(os.path.join(SRC, src_name)).convert('RGBA')
|
||||
bb = im.getchannel('A').getbbox()
|
||||
ink = im.crop(bb)
|
||||
k = (canvas * ratio) / max(ink.width, ink.height)
|
||||
ink = ink.resize((max(1, round(ink.width * k)), max(1, round(ink.height * k))), Image.LANCZOS)
|
||||
out = Image.new('RGBA', (canvas, canvas), (0, 0, 0, 0))
|
||||
out.paste(ink, ((canvas - ink.width) // 2, (canvas - ink.height) // 2), ink)
|
||||
return out
|
||||
|
||||
|
||||
JOBS = [
|
||||
# 目标路径, 生成方式, 说明
|
||||
(f'{ZF}/ruoyi-ui/src/assets/logo/logo.png', ('fitted', 'mark-p80-ink.png', 1000), 'WEB 登录页/工具页 · 墨蓝标'),
|
||||
(f'{ZF}/ruoyi-ui/src/assets/logo/logo-sidebar.png', ('fitted', 'mark-p80-white.png', 1000), 'WEB 侧栏(墨蓝底)· 白标'),
|
||||
(f'{ZF}/zhenfang-uni/src/static/logo.png', ('fitted', 'mark-p80-ink.png', 1000), 'H5 登录页(白卡内)· 墨蓝标'),
|
||||
(f'{ZF}/ruoyi-ui/public/favicon.png', ('plain', 'appicon-512.png', None), 'WEB favicon · 应用图标'),
|
||||
(f'{ZF}/xiaohuju/build/icons/icon.png', ('plain', 'appicon-512.png', None), 'Electron 托盘/窗口图标'),
|
||||
]
|
||||
|
||||
ICO_OUT = f'{ZF}/xiaohuju/build/icons/icon.ico'
|
||||
|
||||
for dest, spec, label in JOBS:
|
||||
before = os.path.getsize(dest) if os.path.exists(dest) else 0
|
||||
kind = spec[0]
|
||||
img = fitted(spec[1], spec[2]) if kind == 'fitted' else Image.open(os.path.join(SRC, spec[1])).convert('RGBA')
|
||||
print(' %-58s %sx%s (%dB -> ~)' % (label, img.width, img.height, before))
|
||||
if WRITE:
|
||||
img.save(dest)
|
||||
|
||||
# 多尺寸 ICO(含 256,Windows 图标规范)
|
||||
ico_sizes = [16, 24, 32, 48, 64, 128, 256]
|
||||
base = Image.open(os.path.join(SRC, 'appicon-1024.png')).convert('RGBA')
|
||||
imgs = [base.resize((s, s), Image.LANCZOS) for s in ico_sizes]
|
||||
print(' %-58s %s' % ('Electron 应用图标 ICO', '/'.join(str(s) for s in ico_sizes)))
|
||||
if WRITE:
|
||||
imgs[-1].save(ICO_OUT, format='ICO', sizes=[(s, s) for s in ico_sizes])
|
||||
|
||||
print('\n' + ('已写入' if WRITE else '预演,未写入(加 --write 生效)'))
|
||||
print('注:xiaohuju/build/icons/icon.icns(macOS)需在 macOS 上用 iconutil 生成,本机跳过;')
|
||||
print(' xiaohuju/resources/app.png 是启动背景稿(含蓝色主体图形),需设计重做,本脚本不动。')
|
||||
@@ -0,0 +1,989 @@
|
||||
// 生成「甄房」LOGO 素材挑选页(自包含单文件 HTML,可直接双击打开)
|
||||
// 用法: node _design-test/build-page.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(path.join(__dirname, 'house-icons.json'), 'utf8'));
|
||||
const TIERS = data.tiers;
|
||||
|
||||
// ── 字体清单(本地 OFL + 本机系统字体)────────────────────────────
|
||||
const FONTS = JSON.parse(fs.readFileSync(path.join(__dirname, 'font-specimens.json'), 'utf8'));
|
||||
const FONT_CATS = Object.keys(FONTS.cats);
|
||||
const fontFaces = FONTS.fonts.filter(f => f.kind === 'ofl').map(f =>
|
||||
"@font-face{font-family:'" + f.family + "';src:url('" + f.src + "') format('" +
|
||||
(f.fmt === 'ttf' ? 'truetype' : (f.fmt || 'woff2')) + "');font-display:swap}"
|
||||
).join('\n');
|
||||
const LIC_BAD = /BY-SA|BY-NC|BY-ND|GPL|-NC-|NonCommercial|非商业/i;
|
||||
|
||||
// ── 「甄房」LOGO 概念方案(本次设计)──────────────────────────────
|
||||
const CONCEPTS = [
|
||||
{
|
||||
id: 'zhen-01', name: '甄选之屋', en: 'Selected House', tag: '对勾 · 最直白',
|
||||
desc: '屋顶 + 墙体 + 对勾。一套图形讲完「甄选好房」,识别度最高,缩小到 favicon 仍清晰。适合做主标。',
|
||||
mode: 'stroke',
|
||||
svg: '<path d="M6 30.5 L32 8 L58 30.5"/><path d="M13.5 29.5 V56 H50.5 V29.5"/><path d="M23.5 43 l7 7 L44 31.5"/>'
|
||||
},
|
||||
{
|
||||
id: 'zhen-02', name: '檐瓦徽章', en: 'Eave Tile Badge', tag: '中式 · 圆章',
|
||||
desc: '圆形徽章内一条上翘屋檐 + 两重瓦纹。「甄」本义为陶瓦甄别,此稿把字义直接画进图形,最有文化底子。',
|
||||
mode: 'stroke',
|
||||
svg: '<circle cx="32" cy="32" r="26"/><path d="M11 25.5 C19 15, 45 15, 53 25.5"/><path d="M19.5 34 q12.5 -7 25 0"/><path d="M19.5 43 q12.5 -7 25 0"/>'
|
||||
},
|
||||
{
|
||||
id: 'zhen-03', name: '窗格之家', en: 'Window House', tag: '几何 · 秩序',
|
||||
desc: '屋顶 + 田字窗格。窗格本身有「筛选、框选」的隐喻,结构方正,延展做栅格、水印、加载态都很顺。',
|
||||
mode: 'stroke',
|
||||
svg: '<path d="M7 27 L32 7.5 L57 27"/><rect x="14.5" y="27" width="35" height="29.5" rx="3.5"/><path d="M32 27 V56.5"/><path d="M14.5 41.5 H49.5"/>'
|
||||
},
|
||||
{
|
||||
id: 'zhen-04', name: '印章 · 甄', en: 'Seal Zhen', tag: '汉字 · 印鉴',
|
||||
desc: '圆角方印 + 甄字。最贴近「甄房」二字的字面表达,气质正统稳重,适合政务、金融类房产业务线。',
|
||||
mode: 'mixed',
|
||||
svg: '<rect x="5.5" y="5.5" width="53" height="53" rx="11"/>' +
|
||||
'<text x="32" y="43.5" text-anchor="middle" font-size="31" font-weight="600" font-family="Songti SC,STSong,SimSun,serif" fill="currentColor" stroke="none">甄</text>'
|
||||
},
|
||||
{
|
||||
id: 'zhen-05', name: '钥匙之家', en: 'Key House', tag: '门锁 · 交付',
|
||||
desc: '实心屋形挖出钥匙孔。指向「交付钥匙 = 成交」,适合交易、租售、代办过户等业务。',
|
||||
mode: 'stroke',
|
||||
svg: '<path d="M7 28.5 L32 7.5 L57 28.5 V56 H7 Z"/><circle cx="32" cy="34" r="6.5"/><path d="M32 40.5 V51"/>'
|
||||
},
|
||||
{
|
||||
id: 'zhen-06', name: '找房定位', en: 'Find A Home', tag: '定位 · 找房',
|
||||
desc: '地图定位针内嵌小屋。天然表达「在你想要的位置找到房」,适合 C 端找房 App、地图找房入口。',
|
||||
mode: 'stroke',
|
||||
svg: '<path d="M32 5 C22.5 5 15 12.5 15 22 c0 11.5 17 29 17 29 s17 -17.5 17 -29 C49 12.5 41.5 5 32 5 z"/><path d="M23.5 22.5 L32 15.5 L40.5 22.5"/><path d="M26.5 22.5 V30 H37.5 V22.5"/>'
|
||||
},
|
||||
{
|
||||
id: 'zhen-07', name: '联排双屋', en: 'Twin Houses', tag: '多房源 · 平台',
|
||||
desc: '两栋联排小屋并列。强调「房源多、成规模」,适合房产平台、小区/楼盘库这类主标。',
|
||||
mode: 'stroke',
|
||||
svg: '<path d="M4 34 L17 23 L30 34"/><path d="M8 33 V56 H26 V33"/>' +
|
||||
'<path d="M34 34 L47 23 L60 34"/><path d="M38 33 V56 H56 V33"/>'
|
||||
},
|
||||
{
|
||||
id: 'zhen-08', name: '层叠甄选', en: 'Layered Pick', tag: '抽象 · 现代',
|
||||
desc: '屋顶下三层递减横线,像层层筛选的房源列表,也呼应「甄」字的多横结构。最现代、最像互联网品牌。',
|
||||
mode: 'stroke',
|
||||
svg: '<path d="M6 25 L32 7 L58 25"/><path d="M13 32.5 H51"/><path d="M18 40.5 H46"/><path d="M23 48.5 H41"/>'
|
||||
}
|
||||
];
|
||||
|
||||
// ── 第二批:home-04 外框 + 字母 Z(直接用 home-04 原始路径,外框几何固定)────
|
||||
// 外框 = 素材库 hugeicons:home-04(MIT)的原始 path,按 24→64 网格等比放大(stroke 同步放大,粗细比例与原图一致)
|
||||
// 唯一改动:去掉原图底部那条内部横线(M16 17H8)——它正好落在 Z 的位置,且不属于「外框」
|
||||
const H04 = 'M3 11.99v2.51c0 3.3 0 4.95 1.025 5.975S6.7 21.5 10 21.5h4c3.3 0 4.95 0 5.975-1.025S21 17.8 21 14.5v-2.51c0-1.682 0-2.522-.356-3.25s-1.02-1.244-2.346-2.276l-2-1.555C14.233 3.303 13.2 2.5 12 2.5s-2.233.803-4.298 2.409l-2 1.555C4.375 7.496 3.712 8.012 3.356 8.74S3 10.308 3 11.99';
|
||||
const FG = 'translate(5.3304 5.3294) scale(2.22222)'; // 24 网格 → 64 网格,外框落位 12~52 / 10.9~53.1,中心 (32,32)
|
||||
const INK = '#0C2340', GOLD = '#C6A15B', WH = '#FFFFFF'; // #0C2340 = 定稿色板「甄房蓝 900」
|
||||
|
||||
// 定稿字形:Forte 的 Z(已由 fontTools 抽成真实曲线,见 logo/glyphs.json)
|
||||
const GLYPH_FILE = path.join(__dirname, 'logo', 'glyphs.json');
|
||||
const GLYPHS = fs.existsSync(GLYPH_FILE) ? JSON.parse(fs.readFileSync(GLYPH_FILE, 'utf8')) : {};
|
||||
const FORTEZ = GLYPHS.forteZ ? GLYPHS.forteZ.d : '';
|
||||
const ZCX = GLYPHS.forteZ ? GLYPHS.forteZ.placed[0] + GLYPHS.forteZ.placed[2] / 2 : 32; // Z 着墨中心 x
|
||||
// 水平拉伸(只拉宽、不动高):绕 Z 自身中心缩放,宽度倍数 sx
|
||||
const wideZ = (sx) => '<g transform="translate(' + ZCX + ' 0) scale(' + sx + ' 1) translate(-' + ZCX + ' 0)"><path d="' +
|
||||
FORTEZ + '" fill="' + INK + '"/></g>';
|
||||
const frame = (sw, color) => '<g transform="' + FG + '"><path d="' + H04 + '" fill="none" stroke="' + color +
|
||||
'" stroke-width="' + sw + '" stroke-linecap="round" stroke-linejoin="round"/></g>';
|
||||
const ZSTD = 'M21.5 30 H42.5 V35.4 L30 42.1 H42.5 V47.5 H21.5 V42.1 L34 35.4 H21.5 Z'; // 实心方块 Z(深底反白用)
|
||||
const ZSKEL = 'M23 31.5 H41 L23 46 H41'; // 圆角 Z 骨架
|
||||
|
||||
// Z 统一字面框:x 21.5~42.5、y 30~47.5(各款按笔画宽自动内缩,保证字面大小一致)
|
||||
const ZB = { x0: 21.5, x1: 42.5, y0: 30, y1: 47.5 };
|
||||
// 骨架式 Z:给定笔画宽度与端点,自动算出中心线端点,使描边后的外沿正好落进字面框
|
||||
function zline(w, o) {
|
||||
o = o || {};
|
||||
var caps = o.caps || 'butt', pad = caps === 'round' ? w / 2 : 0;
|
||||
var x0 = ZB.x0 + w / 2 + pad, x1 = ZB.x1 - w / 2 - pad;
|
||||
var y0 = ZB.y0 + w / 2 + pad, y1 = ZB.y1 - w / 2 - pad;
|
||||
return '<path d="M' + x0 + ' ' + y0 + ' H' + x1 + ' L' + x0 + ' ' + y1 + ' H' + x1 +
|
||||
'" fill="none" stroke="' + (o.color || INK) + '" stroke-width="' + w +
|
||||
'" stroke-linecap="' + caps + '" stroke-linejoin="' + (o.join || 'miter') + '"/>';
|
||||
}
|
||||
|
||||
const CONCEPTS_FRAME = [
|
||||
// ── 已定稿 ───────────────────────────────────────────
|
||||
...(FORTEZ ? [{
|
||||
id: 'zf-final', star: 2, name: 'Forte Z', en: 'Final', tag: '已定稿 ✓',
|
||||
desc: '你选定的最终字形:Forte 的大写 Z。已用 fontTools 从字体里抽出真实曲线(不是位图描摹),所以不依赖字体环境即可使用;完整资产(SVG + 36 个位图 + 色板)见顶部「定稿交付」链接。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) + '<path d="' + FORTEZ + '" fill="' + INK + '"/>'
|
||||
},
|
||||
{
|
||||
id: 'zf-w115', name: 'Forte Z · 拉宽 15%', en: 'Wide 115', tag: '水平拉伸 ×1.15',
|
||||
desc: '外框与字高完全不动,只把 Z 水平拉宽 15%。最克制的一档,比原版略舒展,几乎看不出被拉过——想要「稳」就选它。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) + wideZ(1.15)
|
||||
},
|
||||
{
|
||||
id: 'zf-w130', name: 'Forte Z · 拉宽 30%', en: 'Wide 130', tag: '水平拉伸 ×1.30',
|
||||
desc: '明显变宽的一档:横画被拉长、字腔更开阔,整体更「阔气」。放在方框里左右留白更窄,视觉重心更稳,是我建议优先考虑的一档。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) + wideZ(1.30)
|
||||
},
|
||||
{
|
||||
id: 'zf-w145', name: 'Forte Z · 拉宽 45%', en: 'Wide 145', tag: '水平拉伸 ×1.45',
|
||||
desc: '宽体感最强的一档,接近专业字体的「extended」字重观感。注意:水平拉伸会让竖画显厚、斜画变缓,再往上就会开始显胖。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) + wideZ(1.45)
|
||||
}] : []),
|
||||
// ── 保留三款 ─────────────────────────────────────────
|
||||
{
|
||||
id: 'zf-k1', star: 1, name: '深底反白', en: 'Dark Reverse', tag: '已保留 · 深色场景',
|
||||
desc: '深墨蓝圆角底 + 白框白 Z。深色界面、App 图标、深色名片用这一版。',
|
||||
mode: 'solid',
|
||||
svg: '<rect x="5" y="5" width="54" height="54" rx="13" fill="' + INK + '"/>' +
|
||||
frame(1.5, WH) + '<path d="' + ZSTD + '" fill="' + WH + '"/>'
|
||||
},
|
||||
{
|
||||
id: 'zf-k2', star: 1, name: '圆角 Z', en: 'Round Z', tag: '已保留 · 亲和',
|
||||
desc: 'Z 用 5.2 圆头描边,转角全圆,跟圆润的框最统一。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) +
|
||||
'<path d="' + ZSKEL + '" fill="none" stroke="' + INK + '" stroke-width="5.2" stroke-linejoin="round" stroke-linecap="round"/>'
|
||||
},
|
||||
{
|
||||
id: 'zf-k3', star: 1, name: '线框 Z', en: 'Line Z', tag: '已保留 · 轻',
|
||||
desc: 'Z 用 3.6 描边空心字,整体透气,设计工作室调性。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) +
|
||||
'<path d="' + ZSKEL + '" fill="none" stroke="' + INK + '" stroke-width="3.6" stroke-linejoin="round" stroke-linecap="round"/>'
|
||||
},
|
||||
|
||||
// ── 新增 Z 字形(外框不动,只换 Z)──────────────────────
|
||||
{
|
||||
id: 'zf-01', name: '锐折 Z', en: 'Sharp Z', tag: '尖角 · 硬朗',
|
||||
desc: '3.6 等宽细线,但转角是尖的(miter 直角),端点平切。比圆角版硬朗、比实心版轻,最有「精密工具」气质。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) + zline(3.6, { caps: 'butt', join: 'miter' })
|
||||
},
|
||||
{
|
||||
id: 'zf-02', name: '方头粗 Z', en: 'Square Bold', tag: '几何 · 压得住',
|
||||
desc: '5.4 粗线、方头方角,像 Helvetica 的骨架直接画出来。远近都清楚,做门头招牌没问题。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) + zline(5.4, { caps: 'butt', join: 'miter' })
|
||||
},
|
||||
{
|
||||
id: 'zf-03', name: '细线 Z', en: 'Hairline', tag: '极细 · 高级',
|
||||
desc: '笔画只有 2.4,跟外框的细线等重。整个标看起来像一张线描稿,适合高端、克制的物料。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) + zline(2.4, { caps: 'butt', join: 'miter' })
|
||||
},
|
||||
{
|
||||
id: 'zf-04', name: '超粗 Z', en: 'Heavy', tag: '最重 · 冲击力',
|
||||
desc: '笔画 6.6,几乎把框内填满。远看是一个厚重的方块符号,适合做车贴、导视、单色印章。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) + zline(6.6, { caps: 'butt', join: 'miter' })
|
||||
},
|
||||
{
|
||||
id: 'zf-05', name: '宽体 Z', en: 'Extended', tag: '宽扁 · 稳',
|
||||
desc: '字面拉宽到 27(比标准宽 28%),笔画 4.4。宽扁的字给人「稳、可信」的心理暗示,适合金融/政务向。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) + '<path d="M20.7 32.2 H43.3 L20.7 45.3 H43.3" fill="none" stroke="' + INK +
|
||||
'" stroke-width="4.4" stroke-linecap="butt" stroke-linejoin="miter"/>'
|
||||
},
|
||||
{
|
||||
id: 'zf-06', name: '窄体 Z', en: 'Condensed', tag: '瘦高 · 利落',
|
||||
desc: '字面收到 13(标准的一半多点),笔画 5.4。瘦高的字显精致、有设计感,也留出更多呼吸。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) + '<path d="M28.2 32.7 H35.8 L28.2 44.8 H35.8" fill="none" stroke="' + INK +
|
||||
'" stroke-width="5.4" stroke-linecap="butt" stroke-linejoin="miter"/>'
|
||||
},
|
||||
{
|
||||
id: 'zf-07', name: '曲线斜画', en: 'Curved Z', tag: '柔 · 流动',
|
||||
desc: '把 Z 的斜画改成一条 S 弯。硬邦邦的字母一下有了流动感,和外框的圆弧线条也更呼应。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) + '<path d="M23.3 31.8 H40.7 C33.5 36, 30.5 41.5, 23.3 45.7 H40.7" fill="none" stroke="' + INK +
|
||||
'" stroke-width="3.6" stroke-linecap="butt" stroke-linejoin="round"/>'
|
||||
},
|
||||
{
|
||||
id: 'zf-08', name: '断笔 Z', en: 'Stencil', tag: '断开 · 有节奏',
|
||||
desc: '三笔全断开,像模板喷字。笔画的间隙让这个 Z 有了节奏感,也更容易在低精度场景下不糊成一团。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) +
|
||||
'<path d="M23.3 32.2 H40.7" fill="none" stroke="' + INK + '" stroke-width="3.4" stroke-linecap="butt"/>' +
|
||||
'<path d="M38.6 36.2 L25.4 42.6" fill="none" stroke="' + INK + '" stroke-width="3.4" stroke-linecap="butt"/>' +
|
||||
'<path d="M23.3 46.6 H40.7" fill="none" stroke="' + INK + '" stroke-width="3.4" stroke-linecap="butt"/>'
|
||||
},
|
||||
{
|
||||
id: 'zf-09', name: '空心字 Z', en: 'Hollow', tag: '真·空心',
|
||||
desc: '不是用线画的,是把一个实心 Z 内部挖空——所以横竖有真实的粗细与字腔,放大看是很正经的字母设计。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) +
|
||||
'<path fill-rule="evenodd" fill="' + INK + '" d="M21.5 30 H42.5 V35.4 L30 42.1 H42.5 V47.5 H21.5 V42.1 L34 35.4 H21.5 Z ' +
|
||||
'M25.5 33 H38.5 V35.6 L30.5 41.9 H38.5 V44.5 H25.5 V41.9 L33.5 35.6 H25.5 Z"/>'
|
||||
},
|
||||
{
|
||||
id: 'zf-10', name: '勾尾 Z', en: 'Check Tail', tag: '甄选 · 语义',
|
||||
desc: 'Z 的底横末端收笔上扬成一个勾,等于把「甄选/通过」这层意思藏进了字母里。形义双关,是这批里最有故事的一稿。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) +
|
||||
'<path d="M23.3 31.8 H40.7 L23.3 45.7 H32 L39.5 38.2" fill="none" stroke="' + INK +
|
||||
'" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
},
|
||||
{
|
||||
id: 'zf-11', name: '粗细对比 Z', en: 'Contrast', tag: '衬线感 · 优雅',
|
||||
desc: '横画加粗到 6、斜画压细到 2.3,做出传统衬线字的粗细对比。优雅、有书卷气,适合线下高端品牌。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) +
|
||||
'<path fill="' + INK + '" d="M21.5 30 H42.5 V36 L29 41.5 H42.5 V47.5 H21.5 V41.5 H23 L36.5 36 H21.5 Z"/>'
|
||||
},
|
||||
{
|
||||
id: 'zf-12', name: '金线 Z', en: 'Gold Line', tag: '墨蓝 + 金',
|
||||
desc: '线框 Z 的配色版:框留墨蓝、Z 换金色 #C6A15B。比实心金 Z 更含蓄,烫金、压凹工艺都能做。',
|
||||
mode: 'solid',
|
||||
svg: frame(1.5, INK) + zline(3.6, { caps: 'butt', join: 'miter', color: GOLD })
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
// ── 数据瘦身 + 预置检索字段 ──────────────────────────────────────
|
||||
const items = data.list.map((it, i) => ({
|
||||
i: i,
|
||||
id: it.id, n: it.name, t: it.tier, s: it.source, sn: it.sourceName,
|
||||
ty: it.type, lic: it.license, bad: LIC_BAD.test(it.license || '') ? 1 : 0,
|
||||
svg: it.svg
|
||||
}));
|
||||
|
||||
const sources = {};
|
||||
items.forEach(x => { sources[x.s] = (sources[x.s] || 0) + 1; });
|
||||
const sourceList = Object.keys(sources).sort((a, b) => sources[b] - sources[a]);
|
||||
|
||||
const payload = {
|
||||
builtAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||
total: items.length,
|
||||
sourceCount: sourceList.length,
|
||||
tiers: TIERS.map(t => ({ key: t.key, label: t.label, desc: t.desc })),
|
||||
sources: sourceList.map(id => ({ id: id, count: sources[id], name: (items.find(x => x.s === id) || {}).sn || id })),
|
||||
items: items
|
||||
};
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>甄房 ZhenFang · LOGO 素材挑选台</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0a0d13; --panel:#111621; --panel2:#161c29; --line:rgba(255,255,255,.08);
|
||||
--tx:#e9edf5; --mut:#8b94a7; --dim:#5d6577;
|
||||
--gold:#c9a961; --gold2:#e6cd93; --ok:#4ade80;
|
||||
--serif:"Songti SC","STSong","SimSun","Noto Serif SC",serif;
|
||||
}
|
||||
body{font-family:"PingFang SC","Microsoft YaHei","Segoe UI",sans-serif;background:var(--bg);color:var(--tx);
|
||||
-webkit-font-smoothing:antialiased;padding:0 0 80px}
|
||||
a{color:var(--gold)}
|
||||
.wrap{max-width:1560px;margin:0 auto;padding:0 28px}
|
||||
|
||||
/* ── Hero ── */
|
||||
.hero{padding:52px 0 34px;border-bottom:1px solid var(--line);
|
||||
background:radial-gradient(900px 320px at 18% -10%,rgba(201,169,97,.16),transparent 60%)}
|
||||
.hero h1{font-size:34px;letter-spacing:1px}
|
||||
.hero h1 b{font-family:var(--serif);font-weight:600;letter-spacing:6px;
|
||||
background:linear-gradient(100deg,var(--gold2),var(--gold));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.hero .en{color:var(--dim);letter-spacing:7px;font-size:11px;margin-top:8px}
|
||||
.hero p.lead{color:var(--mut);font-size:13.5px;margin-top:16px;line-height:1.85;max-width:900px}
|
||||
.kpis{display:flex;gap:34px;margin-top:24px;flex-wrap:wrap}
|
||||
.kpi b{display:block;font-size:24px;color:var(--gold2);font-weight:600}
|
||||
.kpi span{font-size:11.5px;color:var(--mut)}
|
||||
|
||||
h2.sec{font-size:19px;margin:46px 0 6px;display:flex;align-items:center;gap:10px}
|
||||
h2.sec:before{content:"";width:4px;height:18px;border-radius:2px;background:linear-gradient(180deg,var(--gold2),var(--gold))}
|
||||
.secsub{color:var(--mut);font-size:12.5px;margin-bottom:20px;line-height:1.7}
|
||||
|
||||
/* ── 方案 ── */
|
||||
.concepts{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:18px}
|
||||
.cc{background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--line);border-radius:16px;
|
||||
padding:20px;display:flex;gap:18px;transition:.2s;position:relative;overflow:hidden}
|
||||
.cc:hover{border-color:rgba(201,169,97,.42);transform:translateY(-2px)}
|
||||
.cc .mark{width:88px;height:88px;flex:none;border-radius:20px;display:flex;align-items:center;justify-content:center;
|
||||
background:linear-gradient(150deg,#f7f3e9,#efe7d5);box-shadow:0 8px 22px rgba(0,0,0,.35)}
|
||||
.cc .mark svg{width:58px;height:58px;color:#1d2431}
|
||||
.cc.solid .mark{background:#fff;box-shadow:0 6px 18px rgba(0,0,0,.38),0 0 0 1px rgba(0,0,0,.06) inset}
|
||||
.cc.solid .mark svg{width:62px;height:62px}
|
||||
code{background:#0d1220;border:1px solid var(--line);border-radius:5px;padding:1px 5px;font-size:11px;color:var(--gold2)}
|
||||
.star{color:var(--gold)}
|
||||
.cc .body{flex:1;min-width:0}
|
||||
.cc .nm{font-size:15.5px;font-weight:600;display:flex;align-items:center;gap:8px}
|
||||
.cc .nm .star{color:var(--gold);font-size:12px;line-height:1}
|
||||
.cc .nm em{font-style:normal;font-size:10px;color:var(--gold);border:1px solid rgba(201,169,97,.4);
|
||||
padding:1px 7px;border-radius:20px;font-weight:400}
|
||||
.cc .lock{display:flex;align-items:baseline;gap:9px;margin:9px 0 8px}
|
||||
.cc .lock .zh{font-family:var(--serif);font-size:27px;letter-spacing:5px;font-weight:600}
|
||||
.cc .lock .en2{font-size:9.5px;letter-spacing:3.5px;color:var(--dim)}
|
||||
.cc .ds{font-size:12px;color:var(--mut);line-height:1.75}
|
||||
.cc .acts{margin-top:11px;display:flex;gap:8px}
|
||||
.cc .acts button{font-size:11px;padding:4px 10px;border-radius:7px;border:1px solid var(--line);
|
||||
background:#0d1220;color:var(--mut);cursor:pointer}
|
||||
.cc .acts button:hover{color:var(--gold2);border-color:rgba(201,169,97,.45)}
|
||||
|
||||
/* ── 挑选台 ── */
|
||||
.picker{display:grid;grid-template-columns:1fr 348px;gap:22px;align-items:start;margin-top:8px}
|
||||
.filters{position:sticky;top:0;z-index:9;background:linear-gradient(180deg,var(--bg) 72%,rgba(10,13,19,0));
|
||||
padding:12px 0 14px;display:flex;flex-direction:column;gap:11px}
|
||||
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||||
input[type=search],select{background:#0d1220;border:1px solid var(--line);color:var(--tx);
|
||||
border-radius:9px;padding:8px 12px;font-size:12.5px;outline:none;font-family:inherit}
|
||||
input[type=search]{flex:1;min-width:220px}
|
||||
input[type=search]:focus,select:focus{border-color:rgba(201,169,97,.5)}
|
||||
.tabs{display:flex;gap:7px;flex-wrap:wrap}
|
||||
.tab{padding:6px 13px;border-radius:20px;border:1px solid var(--line);background:#0d1220;color:var(--mut);
|
||||
font-size:12px;cursor:pointer;white-space:nowrap;transition:.15s}
|
||||
.tab:hover{color:var(--tx)}
|
||||
.tab.on{background:linear-gradient(120deg,var(--gold),#a8873f);color:#17130a;border-color:transparent;font-weight:600}
|
||||
.tab i{font-style:normal;opacity:.62;margin-left:5px;font-size:10.5px}
|
||||
.chk{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--mut);cursor:pointer;user-select:none}
|
||||
.chk input{accent-color:var(--gold)}
|
||||
.cnt{font-size:11.5px;color:var(--dim);margin-left:auto}
|
||||
input[type=color]{width:34px;height:30px;padding:0;border:1px solid var(--line);border-radius:8px;background:#0d1220;cursor:pointer}
|
||||
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:10px}
|
||||
.ic{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:11px 7px 8px;cursor:pointer;
|
||||
display:flex;flex-direction:column;align-items:center;gap:7px;transition:.14s;position:relative}
|
||||
.ic:hover{border-color:rgba(201,169,97,.5);background:var(--panel2)}
|
||||
.ic.on{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold) inset}
|
||||
.ic .bx{width:52px;height:52px;display:flex;align-items:center;justify-content:center;color:#c3cbd9}
|
||||
.ic .bx.multi{background:#f5f2ea;border-radius:12px}
|
||||
.ic.on .bx{color:var(--gold2)}
|
||||
.ic .lb{font-size:9.5px;color:var(--mut);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ic .lic{position:absolute;top:5px;right:6px;font-size:8px;color:#f0a55c;display:none}
|
||||
.ic.bad .lic{display:block}
|
||||
.ic .fav{position:absolute;top:3px;left:4px;font-size:11px;line-height:1;opacity:0;transition:.15s}
|
||||
.ic:hover .fav,.ic.faved .fav{opacity:1}
|
||||
.more{grid-column:1/-1;text-align:center;padding:22px 0}
|
||||
.more button{padding:10px 26px;border-radius:10px;border:1px solid var(--line);background:var(--panel);
|
||||
color:var(--mut);cursor:pointer;font-size:12.5px;font-family:inherit}
|
||||
.more button:hover{color:var(--gold2);border-color:rgba(201,169,97,.45)}
|
||||
.empty{grid-column:1/-1;text-align:center;color:var(--dim);padding:60px 0;font-size:13px}
|
||||
|
||||
/* ── 详情面板 ── */
|
||||
.detail{position:sticky;top:14px;background:linear-gradient(180deg,var(--panel2),var(--panel));
|
||||
border:1px solid var(--line);border-radius:16px;padding:18px}
|
||||
.detail h3{font-size:13px;color:var(--mut);font-weight:500;margin-bottom:13px;display:flex;justify-content:space-between}
|
||||
.prev{display:flex;gap:10px;justify-content:center;margin-bottom:14px}
|
||||
.prev .p{width:82px;height:82px;border-radius:19px;display:flex;align-items:center;justify-content:center}
|
||||
.prev .p.round{border-radius:50%}
|
||||
.prev .p.square{border-radius:9px}
|
||||
.lockup{display:flex;align-items:center;gap:11px;padding:14px;border-radius:12px;background:#0d1220;
|
||||
border:1px solid var(--line);margin-bottom:13px}
|
||||
.lockup .bd{width:50px;height:50px;border-radius:13px;display:flex;align-items:center;justify-content:center;flex:none}
|
||||
.lockup .tw .zh{font-family:var(--serif);font-size:23px;letter-spacing:4px;font-weight:600;line-height:1.1}
|
||||
.lockup .tw .en3{font-size:8.5px;letter-spacing:3px;color:var(--dim);margin-top:3px}
|
||||
.meta{font-size:11px;color:var(--mut);line-height:1.95;word-break:break-all}
|
||||
.meta b{color:var(--dim);font-weight:400}
|
||||
.meta .warn{color:#f0a55c}
|
||||
.dacts{display:flex;gap:7px;flex-wrap:wrap;margin-top:13px}
|
||||
.dacts button{flex:1;min-width:82px;padding:8px 4px;border-radius:9px;border:1px solid var(--line);
|
||||
background:#0d1220;color:var(--mut);font-size:11.5px;cursor:pointer;font-family:inherit}
|
||||
.dacts button:hover{color:var(--gold2);border-color:rgba(201,169,97,.45)}
|
||||
.dacts button.pri{background:linear-gradient(120deg,var(--gold),#a8873f);color:#17130a;border-color:transparent;font-weight:600}
|
||||
.ph{color:var(--dim);font-size:12px;text-align:center;padding:34px 0;line-height:1.9}
|
||||
.faved-list{max-height:150px;overflow:auto;margin-top:11px;border-top:1px solid var(--line);padding-top:11px}
|
||||
.faved-list .fi{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:11px;color:var(--mut)}
|
||||
.faved-list .fi svg{width:18px;height:18px;flex:none;color:#c3cbd9}
|
||||
.faved-list .fi span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
.faved-list .fi em{cursor:pointer;color:var(--dim);font-style:normal}
|
||||
.toast{position:fixed;left:50%;bottom:34px;transform:translateX(-50%) translateY(16px);opacity:0;
|
||||
background:#1b2130;border:1px solid rgba(201,169,97,.4);color:var(--gold2);padding:10px 20px;
|
||||
border-radius:11px;font-size:12.5px;pointer-events:none;transition:.22s;z-index:99}
|
||||
.toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
footer{margin-top:52px;border-top:1px solid var(--line);padding-top:22px;color:var(--dim);font-size:11.5px;line-height:2}
|
||||
@media(max-width:1080px){.picker{grid-template-columns:1fr}.detail{position:static}}
|
||||
|
||||
/* ── 字体样本 ── */
|
||||
.fsgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(198px,1fr));gap:12px}
|
||||
.fs{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:12px 10px 11px;text-align:center;
|
||||
cursor:pointer;transition:.14s}
|
||||
.fs:hover{border-color:rgba(201,169,97,.45);background:var(--panel2);transform:translateY(-2px)}
|
||||
.fs .tile{background:#fff;border-radius:12px;height:126px;display:flex;align-items:center;justify-content:center;
|
||||
margin-bottom:10px;box-shadow:0 4px 12px rgba(0,0,0,.28)}
|
||||
.fs .fn{font-size:11.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.fs .fc{font-size:9.5px;color:var(--mut);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.fs .fl{display:inline-block;font-size:9px;padding:1px 7px;border-radius:20px;margin-top:6px}
|
||||
.fs .fl.ofl{color:#5ee08a;border:1px solid rgba(94,224,138,.35)}
|
||||
.fs .fl.sys{color:#f0a55c;border:1px solid rgba(240,165,92,.3)}
|
||||
${fontFaces}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hero"><div class="wrap">
|
||||
<h1>「甄房」LOGO · <b>素材挑选台</b></h1>
|
||||
<div class="en">Z H E N F A N G · L O G O P I C K E R</div>
|
||||
<p class="lead">
|
||||
第一步:从你的设计项目素材库(画个标 · 共享图标库 222 源 / 37 万图标)里,把跟「房子」相关的素材全部捞了出来,
|
||||
去重、分层、按风格精选成 <b style="color:var(--gold2)">${items.length}</b> 款可挑选图形;<br>
|
||||
第二步:基于这批素材的造型语言,我另外设计了两批「甄房」LOGO 概念方案(下方 ${8 + CONCEPTS_FRAME.length} 稿,均为原创矢量,可直接用)——
|
||||
第一批 8 稿走<b style="color:var(--gold2)">线条 / 中式 / 印章</b>路线;第二批 ${CONCEPTS_FRAME.length} 稿是<b style="color:var(--gold2)">home-04 房子外框 + Z 字形</b>(外框一路不动,只换 Z 的字形与笔画);
|
||||
第三步:Z 的字形不再手画,直接扫你机器上已装的<b style="color:var(--gold2)">${FONTS.total} 款真字体</b>(素材库 OFL 12 款 + 系统字体 ${FONTS.total - 12} 款)渲染对比(第三节)。
|
||||
</p>
|
||||
<div class="kpis">
|
||||
<div class="kpi"><b>${items.length}</b><span>精选房子类素材</span></div>
|
||||
<div class="kpi"><b>${sourceList.length}</b><span>覆盖图标库源</span></div>
|
||||
<div class="kpi"><b>${8 + CONCEPTS_FRAME.length}</b><span>甄房 LOGO 方案</span></div>
|
||||
<div class="kpi"><b>${FONTS.total}</b><span>可试 Z 的字体</span></div>
|
||||
<div class="kpi"><b>${payload.builtAt}</b><span>素材生成时间</span></div>
|
||||
</div>
|
||||
<div style="margin-top:26px;background:linear-gradient(120deg,rgba(198,161,91,.13),rgba(12,35,64,.55));
|
||||
border:1px solid rgba(198,161,91,.42);border-radius:14px;padding:16px 18px;
|
||||
display:flex;align-items:center;gap:16px;flex-wrap:wrap">
|
||||
<span style="font-size:13.5px;color:#E3CB99;font-weight:600">✓ 定稿交付已完成</span>
|
||||
<span style="font-size:12.5px;color:var(--mut)">Forte Z + 甄房蓝(主色 #0C2340)|10 个矢量资产 + 36 个位图 + 完整色板规范</span>
|
||||
<a href="logo/index.html" target="_blank" style="margin-left:auto;font-size:12.5px;color:#0C2340;
|
||||
background:linear-gradient(120deg,#E3CB99,#C6A15B);padding:8px 18px;border-radius:9px;
|
||||
text-decoration:none;font-weight:600">打开定稿交付页 →</a>
|
||||
<a href="logo/brush-z.html" target="_blank" style="font-size:12.5px;color:#2a1f06;
|
||||
background:linear-gradient(120deg,#F3D98F,#E0B95C);padding:8px 18px;border-radius:9px;
|
||||
text-decoration:none;font-weight:700">★ 原创笔刷 Z(版权归你)→</a>
|
||||
<a href="logo/ofl-brush.html" target="_blank" style="font-size:12.5px;color:#0a2c19;
|
||||
background:linear-gradient(120deg,#8fe8ad,#4fd07f);padding:8px 18px;border-radius:9px;
|
||||
text-decoration:none;font-weight:600">OFL 可商用字体试排 →</a>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<h2 class="sec">一、甄房 LOGO 概念方案 · 第一批(线条 / 中式 / 印章 · 8 稿)</h2>
|
||||
<div class="secsub">
|
||||
取名「甄房」:甄,本义为制陶甄别、去伪存真,引申为<b style="color:var(--gold2)">甄选</b>;房即房源。
|
||||
因此所有方案都围绕「房子 + 甄选」两条线索做叠加。每稿都附合体字标(甄房 / ZHENFANG),可直接定为终稿方向。
|
||||
</div>
|
||||
<div class="concepts" id="concepts"></div>
|
||||
|
||||
<h2 class="sec">二、home-04 外框 + Z 字形探索(12 款新字形 + 3 款已留)</h2>
|
||||
<div class="secsub">
|
||||
外框固定不动 = <code>hugeicons:home-04</code>(MIT)原图路径等比放大,形状/比例/线宽全部不动。<b style="color:var(--gold2)">这一节只换里面的 Z</b>:字面统一锁在 21×17.5 的字面框内,各款按笔画宽度自动内缩,所以 12 款的字面大小一致、只有字形不同。<br>
|
||||
<span class="star">★</span> 标记的三款是你已选定保留的(深底反白 / 圆角 Z / 线框 Z),放在最前面方便对照。
|
||||
</div>
|
||||
<div class="concepts" id="concepts-frame"></div>
|
||||
|
||||
<h2 class="sec">三、Z 字母 · 字体精选(${FONTS.total} 款)</h2>
|
||||
<div class="secsub">
|
||||
不再手画字形——直接用<b style="color:var(--gold2)">真字体</b>渲染 Z。外框依旧是 home-04 原图(不动),Z 用各款字体的大写 Z,并<b style="color:var(--gold2)">等比放大到填满同一个字面框(21.5 × 20)</b>——与你保留的三款(Z 约 21 宽 × 19.7 高、竖直中心 38.75)取同一比例,所以不存在「大小不一」的干扰,只看字形本身的宽窄与笔画性格。<br>
|
||||
<span style="color:#5ee08a">●</span> 本地素材库字体(OFL-1.1,<b>可直接商用</b>)
|
||||
<span style="color:#f0a55c">●</span> 你本机已安装的系统字体(<b>仅作预览,商用需自行取得授权</b>)<br>
|
||||
点任意一张卡片即可复制它的 SVG。注意:SVG 里的 Z 是<b>活文字</b>(依赖字体),定稿后我可以把你选中的那一款转成曲线路径。
|
||||
</div>
|
||||
<div class="row" style="margin-bottom:14px">
|
||||
<div class="tabs" id="fontcats"></div>
|
||||
<label class="chk"><input type="checkbox" id="onlyOfl"> 只看可商用(OFL)</label>
|
||||
<span class="cnt" id="fontcnt"></span>
|
||||
</div>
|
||||
<div class="fsgrid" id="fsgrid"></div>
|
||||
|
||||
<h2 class="sec">四、房子类素材挑选(${items.length} 款 · 点击即预览成标)</h2>
|
||||
<div class="secsub">
|
||||
分五层:<b>房屋本体</b>(做主图形首选)、<b>门户构件</b>(屋顶/门窗/钥匙)、<b>楼宇建筑</b>(楼宇/公共建筑/城市)、
|
||||
<b>房产交易</b>(成交/租售/服务)、<b>品牌标志</b>(知名品牌 Logo,仅作参考,有商标风险)。
|
||||
点任意一款 → 右侧实时看它套上「甄房」字标的效果,可换主色、换形状、复制/下载 SVG。
|
||||
</div>
|
||||
|
||||
<div class="picker">
|
||||
<div>
|
||||
<div class="filters">
|
||||
<div class="row">
|
||||
<input type="search" id="q" placeholder="搜索名称 / 来源,如 house、key、villa、lucide…">
|
||||
<label class="chk"><input type="checkbox" id="onlyFav"> 只看已收藏</label>
|
||||
<span class="cnt" id="cnt"></span>
|
||||
</div>
|
||||
<div class="tabs" id="tabs"></div>
|
||||
<div class="row">
|
||||
<select id="src"></select>
|
||||
<select id="ty">
|
||||
<option value="">全部样式</option>
|
||||
<option value="icon">单色(可换主色)</option>
|
||||
<option value="multi">彩色(保留原色)</option>
|
||||
</select>
|
||||
<select id="shape">
|
||||
<option value="squircle">形状:圆角方</option>
|
||||
<option value="round">形状:圆形</option>
|
||||
<option value="square">形状:方形</option>
|
||||
</select>
|
||||
<label class="chk">主色 <input type="color" id="brand" value="#1f6feb"></label>
|
||||
<select id="mode">
|
||||
<option value="light">底:浅色</option>
|
||||
<option value="dark" selected>底:深色</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid" id="grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="detail" id="detail"></div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
素材来源:画个标(DrawLogo)共享图标库 · 按 manifest/icons.json 的 source 定向读取后本地提取,未改动原库任何文件。<br>
|
||||
协议提示:标记 <span style="color:#f0a55c">⚠</span> 的素材为 BY-SA / BY-NC / BY-ND / GPL 等限制性协议,商用前请替换或单独确认;常用 MIT / Apache-2.0 / ISC / CC BY 4.0 可商用(建议署名)。<br>
|
||||
「品牌标志」层为第三方注册商标,仅作造型参考,<b style="color:#f0a55c">不得直接用作甄房 LOGO</b>。<br>
|
||||
第二节外框造型取自 <code>hugeicons:home-04</code>(MIT)原图路径,未改动其形状;框内 Z 字全部为原创几何,可放心用于甄房。<br>
|
||||
生成时间 ${payload.builtAt} · 由 _design-test/extract-house.js + build-page.js 生成
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
window.HOUSE = ${JSON.stringify(payload).replace(/</g, '\\u003c')};
|
||||
</script>
|
||||
<script>
|
||||
(function(){
|
||||
var D = window.HOUSE;
|
||||
var ITEMS = D.items;
|
||||
|
||||
/* ── 图标渲染(复刻画个标判定 + 按真实颜色纠偏,保证深底可见)── */
|
||||
var STROKE_SRC = {'feather':1,'lucide':1,'tabler':1};
|
||||
// 是否真彩色素材(含明显彩度/亮色)→ 保留原色;否则按单色参与换色
|
||||
// 注意:<defs>(mask/渐变定义)里的颜色不代表成品配色,先剥离再判定
|
||||
function isVivid(svg){
|
||||
var s = svg.replace(/<defs[\\s\\S]*?<\\/defs>/g, '');
|
||||
var hex = s.match(/#[0-9a-fA-F]{3,8}/g) || [], i, v, r, g, b, mx, mn;
|
||||
for(i = 0; i < hex.length; i++){
|
||||
v = hex[i].slice(1);
|
||||
if(v.length === 3) v = v[0]+v[0]+v[1]+v[1]+v[2]+v[2];
|
||||
if(v.length >= 6 && /^[0-9a-f]+$/i.test(v.substr(0,6))){
|
||||
r = parseInt(v.substr(0,2),16); g = parseInt(v.substr(2,2),16); b = parseInt(v.substr(4,2),16);
|
||||
mx = Math.max(r,g,b); mn = Math.min(r,g,b);
|
||||
if((mx - mn) > 24 || mx > 96) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function isMono(it){ return !isVivid(it.svg); }
|
||||
// 素材内色值平均亮度(<defs> 不计)
|
||||
function avgLum(svg){
|
||||
var s = svg.replace(/<defs[\\s\\S]*?<\\/defs>/g, '');
|
||||
var hex = s.match(/#[0-9a-fA-F]{3,8}/g) || [], tot = 0, n = 0, i, v, r, g, b;
|
||||
for(i = 0; i < hex.length; i++){
|
||||
v = hex[i].slice(1);
|
||||
if(v.length === 3) v = v[0]+v[0]+v[1]+v[1]+v[2]+v[2];
|
||||
if(v.length < 6 || !/^[0-9a-f]+$/i.test(v.substr(0,6))) continue;
|
||||
r = parseInt(v.substr(0,2),16); g = parseInt(v.substr(2,2),16); b = parseInt(v.substr(4,2),16);
|
||||
tot += (0.2126*r + 0.7152*g + 0.0722*b) / 255; n++;
|
||||
}
|
||||
return n ? tot / n : 0.5;
|
||||
}
|
||||
// 彩色素材若主打深色(深色品牌色)→ 需浅色衬底;主打亮色/白色 → 深色卡片底即可
|
||||
function needsChip(it){ return !isMono(it) && avgLum(it.svg) < 0.45; }
|
||||
function isStroke(it){
|
||||
if(!isMono(it)) return false;
|
||||
if(STROKE_SRC[it.s]) return true;
|
||||
var b = it.svg;
|
||||
if(/stroke-width="0"/.test(b)) return false;
|
||||
// 极小 scale 包装(512 系坐标缩到 24,如 selfhst / antd):描边会被同比缩成发丝 → 强制走填充
|
||||
var sc = b.match(/scale\\(([\\d.]+)\\)/);
|
||||
if(sc && parseFloat(sc[1]) < 0.12) return false;
|
||||
// 填充型信号:evenodd 填充规则且元素级无描边(semi-icons 等,误判描边会整图不可见)
|
||||
if(/fill-rule\\s*=\\s*"evenodd"/.test(b) && !/stroke\\s*=\\s*"(?!none")[^"]*"/.test(b)) return false;
|
||||
if(/stroke\\s*=|stroke-linecap\\s*=|stroke-linejoin\\s*=/.test(b)) return true;
|
||||
var g = b.match(/<(?:path|circle|rect|ellipse|line|polyline|polygon)\\b[^>]*>/g);
|
||||
if(g && g.length && !/fill\\s*=\\s*"(?!none")[^"]*"/.test(b)) return true;
|
||||
return false;
|
||||
}
|
||||
// 深色硬编码 + currentColor 统一换成主题色(按亮度判定,覆盖 #0c1221 / #231f20 这类非纯黑深色)
|
||||
function themed(body, color){
|
||||
return body.replace(/currentColor/gi, color).replace(/#[0-9a-fA-F]{3,8}/g, function(h){
|
||||
var v = h.slice(1);
|
||||
if(v.length === 3) v = v[0]+v[0]+v[1]+v[1]+v[2]+v[2];
|
||||
if(v.length < 6 || !/^[0-9a-f]+$/i.test(v.substr(0,6))) return h;
|
||||
var r = parseInt(v.substr(0,2),16), g = parseInt(v.substr(2,2),16), b = parseInt(v.substr(4,2),16);
|
||||
return (0.299*r + 0.587*g + 0.114*b) < 96 ? color : h;
|
||||
});
|
||||
}
|
||||
function iconSvg(it, color, size, w){
|
||||
var mono = isMono(it);
|
||||
if(!mono) return '<svg viewBox="0 0 24 24" width="'+size+'" height="'+size+'" xmlns="http://www.w3.org/2000/svg">'+it.svg+'</svg>';
|
||||
var body = themed(it.svg, color);
|
||||
var attrs = isStroke(it)
|
||||
? 'fill="none" stroke="'+color+'" stroke-width="'+(w||1.9)+'" stroke-linecap="round" stroke-linejoin="round"'
|
||||
: 'fill="'+color+'" stroke="none"';
|
||||
return '<svg viewBox="0 0 24 24" width="'+size+'" height="'+size+'" xmlns="http://www.w3.org/2000/svg" '+attrs+'>'+body+'</svg>';
|
||||
}
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
|
||||
/* ── 配色 ── */
|
||||
var S = {
|
||||
tier:'all', q:'', src:'', ty:'', onlyFav:false,
|
||||
brand:'#1f6feb', mode:'light', shape:'squircle',
|
||||
active:null, fav:{}
|
||||
};
|
||||
function brandBg(){
|
||||
return S.mode==='dark' ? '#111827' : S.brand;
|
||||
}
|
||||
function multiBg(){
|
||||
return '#f5f2ea'; // 深色作品专用浅色衬底
|
||||
}
|
||||
|
||||
/* ── 方案渲染(两批:线条原创 / 实心几何)── */
|
||||
var G1 = ${JSON.stringify(CONCEPTS).replace(/</g, '\\u003c')};
|
||||
var G2 = ${JSON.stringify(CONCEPTS_FRAME).replace(/</g, '\\u003c')};
|
||||
function conceptSvg(c){
|
||||
if(c.mode === 'solid') return '<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">'+c.svg+'</svg>';
|
||||
var sw = c.mode === 'mixed' ? 3.4 : 3.6;
|
||||
return '<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" '+
|
||||
'stroke-width="'+sw+'" stroke-linecap="round" stroke-linejoin="round">'+c.svg+'</svg>';
|
||||
}
|
||||
function conceptStandalone(c){
|
||||
if(c.mode === 'solid') return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512">'+c.svg+'</svg>';
|
||||
var sw = c.mode === 'mixed' ? 3.4 : 3.6;
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512" fill="none" '+
|
||||
'stroke="#1f6feb" stroke-width="'+sw+'" stroke-linecap="round" stroke-linejoin="round">'+c.svg+'</svg>';
|
||||
}
|
||||
function renderConcepts(hostId, list){
|
||||
var host = document.getElementById(hostId);
|
||||
host.innerHTML = list.map(function(c){
|
||||
return '<div class="cc'+(c.mode === 'solid' ? ' solid' : '')+'" data-cid="'+c.id+'">'+
|
||||
'<div class="mark">'+conceptSvg(c)+'</div>'+
|
||||
'<div class="body">'+
|
||||
'<div class="nm">'+(c.star ? '<span class="star">'+(c.star > 1 ? '★★' : '★')+'</span>' : '')+esc(c.name)+'<em>'+esc(c.tag)+'</em></div>'+
|
||||
'<div class="lock"><span class="zh">甄房</span><span class="en2">ZHENFANG</span></div>'+
|
||||
'<div class="ds">'+esc(c.desc)+'</div>'+
|
||||
'<div class="acts"><button data-act="dl">下载 SVG</button><button data-act="copy">复制代码</button></div>'+
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
host.addEventListener('click', function(e){
|
||||
var b = e.target.closest('button'); if(!b) return;
|
||||
var c = list.filter(function(x){return x.id === b.closest('.cc').dataset.cid;})[0];
|
||||
if(b.dataset.act === 'dl') download('zhenfang-'+c.id+'.svg', conceptStandalone(c));
|
||||
else copy(conceptStandalone(c), '已复制 '+c.name+' 的 SVG 代码');
|
||||
});
|
||||
}
|
||||
renderConcepts('concepts', G1);
|
||||
renderConcepts('concepts-frame', G2);
|
||||
|
||||
/* ── Z 字母字体样本(真字体渲染 + 统一字高归一化)── */
|
||||
var FG2 = '${FG}', H04P = '${H04}', INKC = '${INK}';
|
||||
var FT = ${JSON.stringify(FONTS.fonts).replace(/</g, '\\u003c')};
|
||||
var FCTS = ${JSON.stringify(FONT_CATS)};
|
||||
var fCat = 'all', onlyOfl = false;
|
||||
|
||||
function famCss(f){ return "'" + String(f.family).replace(/'/g, '') + "', sans-serif"; }
|
||||
function frameTag(){
|
||||
return '<g transform="' + FG2 + '"><path d="' + H04P + '" fill="none" stroke="' + INKC +
|
||||
'" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g>';
|
||||
}
|
||||
function specimenSvg(f, size, tf){
|
||||
return '<svg viewBox="0 0 64 64" width="' + size + '" height="' + size + '" xmlns="http://www.w3.org/2000/svg">' +
|
||||
frameTag() + '<text x="32" y="47.5" text-anchor="middle" font-family="' + famCss(f) + '" font-size="25" fill="' + INKC + '"' +
|
||||
(tf ? ' transform="' + tf + '"' : '') + '>Z</text></svg>';
|
||||
}
|
||||
|
||||
var fsgrid = document.getElementById('fsgrid');
|
||||
function renderFonts(){
|
||||
var list = FT.filter(function(f){
|
||||
if(onlyOfl && f.kind !== 'ofl') return false;
|
||||
if(fCat !== 'all' && f.cat !== fCat) return false;
|
||||
return true;
|
||||
});
|
||||
fsgrid.innerHTML = list.map(function(f){
|
||||
var badge = f.kind === 'ofl' ? '<span class="fl ofl">OFL · 可商用</span>' : '<span class="fl sys">系统字体 · 需授权</span>';
|
||||
return '<div class="fs" data-s="' + f.slug + '" title="' + esc(f.family) + ' — ' + esc(f.cn) + '">' +
|
||||
'<div class="tile">' + specimenSvg(f, 116) + '</div>' +
|
||||
'<div class="fn">' + esc(f.family) + '</div>' +
|
||||
'<div class="fc">' + esc(f.cn) + '</div>' + badge + '</div>';
|
||||
}).join('');
|
||||
document.getElementById('fontcnt').textContent = '共 ' + list.length + ' 款';
|
||||
fitZ();
|
||||
}
|
||||
// 字形着墨度量:SVG <text> 的 getBBox / 命中测试拿到的都是「行盒」,不是字形,
|
||||
// 只有 canvas measureText 的 actualBoundingBox* 才是真正的着墨范围(对系统字体与 @font-face 都有效)
|
||||
var _mctx = document.createElement('canvas').getContext('2d');
|
||||
function inkMetrics(family, px){
|
||||
_mctx.font = px + 'px "' + String(family).replace(/"/g, '') + '", sans-serif';
|
||||
var m = _mctx.measureText('Z');
|
||||
if(!m || (!m.actualBoundingBoxAscent && !m.actualBoundingBoxDescent)) return null;
|
||||
var asc = m.actualBoundingBoxAscent || 0, desc = m.actualBoundingBoxDescent || 0;
|
||||
var w = (m.actualBoundingBoxLeft || 0) + (m.actualBoundingBoxRight || 0);
|
||||
if(!w) w = m.width;
|
||||
return { asc: asc, desc: desc, w: w, h: asc + desc }; // 25px 测量值 ⇔ viewBox 用户单位(text 的 font-size=25 即 25 用户单位)
|
||||
}
|
||||
var FMAP = {};
|
||||
FT.forEach(function(f){ FMAP[f.slug] = f; });
|
||||
function fitOne(card){
|
||||
var f = FMAP[card.getAttribute('data-s')];
|
||||
var t = card.querySelector('.tile svg text');
|
||||
if(!f || !t) return;
|
||||
var mk = inkMetrics(f.family, 25); // 以 25px 测量,比例与字号无关
|
||||
if(!mk || mk.h < 2) return;
|
||||
// 目标:等比填满 21.5 × 20 的字面框(与保留三款同比例),哪一边先贴死就以哪边为准
|
||||
var k = Math.min(21.5 / mk.w, 20 / mk.h);
|
||||
if(k > 8) k = 8;
|
||||
if(k < 0.1) k = 0.1;
|
||||
// 让字形竖直中心落在 38.75(与保留款一致):反推基线最终位置
|
||||
var baseFinal = 38.75 + (mk.asc - mk.desc) * k / 2;
|
||||
var dy = baseFinal - 47.5;
|
||||
t.setAttribute('transform', 'translate(0 ' + dy + ') translate(32 47.5) scale(' + k + ') translate(-32 -47.5)');
|
||||
card.setAttribute('data-fit', '1');
|
||||
}
|
||||
function fitZ(){
|
||||
var cards = fsgrid.querySelectorAll('.fs');
|
||||
for(var i = 0; i < cards.length; i++) fitOne(cards[i]);
|
||||
}
|
||||
fsgrid.addEventListener('click', function(e){
|
||||
var c = e.target.closest('.fs'); if(!c) return;
|
||||
var f = FT.filter(function(x){ return x.slug === c.dataset.s; })[0];
|
||||
var tf = c.querySelector('text').getAttribute('transform') || '';
|
||||
copy(specimenSvg(f, 512, tf), '已复制 ' + f.family + ' 的 Z(活文字,依赖该字体)');
|
||||
});
|
||||
|
||||
var fcats = document.getElementById('fontcats');
|
||||
fcats.innerHTML = ['all'].concat(FCTS).map(function(c){
|
||||
return '<div class="tab' + (c === 'all' ? ' on' : '') + '" data-k="' + esc(c) + '">' +
|
||||
(c === 'all' ? '全部' : esc(c)) + '</div>';
|
||||
}).join('');
|
||||
fcats.onclick = function(e){
|
||||
var t = e.target.closest('.tab'); if(!t) return;
|
||||
fCat = t.dataset.k;
|
||||
[].forEach.call(fcats.children, function(x){ x.classList.toggle('on', x === t); });
|
||||
renderFonts();
|
||||
};
|
||||
document.getElementById('onlyOfl').onchange = function(e){ onlyOfl = e.target.checked; renderFonts(); };
|
||||
|
||||
renderFonts();
|
||||
function refitAll(){ // 字体异步加载完成后重测(首次可能量到回退字体)
|
||||
var cs = fsgrid.querySelectorAll('.fs');
|
||||
for(var i = 0; i < cs.length; i++) cs[i].removeAttribute('data-fit');
|
||||
fitZ();
|
||||
}
|
||||
if(document.fonts && document.fonts.ready){ document.fonts.ready.then(refitAll); }
|
||||
if(document.fonts && document.fonts.addEventListener){ document.fonts.addEventListener('loadingdone', refitAll); }
|
||||
setTimeout(refitAll, 700); setTimeout(refitAll, 2000);
|
||||
|
||||
/* ── 过滤 ── */
|
||||
function filtered(){
|
||||
var q = S.q.trim().toLowerCase();
|
||||
return ITEMS.filter(function(it){
|
||||
if(S.tier !== 'all' && it.t !== S.tier) return false;
|
||||
if(S.src && it.s !== S.src) return false;
|
||||
if(S.ty === 'icon' && !isMono(it)) return false;
|
||||
if(S.ty === 'multi' && isMono(it)) return false;
|
||||
if(S.onlyFav && !S.fav[it.id]) return false;
|
||||
if(q && (it.n+' '+it.id+' '+it.sn).toLowerCase().indexOf(q) < 0) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 网格分批渲染 ── */
|
||||
// 坐标自适应:少数素材(slds 等)原始坐标不是 24 系,直接套 24 viewBox 会缩成小点
|
||||
function autofit(root){
|
||||
var svgs = root.querySelectorAll('svg:not([data-fit])');
|
||||
for(var i = 0; i < svgs.length; i++){
|
||||
var svg = svgs[i], bb;
|
||||
svg.setAttribute('data-fit', '1');
|
||||
try{ bb = svg.getBBox(); }catch(e){ continue; }
|
||||
if(!bb || !bb.width || !bb.height) continue;
|
||||
var mx = Math.max(bb.width, bb.height);
|
||||
if(mx >= 12 && mx <= 26) continue;
|
||||
var k = 18 / mx;
|
||||
if(k > 8) k = 8;
|
||||
if(k < 0.4) k = 0.4;
|
||||
var cx = bb.x + bb.width / 2, cy = bb.y + bb.height / 2;
|
||||
var g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
g.setAttribute('transform', 'translate(' + (12 - cx * k) + ' ' + (12 - cy * k) + ') scale(' + k + ')');
|
||||
while(svg.firstChild) g.appendChild(svg.firstChild);
|
||||
svg.appendChild(g);
|
||||
}
|
||||
}
|
||||
var shown = 0, list = [], BATCH = 120;
|
||||
var grid = document.getElementById('grid');
|
||||
function renderGrid(reset){
|
||||
if(reset){ list = filtered(); shown = 0; grid.innerHTML = ''; }
|
||||
var frag = document.createDocumentFragment();
|
||||
var end = Math.min(shown + BATCH, list.length);
|
||||
for(var k = shown; k < end; k++){
|
||||
var it = list[k];
|
||||
var mono = isMono(it);
|
||||
var d = document.createElement('div');
|
||||
d.className = 'ic' + (it.bad ? ' bad' : '') + (S.fav[it.id] ? ' faved' : '') + (S.active && S.active.id === it.id ? ' on' : '');
|
||||
d.dataset.id = it.id;
|
||||
d.title = it.n + ' · ' + it.sn + (it.lic ? ' · ' + it.lic : '');
|
||||
d.innerHTML = '<span class="lic">⚠</span><span class="fav">'+(S.fav[it.id]?'★':'☆')+'</span>'+
|
||||
'<div class="bx'+(needsChip(it)?' multi':'')+'">'+iconSvg(it, '#c3cbd9', mono?40:34, 1.7)+'</div>'+
|
||||
'<div class="lb">'+esc(it.n)+'</div>';
|
||||
frag.appendChild(d);
|
||||
}
|
||||
shown = end;
|
||||
grid.appendChild(frag);
|
||||
autofit(grid);
|
||||
var old = grid.querySelector('.more'); if(old) old.remove();
|
||||
if(shown < list.length){
|
||||
var m = document.createElement('div');
|
||||
m.className = 'more';
|
||||
m.innerHTML = '<button>加载更多(已显示 '+shown+' / '+list.length+')</button>';
|
||||
m.querySelector('button').onclick = function(){ renderGrid(false); };
|
||||
grid.appendChild(m);
|
||||
}
|
||||
if(!list.length){ grid.innerHTML = '<div class="empty">没有匹配的素材,换个关键词或清掉筛选试试。</div>'; }
|
||||
document.getElementById('cnt').textContent = '筛选结果 ' + list.length + ' 款';
|
||||
}
|
||||
|
||||
grid.addEventListener('click', function(e){
|
||||
var card = e.target.closest('.ic'); if(!card) return;
|
||||
var it = ITEMS.filter(function(x){return x.id === card.dataset.id;})[0];
|
||||
if(e.target.classList.contains('fav')){
|
||||
if(S.fav[it.id]) delete S.fav[it.id]; else S.fav[it.id] = it;
|
||||
card.classList.toggle('faved');
|
||||
card.querySelector('.fav').textContent = S.fav[it.id] ? '★' : '☆';
|
||||
if(S.onlyFav) renderGrid(true);
|
||||
renderDetail(); return;
|
||||
}
|
||||
S.active = it;
|
||||
[].forEach.call(grid.querySelectorAll('.ic.on'), function(c){ c.classList.remove('on'); });
|
||||
card.classList.add('on');
|
||||
renderDetail();
|
||||
if(window.innerWidth > 1080) document.getElementById('detail').scrollIntoView({block:'nearest'});
|
||||
});
|
||||
|
||||
/* ── 详情面板 ── */
|
||||
function badge(B, size){
|
||||
var bg = needsChip(B) ? multiBg() : brandBg();
|
||||
var ico = iconSvg(B, '#ffffff', Math.round(size * 0.6), 1.9);
|
||||
return '<div class="p '+S.shape+'" style="width:'+size+'px;height:'+size+'px;background:'+bg+'">'+ico+'</div>';
|
||||
}
|
||||
function lockup(B){
|
||||
var bg = needsChip(B) ? multiBg() : brandBg();
|
||||
return '<div class="lockup">'+
|
||||
'<div class="bd" style="background:'+bg+'">'+iconSvg(B, '#ffffff', 30, 1.9)+'</div>'+
|
||||
'<div class="tw"><div class="zh">甄房</div><div class="en3">ZHENFANG</div></div></div>';
|
||||
}
|
||||
function renderDetail(){
|
||||
var el = document.getElementById('detail');
|
||||
var B = S.active;
|
||||
var nFav = Object.keys(S.fav).length;
|
||||
if(!B){
|
||||
el.innerHTML = '<h3>实时预览<span>'+(nFav?('已收藏 '+nFav):'')+'</span></h3>'+
|
||||
'<div class="ph">← 从左侧点选任意一款房子素材<br>这里会实时合成「甄房」标<br><br>'+
|
||||
'<span style="color:var(--dim);font-size:11px">可换主色 / 形状 / 底色,一键复制或下载 SVG</span></div>'+
|
||||
favBlock();
|
||||
bindFav(el); autofit(el); return;
|
||||
}
|
||||
var multi = !isMono(B);
|
||||
el.innerHTML = '<h3>实时预览<span>'+(nFav?('已收藏 '+nFav):'')+'</span></h3>'+
|
||||
'<div class="prev">'+badge(B, 82)+'</div>'+
|
||||
lockup(B)+
|
||||
'<div class="meta">'+
|
||||
'<div><b>名称</b> '+esc(B.n)+'</div>'+
|
||||
'<div><b>标识</b> '+esc(B.id)+'</div>'+
|
||||
'<div><b>来源</b> '+esc(B.sn)+'('+esc(B.s)+')</div>'+
|
||||
'<div><b>协议</b> <span class="'+(B.bad?'warn':'')+'">'+esc(B.lic||'未标注')+(B.bad?' ⚠ 限制性协议,商用需确认':'')+'</span></div>'+
|
||||
(multi?'<div class="warn">多色素材:保留原色,不随主色变化</div>':'')+
|
||||
'</div>'+
|
||||
'<div class="dacts">'+
|
||||
'<button class="pri" data-a="fav">'+(S.fav[B.id]?'★ 已收藏':'☆ 收藏')+'</button>'+
|
||||
'<button data-a="copy">复制 SVG</button>'+
|
||||
'<button data-a="dl">下载图形</button>'+
|
||||
'<button data-a="dl2">下载带底标</button>'+
|
||||
'</div>'+
|
||||
favBlock();
|
||||
bindFav(el); autofit(el);
|
||||
el.querySelector('[data-a=fav]').onclick = function(){
|
||||
if(S.fav[B.id]) delete S.fav[B.id]; else S.fav[B.id] = B;
|
||||
renderGrid(true); renderDetail();
|
||||
};
|
||||
el.querySelector('[data-a=copy]').onclick = function(){ copy(svgFlat(B), '已复制 '+B.n+' 的 SVG'); };
|
||||
el.querySelector('[data-a=dl]').onclick = function(){ download('zhenfang-icon-'+B.s+'-'+B.n+'.svg', svgFlat(B)); };
|
||||
el.querySelector('[data-a=dl2]').onclick = function(){ download('zhenfang-logo-'+B.s+'-'+B.n+'.svg', svgBadge(B)); };
|
||||
}
|
||||
// 纯图形 SVG(24 坐标,可换色)
|
||||
function svgFlat(B){
|
||||
var multi = !isMono(B);
|
||||
if(multi) return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="512" height="512">'+B.svg+'</svg>';
|
||||
var body = themed(B.svg, S.brand);
|
||||
var attrs = isStroke(B)
|
||||
? 'fill="none" stroke="'+S.brand+'" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"'
|
||||
: 'fill="'+S.brand+'" stroke="none"';
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="512" height="512" '+attrs+'>'+body+'</svg>';
|
||||
}
|
||||
// 带底标 SVG(512 画布,含底色形状 + 白色图形)
|
||||
function svgBadge(B){
|
||||
var multi = !isMono(B);
|
||||
var bg = needsChip(B) ? multiBg() : brandBg();
|
||||
var bgShape = S.shape === 'round'
|
||||
? '<circle cx="256" cy="256" r="256" fill="'+bg+'"/>'
|
||||
: '<rect x="0" y="0" width="512" height="512" rx="'+(S.shape === 'square' ? 40 : 110)+'" fill="'+bg+'"/>';
|
||||
var ico;
|
||||
if(multi){
|
||||
ico = '<g transform="translate(128 128) scale(10.6667)">'+B.svg+'</g>';
|
||||
} else if(isStroke(B)){
|
||||
ico = '<g transform="translate(128 128) scale(10.6667)" fill="none" stroke="#fff" stroke-width="1.9" '+
|
||||
'stroke-linecap="round" stroke-linejoin="round">'+themed(B.svg, '#fff')+'</g>';
|
||||
} else {
|
||||
ico = '<g transform="translate(128 128) scale(10.6667)" fill="#fff" stroke="none">'+themed(B.svg, '#fff')+'</g>';
|
||||
}
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">'+bgShape+ico+'</svg>';
|
||||
}
|
||||
function favBlock(){
|
||||
var ks = Object.keys(S.fav);
|
||||
if(!ks.length) return '';
|
||||
return '<div class="faved-list">'+ks.map(function(k){
|
||||
var it = S.fav[k];
|
||||
return '<div class="fi" data-id="'+esc(k)+'">'+iconSvg(it,'#c3cbd9',18,1.7)+'<span>'+esc(it.n)+'</span><em>×</em></div>';
|
||||
}).join('')+'</div>';
|
||||
}
|
||||
function bindFav(el){
|
||||
el.querySelectorAll('.faved-list .fi').forEach(function(row){
|
||||
row.querySelector('em').onclick = function(){
|
||||
delete S.fav[row.dataset.id];
|
||||
if(S.active && S.active.id === row.dataset.id) {} // 保留预览
|
||||
renderGrid(true); renderDetail();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 工具 ── */
|
||||
function toast(m){ var t = document.getElementById('toast'); t.textContent = m; t.classList.add('on');
|
||||
clearTimeout(t._t); t._t = setTimeout(function(){ t.classList.remove('on'); }, 1700); }
|
||||
function copy(text, msg){
|
||||
if(navigator.clipboard && navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(text).then(function(){ toast(msg); }, function(){ fallback(text, msg); });
|
||||
} else fallback(text, msg);
|
||||
}
|
||||
function fallback(text, msg){
|
||||
var ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta);
|
||||
ta.select(); try{ document.execCommand('copy'); toast(msg); }catch(e){ toast('复制失败,请手动选择'); }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
function download(name, text){
|
||||
var b = new Blob([text], {type:'image/svg+xml'});
|
||||
var a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = name;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
setTimeout(function(){ URL.revokeObjectURL(a.href); }, 1500);
|
||||
toast('已下载 ' + name);
|
||||
}
|
||||
|
||||
/* ── 筛选控件 ── */
|
||||
var tabs = document.getElementById('tabs');
|
||||
var countByTier = {all: ITEMS.length};
|
||||
ITEMS.forEach(function(it){ countByTier[it.t] = (countByTier[it.t]||0) + 1; });
|
||||
tabs.innerHTML = [{key:'all',label:'全部'}].concat(D.tiers).map(function(t){
|
||||
var key = t.key || 'all';
|
||||
return '<div class="tab'+(key==='all'?' on':'')+'" data-k="'+key+'">'+esc(t.label)+'<i>'+(countByTier[key]||0)+'</i></div>';
|
||||
}).join('');
|
||||
tabs.onclick = function(e){
|
||||
var t = e.target.closest('.tab'); if(!t) return;
|
||||
S.tier = t.dataset.k;
|
||||
[].forEach.call(tabs.children, function(c){ c.classList.toggle('on', c === t); });
|
||||
renderGrid(true);
|
||||
};
|
||||
document.getElementById('src').innerHTML = '<option value="">全部来源('+D.sourceCount+')</option>' +
|
||||
D.sources.map(function(s){ return '<option value="'+esc(s.id)+'">'+esc(s.name)+' · '+s.count+'</option>'; }).join('');
|
||||
document.getElementById('q').oninput = function(e){ S.q = e.target.value; renderGrid(true); };
|
||||
document.getElementById('src').onchange = function(e){ S.src = e.target.value; renderGrid(true); };
|
||||
document.getElementById('ty').onchange = function(e){ S.ty = e.target.value; renderGrid(true); };
|
||||
document.getElementById('onlyFav').onchange = function(e){ S.onlyFav = e.target.checked; renderGrid(true); };
|
||||
document.getElementById('brand').oninput = function(e){ S.brand = e.target.value; renderGrid(false); renderDetail(); };
|
||||
document.getElementById('mode').onchange = function(e){ S.mode = e.target.value; renderDetail(); };
|
||||
document.getElementById('shape').onchange = function(e){ S.shape = e.target.value; renderDetail(); };
|
||||
|
||||
renderGrid(true);
|
||||
renderDetail();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, 'zhenfang-logo-picker.html'), html);
|
||||
console.log('已生成:', path.join(__dirname, 'zhenfang-logo-picker.html'), (Buffer.byteLength(html) / 1024).toFixed(0) + 'KB');
|
||||
@@ -0,0 +1,11 @@
|
||||
// 生成 element-variables.scss 的校验副本(移除依赖 node_modules 的 element-ui 源码 import),供 sass 单独解析
|
||||
// 用法: node _design-test/check-elvars.js
|
||||
const fs = require('fs');
|
||||
const SRC = 'e:/KF_7/ZhenOS-ZF/ruoyi-ui/src/assets/styles/element-variables.scss';
|
||||
const OUT = '_design-test/zf-check-elvars.scss';
|
||||
let t = fs.readFileSync(SRC, 'utf8');
|
||||
const before = t.length;
|
||||
t = t.replace(/@import\s+"~element-ui[^\n]*\n/, '// (校验副本:已移除 element-ui 源码 import)\n');
|
||||
if (t.length === before) { console.error('未找到 element-ui import,请检查'); process.exit(1); }
|
||||
fs.writeFileSync(OUT, t);
|
||||
console.log('已生成 ' + OUT);
|
||||
@@ -0,0 +1,214 @@
|
||||
// 从「画个标」共享图标库提取房子/房产相关素材(分层 + 精选)
|
||||
// 用法: node _design-test/extract-house.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SRC_DIR = path.join(ROOT, 'js', 'data', 'sources');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'js', 'data', 'manifest', 'icons.json'), 'utf8'));
|
||||
const metaById = {};
|
||||
(manifest.sources || []).forEach(s => { metaById[s.id] = s; });
|
||||
|
||||
// ── 分层词表(按 name 的 token 匹配,不做子串匹配)────────────────
|
||||
const TIERS = [
|
||||
{
|
||||
key: 'house',
|
||||
label: '房屋本体',
|
||||
desc: '房子 / 住宅 / 房产 —— 做「甄房」主图形最直接的候选',
|
||||
exact: ['house', 'houses', 'home', 'homes', 'household', 'homestead', 'villa', 'mansion', 'cabin',
|
||||
'cottage', 'chalet', 'hut', 'bungalow', 'dwelling', 'residence', 'residential', 'townhouse',
|
||||
'duplex', 'igloo', 'tent', 'houseroom', 'household', 'shelter',
|
||||
'real-estate', 'realestate', 'estate', 'property', 'properties', 'apartment', 'apartments',
|
||||
'condo', 'condominium', 'flat', 'housing', 'houselink', 'housekey', 'house-key']
|
||||
},
|
||||
{
|
||||
key: 'part',
|
||||
label: '门户构件',
|
||||
desc: '屋顶 / 门窗 / 钥匙 / 院落 —— 可做辅助图形或组合符号',
|
||||
exact: ['roof', 'roofing', 'chimney', 'door', 'doors', 'doorway', 'window', 'windows', 'gate', 'gates',
|
||||
'fence', 'fencing', 'stairs', 'staircase', 'stair', 'elevator', 'key', 'keys', 'keyhole', 'keycard',
|
||||
'lock', 'locks', 'padlock', 'garage', 'mailbox', 'balcony', 'porch', 'terrace', 'ladder',
|
||||
'doorknob', 'doorbell', 'gateway', 'hedge', 'garden']
|
||||
},
|
||||
{
|
||||
key: 'building',
|
||||
label: '楼宇建筑',
|
||||
desc: '楼宇 / 公共建筑 / 城市 —— 适合「房产平台」类意象',
|
||||
exact: ['building', 'buildings', 'skyscraper', 'skyscrapers', 'tower', 'towers', 'office', 'hotel',
|
||||
'motel', 'bank', 'store', 'shop', 'shopfront', 'warehouse', 'factory', 'industry', 'industrial',
|
||||
'city', 'town', 'urban', 'architecture', 'architectural', 'landmark', 'monument', 'castle',
|
||||
'church', 'temple', 'mosque', 'school', 'university', 'college', 'hospital', 'library', 'museum',
|
||||
'stadium', 'bridge', 'barn', 'farm', 'silo', 'windmill', 'lighthouse', 'palace', 'fort', 'fortress',
|
||||
'pyramid', 'pagoda', 'gatehouse', 'observatory', 'terminal', 'station', 'airport']
|
||||
},
|
||||
{
|
||||
key: 'deal',
|
||||
label: '房产交易',
|
||||
desc: '买卖 / 租售 / 中介服务 —— 做辅助标识(打勾、成交、钥匙交接)',
|
||||
exact: ['realtor', 'realty', 'mortgage', 'sold', 'for-sale', 'forsale', 'sale', 'sell', 'rent',
|
||||
'rental', 'lease', 'agent', 'broker', 'handshake', 'appraisal', 'valuation', 'house-search',
|
||||
'home-search', 'property-search']
|
||||
},
|
||||
{
|
||||
key: 'brand',
|
||||
label: '品牌标志',
|
||||
desc: '房产 / 家居类知名品牌 Logo(仅作参考,有商标风险,勿直接商用)',
|
||||
exact: ['zillow', 'trulia', 'redfin', 'realtor', 'rightmove', 'airbnb', 'booking', 'trivago',
|
||||
'homeadvisor', 'houzz', 'idealista', 'immobiliare', 'century21', 'remax', 'coldwell',
|
||||
'keller-williams', 'opendoor', 'offerpad', 'homelight', 'ikea', 'wayfair', 'homedepot',
|
||||
'lowes', 'leroy-merlin', 'castorama', 'homify', 'nest', 'smart-home', 'matter', 'zigbee']
|
||||
}
|
||||
];
|
||||
|
||||
const TIER_INDEX = TIERS.map(t => ({ key: t.key, re: new RegExp('^(?:' + t.exact.join('|') + ')$', 'i') }));
|
||||
|
||||
const NEG_RE = /(^|-)(off|offline|alert|error|forbidden|blocked|cancel|remove|delete|minus|slash|no|not|without|broken|warning|question|unavailable|unlock|open|add|plus|edit|download|upload|share|upload|export|import|send|edit|list|grid|check|search|refresh|sync|cog|settings|gear|service|tools?|repair)\b/i;
|
||||
|
||||
// 「home-*」「house-*」这类带前缀的语义噪音(智能家居/家务类,与建筑无关)
|
||||
const NOISE_RE = /^(home|house)-(automation|assistant|assistantd|automation|wifi|pod|speaker|audio|appliance|appliances|theater|theatre|workout|button|sensor|monitor|screen|security|enterprise|assistant|hub|bridge|energy|battery|charging|charging-station|filming|sound|video|camera|lightning|lightning-bolt|thermometer|thermostat|music|movie|content|connect|network|server|storage|data|dns|domain|page|pages|work|study|education|learning|teaching|doctor|hospital|clinic|care|nurse|service|services|device|devices|interface|account|accounts|bank|banking|credit|loan|insurance|tax|budget|invoice|wallet|money|finance|business|office|work|cooking|cook|meal|food|chef|recipe|cleaning|clean|laundry|chores|chore|garden|plant|plants|watering|irrigation|pet|baby|kids|child|elderly|health|fitness|gym|yoga|sleep|snooze|reminder|schedule|calendar|clock|time|timer|alarm|doorbell|doorbell|lock|locker|key|keyboard|link|links|tilt|tilted|indicator|realtime|runtime|runtime)$/i;
|
||||
|
||||
function tierOf(name) {
|
||||
const norm = name.toLowerCase().replace(/_/g, '-');
|
||||
if (NOISE_RE.test(norm)) return null;
|
||||
for (const t of TIER_INDEX) if (t.re.test(norm)) return t.key;
|
||||
const parts = norm.split(/[-_ ]+/).filter(Boolean);
|
||||
for (const p of parts) {
|
||||
for (const t of TIER_INDEX) if (t.re.test(p)) return t.key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const RE_NAME = /"name":"((?:[^"\\]|\\.)*)"/;
|
||||
const RE_SVG = /"svg":"((?:[^"\\]|\\.)*)"/;
|
||||
const RE_ID = /"id":"((?:[^"\\]|\\.)*)"/;
|
||||
const RE_TYPE = /"type":"((?:[^"\\]|\\.)*)"/;
|
||||
const RE_CAT = /"categoryCn":"((?:[^"\\]|\\.)*)"/;
|
||||
|
||||
const files = fs.readdirSync(SRC_DIR).filter(f => f.endsWith('.js') && !f.startsWith('dicebear-'));
|
||||
const raw_list = [];
|
||||
const seenSvg = new Map();
|
||||
|
||||
for (const f of files) {
|
||||
const id = f.replace(/\.js$/, '');
|
||||
const meta = metaById[id] || {};
|
||||
if (meta.workspaces && meta.workspaces.length && !meta.workspaces.includes('logo') && !meta.workspaces.includes('avatar')) continue;
|
||||
const raw = fs.readFileSync(path.join(SRC_DIR, f), 'utf8');
|
||||
const MARK = '{"id":"';
|
||||
let pos = 0;
|
||||
while (true) {
|
||||
const start = raw.indexOf(MARK, pos);
|
||||
if (start === -1) break;
|
||||
let end = raw.indexOf('},{"id":"', start);
|
||||
if (end === -1) end = raw.indexOf(']}', start);
|
||||
if (end === -1) end = raw.length;
|
||||
const objStr = raw.slice(start, end);
|
||||
pos = end;
|
||||
const nm = RE_NAME.exec(objStr);
|
||||
if (!nm) continue;
|
||||
const name = nm[1];
|
||||
const tier = tierOf(name);
|
||||
if (!tier) continue;
|
||||
const sm = RE_SVG.exec(objStr);
|
||||
if (!sm || sm[1].length < 20) continue;
|
||||
const svg = sm[1];
|
||||
if (seenSvg.has(svg)) continue;
|
||||
const im = RE_ID.exec(objStr);
|
||||
seenSvg.set(svg, 1);
|
||||
raw_list.push({
|
||||
id: im ? im[1] : '',
|
||||
name: name,
|
||||
tier: tier,
|
||||
source: id,
|
||||
sourceName: meta.nameCn || meta.name || id,
|
||||
type: (RE_TYPE.exec(objStr) || [, ''])[1] || meta.type || 'icon',
|
||||
license: meta.license || '',
|
||||
categoryCn: (RE_CAT.exec(objStr) || [, ''])[1],
|
||||
svg: svg.replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 精选:同名图标最多保留 N 个不同来源(保证风格可选、又不爆炸)──────
|
||||
const CORE = new Set(['house', 'home', 'building', 'real-estate', 'estate', 'property', 'apartment',
|
||||
'villa', 'cottage', 'cabin', 'mansion', 'townhouse', 'residence', 'residential', 'housing', 'flat',
|
||||
'condo', 'roof', 'door', 'window', 'gate', 'key', 'keyhole', 'lock', 'garage', 'mailbox', 'fence',
|
||||
'stairs', 'office', 'hotel', 'bank', 'school', 'hospital', 'warehouse', 'factory', 'shop', 'store',
|
||||
'city', 'tower', 'skyscraper', 'castle', 'church', 'landmark', 'architecture']);
|
||||
const MAX_PER_NAME_CORE = 16;
|
||||
const MAX_PER_NAME_OTHER = 4;
|
||||
// 精选硬上限(保证风格多样又不过量)
|
||||
const TIER_CAP = { house: 560, part: 320, building: 360, deal: 90, brand: 170 };
|
||||
const PER_SOURCE_CAP = 52;
|
||||
|
||||
// 常见主流图标集优先级(做 LOGO 时通常最干净的几何线性风格)
|
||||
const PREFERRED = ['lucide', 'lucide-lab', 'tabler', 'phosphor', 'material-symbols', 'material-symbols-light',
|
||||
'heroicons', 'mdi', 'mdi-light', 'remix', 'solar', 'mingcute', 'iconamoon', 'iconoir', 'hugeicons',
|
||||
'carbon', 'fluent', 'basil', 'uil', 'icon-park-outline', 'icon-park', 'feather', 'ionicons', 'octicon',
|
||||
'boxicons', 'bxs', 'bx', 'bootstrap', 'fa7-solid', 'fa7-regular', 'fluent-mdl2', 'antd', 'tdesign',
|
||||
'semi-icons', 'line-md', 'gravity-ui', 'mage', 'clarity', 'jam', 'proicons', 'prime', 'cuida',
|
||||
'pixelarticons', 'lets-icons', 'unjs', 'typicons', 'fontisto', 'foundation', 'devicon', 'streamline',
|
||||
'gis', 'maki', 'gameicons', 'openmoji', 'noto', 'twemoji', 'emojione', 'fluent-emoji',
|
||||
'simple-icons', 'cib', 'bxl', 'fa7-brands', 'logos', 'selfhst', 'svgl'];
|
||||
const PRANK = Object.fromEntries(PREFERRED.map((s, i) => [s, i]));
|
||||
|
||||
const byName = new Map();
|
||||
for (const it of raw_list) {
|
||||
if (NEG_RE.test(it.name)) continue;
|
||||
const k = it.tier + '|' + it.name.toLowerCase();
|
||||
if (!byName.has(k)) byName.set(k, []);
|
||||
byName.get(k).push(it);
|
||||
}
|
||||
|
||||
const staged = [];
|
||||
for (const [k, arr] of byName) {
|
||||
const name = k.split('|')[1];
|
||||
const max = CORE.has(name) ? MAX_PER_NAME_CORE : MAX_PER_NAME_OTHER;
|
||||
// 优先主流图标集 -> 单色线性 -> id 短
|
||||
arr.sort((a, b) => {
|
||||
const ap = PRANK[a.source] === undefined ? 999 : PRANK[a.source];
|
||||
const bp = PRANK[b.source] === undefined ? 999 : PRANK[b.source];
|
||||
if (ap !== bp) return ap - bp;
|
||||
if ((a.type !== 'icon') !== (b.type !== 'icon')) return (a.type !== 'icon') - (b.type !== 'icon');
|
||||
return a.id.length - b.id.length;
|
||||
});
|
||||
staged.push(...arr.slice(0, max));
|
||||
}
|
||||
|
||||
// 排序优先级:核心名精确命中 > 名字短 > 来源稳定
|
||||
staged.sort((a, b) => {
|
||||
const an = a.name.toLowerCase(), bn = b.name.toLowerCase();
|
||||
const ac = CORE.has(an) ? 0 : 1, bc = CORE.has(bn) ? 0 : 1;
|
||||
if (ac !== bc) return ac - bc;
|
||||
if (an.length !== bn.length) return an.length - bn.length;
|
||||
return a.source.localeCompare(b.source);
|
||||
});
|
||||
|
||||
const picked = [];
|
||||
const tierCount = {};
|
||||
const srcCount = {};
|
||||
for (const it of staged) {
|
||||
const cap = TIER_CAP[it.tier] || 200;
|
||||
if ((tierCount[it.tier] || 0) >= cap) continue;
|
||||
if ((srcCount[it.source] || 0) >= PER_SOURCE_CAP) continue;
|
||||
tierCount[it.tier] = (tierCount[it.tier] || 0) + 1;
|
||||
srcCount[it.source] = (srcCount[it.source] || 0) + 1;
|
||||
picked.push(it);
|
||||
}
|
||||
|
||||
// 详情字段瘦身
|
||||
const slim = picked.map(it => ({
|
||||
id: it.id, name: it.name, tier: it.tier, source: it.source, sourceName: it.sourceName,
|
||||
type: it.type, license: it.license, categoryCn: it.categoryCn, svg: it.svg
|
||||
}));
|
||||
|
||||
const stat = {};
|
||||
slim.forEach(i => stat[i.tier] = (stat[i.tier] || 0) + 1);
|
||||
const srcStat = {};
|
||||
slim.forEach(i => srcStat[i.source] = (srcStat[i.source] || 0) + 1);
|
||||
|
||||
const outFile = path.join(__dirname, 'house-icons.json');
|
||||
fs.writeFileSync(outFile, JSON.stringify({ total: slim.length, tiers: TIERS, list: slim }));
|
||||
console.log('原始命中(svg 去重后):', raw_list.length);
|
||||
console.log('精选后:', slim.length, JSON.stringify(stat));
|
||||
console.log('命中源数:', Object.keys(srcStat).length);
|
||||
console.log('输出:', outFile, (fs.statSync(outFile).size / 1024).toFixed(0) + 'KB');
|
||||
@@ -0,0 +1,14 @@
|
||||
// 临时脚本:提取 feather:pen-tool 图标 svg,输出到 _design-test/
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
|
||||
const code = fs.readFileSync('js/data/sources/feather.js', 'utf8');
|
||||
const sb = {};
|
||||
sb.window = sb; sb.self = sb; sb.globalThis = sb;
|
||||
vm.runInNewContext(code, sb, { filename: 'feather.js' });
|
||||
const icons = sb.window['HGB_Source_feather'].icons;
|
||||
const icon = icons.find(i => i.id === 'pen-tool' || i.name === 'pen-tool');
|
||||
if (!icon) { console.error('pen-tool 未找到'); process.exit(1); }
|
||||
console.log('id=' + icon.id + ' name=' + icon.name + ' categoryCn=' + (icon.categoryCn || ''));
|
||||
console.log('svg=' + icon.svg);
|
||||
fs.writeFileSync('_design-test/pen-tool-svg.txt', icon.svg, 'utf8');
|
||||
@@ -0,0 +1,67 @@
|
||||
// 临时脚本:按品牌意象关键词从图标数据文件提取候选(画个标 logo 测试用)
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OUT = '_design-test';
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync('js/data/manifest/icons.json', 'utf8'));
|
||||
const srcMap = {};
|
||||
manifest.sources.forEach(s => { srcMap[s.id] = s; });
|
||||
|
||||
// 候选源:线条主库 + 实心/多色特色
|
||||
const TARGETS = ['feather', 'lucide', 'tabler', 'bootstrap', 'remix', 'heroicons', 'iconoir', 'phosphor', 'fluent', 'fluent-mdl2'];
|
||||
|
||||
// 品牌意象词表:画个标 = 画(绘) + 标(标志)
|
||||
const THEMES = {
|
||||
design: ['pen', 'pencil', 'palette', 'brush', 'paint', 'edit', 'ruler', 'compass', 'layout', 'draft', 'design', 'sketch', 'ink'],
|
||||
brand: ['flag', 'tag', 'badge', 'bookmark', 'award', 'crown', 'star', 'sparkle', 'label', 'logo', 'target', 'certificate'],
|
||||
symbol: ['hexagon', 'diamond', 'triangle', 'shield', 'anchor', 'gem', 'bolt', 'zap', 'infinity', 'sun', 'moon', 'leaf', 'flower'],
|
||||
tech: ['rocket', 'cpu', 'cloud', 'code', 'terminal', 'wifi', 'database', 'globe'],
|
||||
commerce: ['bag', 'cart', 'coin', 'wallet', 'briefcase', 'chart', 'store', 'gift'],
|
||||
misc: ['key', 'lock', 'mail', 'phone', 'calendar', 'clock', 'home', 'book', 'heart', 'camera', 'map', 'plane']
|
||||
};
|
||||
|
||||
function loadSource(sid) {
|
||||
const file = path.join(ROOT, 'js/data/sources', sid + '.js');
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const code = fs.readFileSync(file, 'utf8');
|
||||
const sb = {};
|
||||
sb.window = sb; sb.self = sb; sb.globalThis = sb;
|
||||
vm.runInNewContext(code, sb, { filename: sid + '.js' });
|
||||
const src = sb.window['HGB_Source_' + sid];
|
||||
return src ? src.icons : null;
|
||||
}
|
||||
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const sid of TARGETS) {
|
||||
const cfg = srcMap[sid];
|
||||
if (!cfg) continue;
|
||||
const icons = loadSource(sid);
|
||||
if (!icons) { console.log('SKIP(无数据文件): ' + sid); continue; }
|
||||
for (const [theme, kws] of Object.entries(THEMES)) {
|
||||
for (const kw of kws) {
|
||||
let hits = icons.filter(ic => (ic.name || '').toLowerCase().includes(kw) && !seen.has(sid + ':' + ic.id));
|
||||
hits = hits.slice(0, 1); // 每源每词取 1 个,控制候选量
|
||||
for (const h of hits) {
|
||||
seen.add(sid + ':' + h.id);
|
||||
out.push({
|
||||
theme, source: sid, id: h.id, name: h.name,
|
||||
categoryCn: h.categoryCn || '', type: h.type || cfg.type,
|
||||
svg: h.svg, license: cfg.license, licenseNote: cfg.licenseNote,
|
||||
coordText: cfg.coordText, style: cfg.style || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` 加载 ${sid}(${cfg.count} 图标)→ 命中 ${out.filter(o => o.source === sid).length}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(OUT, 'candidates.json'), JSON.stringify(out, null, 1), 'utf8');
|
||||
console.log(`\n候选池共 ${out.length} 个图标 → ${OUT}/candidates.json`);
|
||||
const byTheme = {};
|
||||
out.forEach(o => { byTheme[o.theme] = (byTheme[o.theme] || 0) + 1; });
|
||||
console.log('按主题分布:' + JSON.stringify(byTheme));
|
||||
@@ -0,0 +1,79 @@
|
||||
// 临时脚本 v2:意象白名单 + 词边界匹配 + 黑名单过滤,重选候选池
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OUT = '_design-test';
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync('js/data/manifest/icons.json', 'utf8'));
|
||||
const srcMap = {};
|
||||
manifest.sources.forEach(s => { srcMap[s.id] = s; });
|
||||
|
||||
const TARGETS = ['feather', 'lucide', 'tabler', 'bootstrap', 'remix', 'heroicons', 'iconoir', 'phosphor'];
|
||||
|
||||
// 意象白名单:品牌感强的核心词(拆词匹配,词必须独立出现)
|
||||
const THEMES = {
|
||||
design: ['pencil', 'pen', 'palette', 'brush', 'paint', 'edit', 'ruler', 'compass', 'pen-tool', 'highlighter', 'marker', 'swatch', 'frame', 'crop', 'layers', 'grid', 'layout', 'design', 'sketch', 'ink', 'draft'],
|
||||
brand: ['flag', 'tag', 'badge', 'bookmark', 'award', 'crown', 'star', 'sparkle', 'label', 'target', 'certificate', 'ribbon', 'medal', 'trophy'],
|
||||
symbol: ['hexagon', 'diamond', 'triangle', 'shield', 'anchor', 'gem', 'bolt', 'zap', 'infinity', 'sun', 'moon', 'leaf', 'flower', 'mountain', 'wave', 'snowflake', 'crosshair', 'asterisk'],
|
||||
tech: ['rocket', 'cpu', 'cloud', 'code', 'terminal', 'wifi', 'database', 'globe', 'chip', 'robot', 'server', 'bug'],
|
||||
commerce: ['bag', 'cart', 'coin', 'wallet', 'briefcase', 'chart', 'store', 'gift', 'receipt', 'percent'],
|
||||
misc: ['key', 'lock', 'mail', 'phone', 'calendar', 'clock', 'home', 'book', 'heart', 'camera', 'map', 'plane', 'music', 'mic', 'coffee', 'cup']
|
||||
};
|
||||
// 黑名单:语义负面/错误/禁用类,绝不做 LOGO
|
||||
const BLACK = ['off', 'alert', 'error', 'warning', 'forbidden', 'ban', 'question', 'slash', 'offline', 'disconnected', 'exclamation', 'trash', 'remove', 'delete', 'block', 'stop'];
|
||||
|
||||
function words(name) {
|
||||
return (name || '').toLowerCase().split(/[-_ ]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function loadSource(sid) {
|
||||
const file = path.join(ROOT, 'js/data/sources', sid + '.js');
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const code = fs.readFileSync(file, 'utf8');
|
||||
const sb = {};
|
||||
sb.window = sb; sb.self = sb; sb.globalThis = sb;
|
||||
vm.runInNewContext(code, sb, { filename: sid + '.js' });
|
||||
const src = sb.window['HGB_Source_' + sid];
|
||||
return src ? src.icons : null;
|
||||
}
|
||||
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const sid of TARGETS) {
|
||||
const cfg = srcMap[sid];
|
||||
if (!cfg) continue;
|
||||
const icons = loadSource(sid);
|
||||
if (!icons) { console.log('SKIP: ' + sid); continue; }
|
||||
for (const [theme, kws] of Object.entries(THEMES)) {
|
||||
for (const kw of kws) {
|
||||
const kwWords = words(kw);
|
||||
const hits = icons.filter(ic => {
|
||||
const ws = words(ic.name);
|
||||
if (!ws.length) return false;
|
||||
if (ws.some(w => BLACK.includes(w))) return false;
|
||||
// 白名词须完全包含(词集合 ⊇ 白名词集合)
|
||||
return kwWords.every(w => ws.includes(w)) && !seen.has(sid + ':' + ic.id);
|
||||
});
|
||||
const pick = hits.slice(0, 1); // 每源每意象取 1 个,控制量
|
||||
for (const h of pick) {
|
||||
seen.add(sid + ':' + h.id);
|
||||
out.push({
|
||||
theme, source: sid, id: h.id, name: h.name,
|
||||
categoryCn: h.categoryCn || '', type: h.type || cfg.type,
|
||||
svg: h.svg, license: cfg.license, licenseNote: cfg.licenseNote,
|
||||
coordText: cfg.coordText, style: cfg.style || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` 加载 ${sid} → 命中 ${out.filter(o => o.source === sid).length}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(OUT, 'candidates2.json'), JSON.stringify(out, null, 1), 'utf8');
|
||||
console.log(`\n候选池 v2 共 ${out.length} 个 → candidates2.json`);
|
||||
const byTheme = {};
|
||||
out.forEach(o => { byTheme[o.theme] = (byTheme[o.theme] || 0) + 1; });
|
||||
console.log('主题分布:' + JSON.stringify(byTheme));
|
||||
@@ -0,0 +1,113 @@
|
||||
// 临时脚本:从候选池选 100 个生成「画个标」logo 设计稿 HTML
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const ROOT = process.cwd();
|
||||
const OUT = '_design-test';
|
||||
|
||||
const cands = JSON.parse(fs.readFileSync(path.join(OUT, 'candidates2.json'), 'utf8'));
|
||||
|
||||
// 主题配额(共 100)
|
||||
const QUOTA = { design: 25, brand: 20, symbol: 18, tech: 12, commerce: 10, misc: 15 };
|
||||
// 源优先级:经典线条在前,大库补充
|
||||
const SRC_ORDER = ['feather', 'lucide', 'heroicons', 'iconoir', 'tabler', 'bootstrap', 'remix', 'phosphor', 'fluent', 'fluent-mdl2'];
|
||||
|
||||
// 按主题分组
|
||||
const byTheme = {};
|
||||
cands.forEach(c => { (byTheme[c.theme] = byTheme[c.theme] || []).push(c); });
|
||||
|
||||
// 跨源轮选:每主题按源优先级轮流取,配额内
|
||||
const picked = [];
|
||||
for (const [theme, n] of Object.entries(QUOTA)) {
|
||||
const pool = byTheme[theme] || [];
|
||||
// 每源分组
|
||||
const perSrc = {};
|
||||
pool.forEach(c => { (perSrc[c.source] = perSrc[c.source] || []).push(c); });
|
||||
const rounds = Math.ceil(n / SRC_ORDER.length);
|
||||
let taken = 0;
|
||||
for (let r = 0; r < rounds && taken < n; r++) {
|
||||
for (const sid of SRC_ORDER) {
|
||||
const arr = perSrc[sid];
|
||||
if (arr && arr.length > r) {
|
||||
picked.push(arr[r]);
|
||||
taken++;
|
||||
if (taken >= n) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 配额未满则从剩余补
|
||||
if (taken < n) {
|
||||
const rest = pool.filter(c => !picked.includes(c));
|
||||
for (const c of rest) { if (taken >= n) break; picked.push(c); taken++; }
|
||||
}
|
||||
}
|
||||
console.log('选中 ' + picked.length + ' 个');
|
||||
const srcStat = {};
|
||||
picked.forEach(c => { srcStat[c.source] = (srcStat[c.source] || 0) + 1; });
|
||||
console.log('来源分布:' + JSON.stringify(srcStat));
|
||||
|
||||
// 品牌配色(12 组,深底白图标)
|
||||
const PALETTES = [
|
||||
['#FF5A5F', '#FF2D55'], ['#FF9500', '#E66A00'], ['#FFD60A', '#E6B800'],
|
||||
['#34C759', '#1F9D4F'], ['#00C7BE', '#009B94'], ['#0A84FF', '#0055D4'],
|
||||
['#5E5CE6', '#3B36C7'], ['#FF375F', '#D40035'], ['#AF52DE', '#8E2BC7'],
|
||||
['#30B0C7', '#1B7F94'], ['#8E8E93', '#48484A'], ['#111111', '#000000']
|
||||
];
|
||||
// 徽章形状轮换:圆 / 圆角方 / 方
|
||||
const SHAPES = ['round', 'squircle', 'square'];
|
||||
|
||||
let cards = '';
|
||||
picked.forEach((c, i) => {
|
||||
const [c1, c2] = PALETTES[i % PALETTES.length];
|
||||
const shape = SHAPES[i % SHAPES.length];
|
||||
const grad = `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`;
|
||||
const chip = `background:${c1};`;
|
||||
cards += `
|
||||
<div class="card" style="--grad:${grad};">
|
||||
<div class="badge ${shape}" style="background:var(--grad);">
|
||||
<svg viewBox="0 0 24 24" width="56" height="56" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">${c.svg}</svg>
|
||||
</div>
|
||||
<div class="name">画个标</div>
|
||||
<div class="en">DrawLogo</div>
|
||||
<div class="meta">
|
||||
<span class="tag" style="${chip}">${c.theme}</span>
|
||||
<span class="src" title="${c.source}:${c.id}">${c.source}:${c.id}</span>
|
||||
</div>
|
||||
<div class="lic">${c.license}${c.licenseNote ? ' · ' + c.licenseNote : ''}</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>画个标 · 100 个 LOGO 候选设计稿(外部调取测试)</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: "PingFang SC", "Microsoft YaHei", sans-serif; background: #0e0e12; color: #eee; padding: 32px; }
|
||||
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||
.sub { color: #888; font-size: 13px; margin-bottom: 24px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
|
||||
.card { background: #1a1a22; border: 1px solid #2a2a35; border-radius: 14px; padding: 18px 14px 14px; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||
.badge { width: 96px; height: 96px; display: flex; align-items: center; justify-content: center; margin-bottom: 12px; }
|
||||
.badge.round { border-radius: 50%; }
|
||||
.badge.squircle { border-radius: 26px; }
|
||||
.badge.square { border-radius: 6px; }
|
||||
.badge svg { display: block; }
|
||||
.name { font-size: 18px; font-weight: 700; letter-spacing: 2px; }
|
||||
.en { font-size: 11px; color: #777; letter-spacing: 3px; margin-top: 2px; }
|
||||
.meta { margin-top: 10px; display: flex; align-items: center; gap: 6px; }
|
||||
.tag { color: #fff; font-size: 10px; padding: 2px 8px; border-radius: 20px; }
|
||||
.src { font-size: 10px; color: #777; }
|
||||
.lic { font-size: 10px; color: #555; margin-top: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>画个标 · 100 个 LOGO 候选设计稿</h1>
|
||||
<div class="sub">数据来源:画个标共享图标库(manifest/icons.json)· 指令四「跨项目调取」测试 · 每稿含来源与协议标注</div>
|
||||
<div class="grid">${cards}</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
fs.writeFileSync(path.join(OUT, '100-logos.html'), html, 'utf8');
|
||||
fs.writeFileSync(path.join(OUT, 'picked.json'), JSON.stringify(picked.map(c => ({ theme: c.theme, source: c.source, id: c.id, name: c.name, license: c.license })), null, 1), 'utf8');
|
||||
console.log('✅ 生成 ' + OUT + '/100-logos.html(' + picked.length + ' 稿)');
|
||||
@@ -0,0 +1,178 @@
|
||||
// 采集可用字体 → _design-test/font-specimens.json
|
||||
// 来源:① 本地素材库 OFL 字体(可商用) ② 本机已安装系统字体(仅预览)
|
||||
// 说明:本机 Node / 浏览器均无外网,Google Fonts 无法拉取,故不含 Web 字体。
|
||||
// 用法: node _design-test/harvest-fonts.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// ── ① 本地素材库字体(js/data/posters/*.js,全部 OFL-1.1,可商用)──────
|
||||
const LOCAL = [
|
||||
{ id: 'league-spartan', family: 'League Spartan', cn: 'League Spartan · 几何无衬线', cat: '几何/无衬线', w: '700' },
|
||||
{ id: 'fontsource-montserrat', family: 'Montserrat', cn: 'Montserrat · 几何无衬线', cat: '几何/无衬线', w: '800' },
|
||||
{ id: 'fontsource-poppins', family: 'Poppins', cn: 'Poppins · 圆几何', cat: '几何/无衬线', w: '700' },
|
||||
{ id: 'fontsource-inter', family: 'Inter', cn: 'Inter · 界面无衬线', cat: '几何/无衬线', w: '700' },
|
||||
{ id: 'fontsource-space-grotesk', family: 'Space Grotesk', cn: 'Space Grotesk · 科技无衬线', cat: '科技/等宽', w: '700' },
|
||||
{ id: 'fontsource-cinzel', family: 'Cinzel', cn: 'Cinzel · 罗马大写碑体', cat: '衬线', w: '700' },
|
||||
{ id: 'fontsource-playfair', family: 'Playfair Display', cn: 'Playfair Display · 高对比衬线', cat: '衬线', w: '700' },
|
||||
{ id: 'fontsource-lora', family: 'Lora', cn: 'Lora · 书卷衬线', cat: '衬线', w: '700' },
|
||||
{ id: 'fontsource-oswald', family: 'Oswald', cn: 'Oswald · 窄体', cat: '窄体/粗黑', w: '500' },
|
||||
{ id: 'fontsource-bebas-neue', family: 'Bebas Neue', cn: 'Bebas Neue · 高瘦大写字', cat: '窄体/粗黑', w: '400' },
|
||||
{ id: 'fontsource-jetbrains-mono', family: 'JetBrains Mono', cn: 'JetBrains Mono · 等宽', cat: '科技/等宽', w: '700' },
|
||||
{ id: 'fontsource-caveat', family: 'Caveat', cn: 'Caveat · 手写', cat: '手写/笔刷', w: '700' }
|
||||
];
|
||||
|
||||
// ── ② 本机已安装字体(浏览器实测存在;仅作预览,商用需自行确认授权)────
|
||||
// [字体名, 分类, 一句话特点]
|
||||
const SYSTEM = [
|
||||
// 几何 / 无衬线
|
||||
['Helvetica', '几何/无衬线', '经典瑞士无衬线,Z 的中性标准'],
|
||||
['Arial', '几何/无衬线', '最通用的无衬线'],
|
||||
['Roboto', '几何/无衬线', 'Google 系统无衬线'],
|
||||
['Segoe UI', '几何/无衬线', 'Windows 系统无衬线'],
|
||||
['Tahoma', '几何/无衬线', '小字清晰的无衬线'],
|
||||
['Verdana', '几何/无衬线', '宽字距、易读'],
|
||||
['Trebuchet MS', '几何/无衬线', '带性格的旧金山体'],
|
||||
['Calibri', '几何/无衬线', '圆角现代无衬线'],
|
||||
['Candara', '几何/无衬线', '优雅的人文无衬线'],
|
||||
['Corbel', '几何/无衬线', '开阔的人文无衬线'],
|
||||
['Century Gothic', '几何/无衬线', '纯几何圆体'],
|
||||
['Gill Sans MT', '几何/无衬线', '英式人文无衬线'],
|
||||
['Franklin Gothic Book', '几何/无衬线', '美式哥特体(细)'],
|
||||
['Franklin Gothic Medium', '几何/无衬线', '美式哥特体(中)'],
|
||||
['Franklin Gothic Demi', '几何/无衬线', '美式哥特体(半粗)'],
|
||||
['Microsoft Sans Serif', '几何/无衬线', 'Windows 经典 UI 体'],
|
||||
['Tw Cen MT', '几何/无衬线', '二十世纪几何体'],
|
||||
['Berlin Sans FB', '几何/无衬线', '有切角性格的展示无衬线'],
|
||||
['Bahnschrift', '几何/无衬线', 'DIN 血统可变窄体'],
|
||||
['Bahnschrift SemiBold', '几何/无衬线', 'DIN 血统(半粗)'],
|
||||
['Arial Narrow', '窄体/粗黑', '压缩无衬线'],
|
||||
['Arial Black', '窄体/粗黑', '超重无衬线'],
|
||||
['Lucida Sans', '几何/无衬线', '宽圆无衬线'],
|
||||
['Lucida Sans Unicode', '几何/无衬线', '宽圆无衬线(大字符集)'],
|
||||
['Microsoft YaHei', '几何/无衬线', '微软雅黑(拉丁部分)'],
|
||||
// 窄体 / 粗黑 / 展示
|
||||
['Impact', '窄体/粗黑', '极致粗窄,Z 一坨重量'],
|
||||
['Haettenschweiler', '窄体/粗黑', '超窄粗体'],
|
||||
['Britannic Bold', '窄体/粗黑', '英式粗展示体'],
|
||||
['Agency FB', '窄体/粗黑', '宽方现代展示体'],
|
||||
['Wide Latin', '窄体/粗黑', '超宽衬线大字'],
|
||||
['Goudy Stout', '窄体/粗黑', '极重衬线展示体'],
|
||||
// 衬线
|
||||
['Times New Roman', '衬线', '最经典衬线'],
|
||||
['Georgia', '衬线', '屏幕衬线,笔画厚重'],
|
||||
['Garamond', '衬线', '法式古典衬线'],
|
||||
['Book Antiqua', '衬线', 'Palatino 血统'],
|
||||
['Bookman Old Style', '衬线', '厚重书本衬线'],
|
||||
['Cambria', '衬线', '屏幕阅读衬线'],
|
||||
['Constantia', '衬线', '人文衬线'],
|
||||
['Palatino Linotype', '衬线', '人文主义衬线'],
|
||||
['Century Schoolbook', '衬线', '教科书衬线'],
|
||||
['Century', '衬线', '古典几何衬线'],
|
||||
['Calisto MT', '衬线', '过渡期衬线'],
|
||||
['Bodoni MT', '衬线', '高对比 Didone,Z 斜画极细'],
|
||||
['Elephant', '衬线', '超重衬线'],
|
||||
['Engravers MT', '衬线', '刻版罗马大写'],
|
||||
['Goudy Old Style', '衬线', '老式衬线'],
|
||||
['High Tower Text', '衬线', '古籍感衬线'],
|
||||
['Modern No. 20', '衬线', 'Didone 复古'],
|
||||
['Perpetua', '衬线', ' Gill 的衬线体'],
|
||||
['Perpetua Titling MT', '衬线', '大写标题衬线'],
|
||||
['Poor Richard', '衬线', '美式老式衬线'],
|
||||
['Castellar', '衬线', '碑体大写(带衬脚)'],
|
||||
['Colonna MT', '衬线', '大写碑体'],
|
||||
['Imprint MT Shadow', '衬线', '带阴影的碑体'],
|
||||
['Niagara Engraved', '衬线', '镂空雕刻体'],
|
||||
['Sylfaen', '衬线', '乔治亚衬线'],
|
||||
['MS Gothic', '衬线', '日式黑体(拉丁窄)'],
|
||||
['Yu Gothic', '几何/无衬线', '日式无衬线'],
|
||||
// slab / 方正
|
||||
['Rockwell', 'slab/方正', '几何板衬线,Z 方脚'],
|
||||
['Rockwell Extra Bold', 'slab/方正', '超重板衬线'],
|
||||
['Cooper Black', 'slab/方正', '圆胖粗衬线'],
|
||||
// 装饰 / ArtDeco / 趣味
|
||||
['Broadway', '装饰/趣味', 'ArtDeco 舞台体'],
|
||||
['Showcard Gothic', '装饰/趣味', '手绘招牌体'],
|
||||
['Stencil', '装饰/趣味', '模板喷字'],
|
||||
['Onyx', '装饰/趣味', '超窄高对比'],
|
||||
['Playbill', '装饰/趣味', '西部木牌体'],
|
||||
['Algerian', '装饰/趣味', '异域装饰体'],
|
||||
['Snap ITC', '装饰/趣味', '弹跳趣味体'],
|
||||
['Juice ITC', '装饰/趣味', '手绘卡通体'],
|
||||
['Magneto', '装饰/趣味', 'ArtDeco 霓虹体'],
|
||||
['Jokerman', '装饰/趣味', '花哨趣味体'],
|
||||
['Curlz MT', '装饰/趣味', '卷曲花体'],
|
||||
['Chiller', '装饰/趣味', '惊悚手写体'],
|
||||
['Pristina', '装饰/趣味', '细笔装饰体'],
|
||||
['Harrington', '装饰/趣味', 'ArtDeco 装饰体'],
|
||||
['Forte', '装饰/趣味', '笔刷装饰体'],
|
||||
['Gigi', '装饰/趣味', '少女感装饰体'],
|
||||
['Maiandra GD', '装饰/趣味', '圆角人文展示体'],
|
||||
['Tempus Sans ITC', '装饰/趣味', '轻快展示体'],
|
||||
['Papyrus', '装饰/趣味', '莎草纸手写体'],
|
||||
['Old English Text MT', '装饰/趣味', '哥特黑体'],
|
||||
['Viner Hand ITC', '手写/笔刷', '硬笔手写'],
|
||||
// 手写 / 笔刷
|
||||
['Brush Script MT', '手写/笔刷', '经典笔刷体'],
|
||||
['Segoe Script', '手写/笔刷', '系统手写体'],
|
||||
['Segoe Print', '手写/笔刷', '系统手写印刷体'],
|
||||
['Freestyle Script', '手写/笔刷', '随性笔刷'],
|
||||
['French Script MT', '手写/笔刷', '法式连笔'],
|
||||
['Edwardian Script ITC', '手写/笔刷', '爱德华花体'],
|
||||
['Palace Script MT', '手写/笔刷', '宫廷花体'],
|
||||
['Script MT Bold', '手写/笔刷', '粗花体'],
|
||||
['Vladimir Script', '手写/笔刷', '自由花体'],
|
||||
['Bradley Hand ITC', '手写/笔刷', '手写便签体'],
|
||||
['Kristen ITC', '手写/笔刷', '儿童手写体'],
|
||||
['Lucida Handwriting', '手写/笔刷', '手写连笔'],
|
||||
['Kunstler Script', '手写/笔刷', '德式装饰花体'],
|
||||
['Ink Free', '手写/笔刷', '钢笔随手写'],
|
||||
['Parchment', '手写/笔刷', '羊皮纸手写'],
|
||||
['Vivaldi', '手写/笔刷', '斜体花体'],
|
||||
['Comic Sans MS', '手写/笔刷', '手绘马克笔'],
|
||||
// 等宽 / 科技
|
||||
['Consolas', '科技/等宽', '编程等宽'],
|
||||
['Cascadia Code', '科技/等宽', '微软编程等宽'],
|
||||
['Cascadia Mono', '科技/等宽', '微软等宽'],
|
||||
['Lucida Console', '科技/等宽', '经典等宽'],
|
||||
['Courier New', '科技/等宽', '打字机等宽']
|
||||
];
|
||||
|
||||
function pickLocalFile(files, want) {
|
||||
const has = (re) => files.find(f => re.test(f));
|
||||
return has(new RegExp('-latin-' + want + '-normal\\.woff2$'))
|
||||
|| has(/-latin-400-normal\.woff2$/)
|
||||
|| has(/-latin-\d+-normal\.woff2$/)
|
||||
|| has(/-latin-.*normal\.woff2$/)
|
||||
|| files.find(function (x) { return /\.woff2$/.test(x); }) || files[0];
|
||||
}
|
||||
|
||||
const out = [];
|
||||
let missing = 0;
|
||||
for (const f of LOCAL) {
|
||||
const p = path.join(ROOT, 'js', 'data', 'posters', f.id + '.js');
|
||||
if (!fs.existsSync(p)) { console.log(' ✗ 缺源文件', f.id); missing++; continue; }
|
||||
const t = fs.readFileSync(p, 'utf8');
|
||||
const files = [...t.matchAll(/"file":"posters\/[^"]+\/([^"]+\.(?:woff2|woff|ttf|otf))"/g)].map(m => m[1]);
|
||||
const latin = files.filter(x => /-latin-/.test(x));
|
||||
const pick = pickLocalFile(latin.length ? latin : files, f.w);
|
||||
if (!pick) { console.log(' ✗ 无 woff2', f.id); missing++; continue; }
|
||||
out.push({
|
||||
slug: f.id, family: f.family, cn: f.cn, cat: f.cat, weight: f.w,
|
||||
kind: 'ofl', license: 'OFL-1.1 · 可商用',
|
||||
fmt: (pick.split('.').pop() || 'woff2').toLowerCase(),
|
||||
src: '../js/data/posters/' + f.id + '/files/' + pick
|
||||
});
|
||||
console.log(' ✓', f.family, '->', pick);
|
||||
}
|
||||
for (const [family, cat, cn] of SYSTEM) {
|
||||
out.push({ slug: 'sys-' + family.toLowerCase().replace(/[^a-z0-9]+/g, '-'), family, cn, cat, weight: '', kind: 'system', license: '系统字体 · 商用需自行授权', src: '' });
|
||||
}
|
||||
console.log('本地 OFL 字体:', LOCAL.length - missing, '| 系统字体:', SYSTEM.length);
|
||||
|
||||
const cats = {};
|
||||
out.forEach(f => { cats[f.cat] = (cats[f.cat] || 0) + 1; });
|
||||
fs.writeFileSync(path.join(__dirname, 'font-specimens.json'),
|
||||
JSON.stringify({ total: out.length, cats, fonts: out }, null, 0));
|
||||
console.log('合计', out.length, '款 ·', JSON.stringify(cats));
|
||||
@@ -0,0 +1,62 @@
|
||||
# 甄房知识图谱健康检查(只读)
|
||||
# 检查项:孤立实体 / 悬空关系 / 前缀合规(不得出现 XiaoHu:)
|
||||
# 用法: python _design-test/kg-health.py [关键词]
|
||||
import os, re, sys, pymysql
|
||||
|
||||
ZF = r'e:\KF_7\ZhenOS-ZF'
|
||||
env = {}
|
||||
for ln in open(os.path.join(ZF, '.env.local'), encoding='utf-8'):
|
||||
m = re.match(r'^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$', ln)
|
||||
if m:
|
||||
env[m.group(1)] = m.group(2).strip().strip('"').strip("'")
|
||||
|
||||
conn = pymysql.connect(host='rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com', port=3306,
|
||||
user=env['DB_USERNAME'], password=env['DB_PASSWORD'],
|
||||
charset='utf8mb4', connect_timeout=12)
|
||||
cur = conn.cursor()
|
||||
cur.execute('USE zhenos_kg')
|
||||
|
||||
def one(sql):
|
||||
cur.execute(sql); return cur.fetchone()[0]
|
||||
|
||||
print('=== 甄房知识图谱健康检查 ===')
|
||||
print(' 实体总数 ', one('SELECT COUNT(*) FROM kg_entities'))
|
||||
print(' 关系总数 ', one('SELECT COUNT(*) FROM kg_relations'))
|
||||
print(' 孤立实体(无任何关系)', one(
|
||||
'SELECT COUNT(*) FROM kg_entities e WHERE NOT EXISTS('
|
||||
'SELECT 1 FROM kg_relations r WHERE r.from_entity=e.entity_name OR r.to_entity=e.entity_name)'))
|
||||
print(' 悬空关系 from ', one(
|
||||
'SELECT COUNT(*) FROM kg_relations WHERE from_entity NOT IN (SELECT entity_name FROM kg_entities)'))
|
||||
print(' 悬空关系 to ', one(
|
||||
'SELECT COUNT(*) FROM kg_relations WHERE to_entity NOT IN (SELECT entity_name FROM kg_entities)'))
|
||||
print(' 非 ZhenosSH: 前缀实体', one(
|
||||
"SELECT COUNT(*) FROM kg_entities WHERE entity_name NOT LIKE 'ZhenosSH:%'"))
|
||||
print(" 旧前缀 XiaoHu: ", one("SELECT COUNT(*) FROM kg_entities WHERE entity_name LIKE 'XiaoHu:%'"))
|
||||
print(' dev_knowledge 条数 ', one('SELECT COUNT(*) FROM dev_knowledge'))
|
||||
print(' 其中 source=AI_SYNC ', one("SELECT COUNT(*) FROM dev_knowledge WHERE source='AI_SYNC'"))
|
||||
print(' 实体中 AI_SYNC ', one("SELECT COUNT(*) FROM kg_entities WHERE source='AI_SYNC'"))
|
||||
print(' 关系中 AI_SYNC 模块 ', one("SELECT COUNT(*) FROM kg_relations WHERE module IN ('ruoyi-ui','zhenfang-uni')"))
|
||||
|
||||
cur.execute("SELECT entity_type, entity_name FROM kg_entities WHERE source='AI_SYNC' ORDER BY entity_type, entity_name")
|
||||
_rows = cur.fetchall()
|
||||
if _rows:
|
||||
print('\n source=AI_SYNC 的新增实体(%d 个):' % len(_rows))
|
||||
for t, n in _rows:
|
||||
print(' %-12s %s' % (t, n))
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
kw = sys.argv[1]
|
||||
cur.execute("SELECT entity_type, entity_name FROM kg_entities WHERE entity_name LIKE %s ORDER BY entity_type",
|
||||
('%' + kw + '%',))
|
||||
rows = cur.fetchall()
|
||||
print('\n 含「%s」的实体 %d 个:' % (kw, len(rows)))
|
||||
for t, n in rows:
|
||||
print(' %-14s %s' % (t, n))
|
||||
cur.execute("SELECT type, title FROM dev_knowledge WHERE title LIKE %s OR tags LIKE %s",
|
||||
('%' + kw + '%', '%' + kw + '%'))
|
||||
rows = cur.fetchall()
|
||||
print(' 含「%s」的知识条目 %d 条:' % (kw, len(rows)))
|
||||
for t, n in rows:
|
||||
print(' %-12s %s' % (t, n))
|
||||
|
||||
conn.close()
|
||||
@@ -0,0 +1,28 @@
|
||||
# 查看 zhenos_kg 中某实体的完整字段(只读)
|
||||
# 用法: python _design-test/kg-inspect.py "ZhenosSH:zhenfang-uni"
|
||||
import os, re, sys, pymysql
|
||||
|
||||
ZF = r'e:\KF_7\ZhenOS-ZF'
|
||||
env = {}
|
||||
for ln in open(os.path.join(ZF, '.env.local'), encoding='utf-8'):
|
||||
m = re.match(r'^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$', ln)
|
||||
if m:
|
||||
env[m.group(1)] = m.group(2).strip().strip('"').strip("'")
|
||||
|
||||
name = sys.argv[1] if len(sys.argv) > 1 else ''
|
||||
conn = pymysql.connect(host='rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com', port=3306,
|
||||
user=env['DB_USERNAME'], password=env['DB_PASSWORD'],
|
||||
charset='utf8mb4', connect_timeout=12)
|
||||
cur = conn.cursor()
|
||||
cur.execute('USE zhenos_kg')
|
||||
cur.execute('SHOW COLUMNS FROM kg_entities')
|
||||
cols = [c[0] for c in cur.fetchall()]
|
||||
cur.execute('SELECT * FROM kg_entities WHERE entity_name=%s', (name,))
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
print('未找到实体:', name)
|
||||
for r in rows:
|
||||
print('=' * 60)
|
||||
for c, v in zip(cols, r):
|
||||
print('%s: %s' % (c, v))
|
||||
conn.close()
|
||||
@@ -0,0 +1,64 @@
|
||||
# 只读探测甄房知识图谱库(zhenos_kg)——不做任何写入
|
||||
# 用法: python _design-test/kg-probe.py
|
||||
import os, re, sys, pymysql
|
||||
|
||||
ZF = r'e:\KF_7\ZhenOS-ZF'
|
||||
env = {}
|
||||
with open(os.path.join(ZF, '.env.local'), encoding='utf-8') as f:
|
||||
for ln in f:
|
||||
m = re.match(r'^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$', ln)
|
||||
if m:
|
||||
env[m.group(1)] = m.group(2).strip().strip('"').strip("'")
|
||||
|
||||
user = env.get('DB_USERNAME')
|
||||
pwd = env.get('DB_PASSWORD')
|
||||
if not user or not pwd:
|
||||
sys.exit('未在 .env.local 取到 DB_USERNAME / DB_PASSWORD')
|
||||
|
||||
HOST = 'rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com'
|
||||
PORT = 3306
|
||||
|
||||
try:
|
||||
conn = pymysql.connect(host=HOST, port=PORT, user=user, password=pwd, charset='utf8mb4',
|
||||
connect_timeout=12, read_timeout=30)
|
||||
except Exception as e:
|
||||
sys.exit('连接失败(可能是 RDS IP 白名单未放行本机):%s' % e)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("SHOW DATABASES")
|
||||
dbs = [r[0] for r in cur.fetchall()]
|
||||
print('可见数据库:', ', '.join(dbs))
|
||||
print('zhenos_kg 存在:', 'zhenos_kg' in dbs)
|
||||
if 'zhenos_kg' not in dbs:
|
||||
conn.close(); sys.exit(0)
|
||||
|
||||
cur.execute("USE zhenos_kg")
|
||||
cur.execute("SHOW TABLES")
|
||||
tabs = [r[0] for r in cur.fetchall()]
|
||||
print('表:', ', '.join(tabs))
|
||||
for t in ('kg_entities', 'kg_relations', 'dev_knowledge'):
|
||||
if t in tabs:
|
||||
cur.execute("SELECT COUNT(*) FROM `%s`" % t)
|
||||
print(' %-16s %d 行' % (t, cur.fetchone()[0]))
|
||||
|
||||
if 'kg_entities' in tabs:
|
||||
cur.execute("SELECT entity_type, COUNT(*) FROM kg_entities GROUP BY entity_type ORDER BY 2 DESC")
|
||||
print('实体类型分布:', ', '.join('%s=%d' % r for r in cur.fetchall()))
|
||||
cur.execute("SELECT COUNT(*) FROM kg_entities WHERE entity_name LIKE 'ZhenosSH:%%'")
|
||||
print("前缀 ZhenosSH: 实体数:", cur.fetchone()[0])
|
||||
cur.execute("SELECT COUNT(*) FROM kg_entities WHERE entity_name LIKE 'XiaoHu:%%'")
|
||||
print("前缀 XiaoHu: 实体数(应为 0):", cur.fetchone()[0])
|
||||
cur.execute("SELECT entity_name FROM kg_entities WHERE entity_name LIKE '%%ui%%' OR entity_name LIKE '%%样式%%' OR entity_name LIKE '%%主题%%' LIMIT 15")
|
||||
rows = cur.fetchall()
|
||||
print('与 UI/样式/主题 相关的已有实体:')
|
||||
for r in rows:
|
||||
print(' ', r[0])
|
||||
if not rows:
|
||||
print(' (无)')
|
||||
|
||||
if 'dev_knowledge' in tabs:
|
||||
cur.execute("SHOW COLUMNS FROM dev_knowledge")
|
||||
print('dev_knowledge 列:', ', '.join(c[0] for c in cur.fetchall()))
|
||||
|
||||
conn.close()
|
||||
print('\n只读探测完成,未做任何写入。')
|
||||
@@ -0,0 +1,60 @@
|
||||
# 读取 zhenos_kg 的三张表结构 + 既有样本,供生成符合约定的同步 SQL
|
||||
# 只读,不写入
|
||||
# 用法: python _design-test/kg-schema.py
|
||||
import os, re, pymysql
|
||||
|
||||
ZF = r'e:\KF_7\ZhenOS-ZF'
|
||||
env = {}
|
||||
with open(os.path.join(ZF, '.env.local'), encoding='utf-8') as f:
|
||||
for ln in f:
|
||||
m = re.match(r'^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$', ln)
|
||||
if m:
|
||||
env[m.group(1)] = m.group(2).strip().strip('"').strip("'")
|
||||
|
||||
conn = pymysql.connect(host='rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com', port=3306,
|
||||
user=env['DB_USERNAME'], password=env['DB_PASSWORD'],
|
||||
charset='utf8mb4', connect_timeout=12)
|
||||
cur = conn.cursor()
|
||||
cur.execute("USE zhenos_kg")
|
||||
|
||||
for t in ('kg_entities', 'kg_relations', 'dev_knowledge'):
|
||||
cur.execute("SHOW CREATE TABLE `%s`" % t)
|
||||
print('=' * 70)
|
||||
print(cur.fetchone()[1])
|
||||
|
||||
cur.execute("SELECT * FROM kg_entities WHERE entity_name='ZhenosSH:ruoyi-ui'")
|
||||
cur.execute("SHOW COLUMNS FROM kg_entities")
|
||||
cols = [c[0] for c in cur.fetchall()]
|
||||
cur.execute("SELECT * FROM kg_entities WHERE entity_name='ZhenosSH:ruoyi-ui'")
|
||||
row = cur.fetchone()
|
||||
print('=' * 70)
|
||||
print('样本实体 ZhenosSH:ruoyi-ui:')
|
||||
for c, v in zip(cols, row):
|
||||
s = str(v)
|
||||
print(' %-20s %s' % (c, s[:300]))
|
||||
|
||||
cur.execute("SELECT * FROM kg_entities WHERE entity_type='ConfigFile' LIMIT 2")
|
||||
print('-' * 70)
|
||||
for r in cur.fetchall():
|
||||
print(' ConfigFile 样本:', dict(zip(cols, [str(x)[:80] for x in r])))
|
||||
|
||||
cur.execute("SHOW COLUMNS FROM kg_relations")
|
||||
rcols = [c[0] for c in cur.fetchall()]
|
||||
cur.execute("SELECT * FROM kg_relations WHERE source_name LIKE 'ZhenosSH:ruoyi-ui%%' LIMIT 4")
|
||||
print('-' * 70)
|
||||
print('ruoyi-ui 的关系样本:')
|
||||
for r in cur.fetchall():
|
||||
print(' ', dict(zip(rcols, [str(x)[:60] for x in r])))
|
||||
|
||||
cur.execute("SHOW COLUMNS FROM dev_knowledge")
|
||||
kcols = [c[0] for c in cur.fetchall()]
|
||||
cur.execute("SELECT type,title,tags,entity_type,entity_name,module,priority,status,source FROM dev_knowledge WHERE type='pitfall' LIMIT 2")
|
||||
print('-' * 70)
|
||||
print('dev_knowledge pitfall 样本:')
|
||||
for r in cur.fetchall():
|
||||
print(' ', dict(zip(['type','title','tags','entity_type','entity_name','module','priority','status','source'],
|
||||
[str(x)[:90] for x in r])))
|
||||
cur.execute("SELECT DISTINCT type FROM dev_knowledge")
|
||||
print(' dev_knowledge 已有 type:', [r[0] for r in cur.fetchall()])
|
||||
|
||||
conn.close()
|
||||
@@ -0,0 +1,38 @@
|
||||
# 把 H5 tabbar 的「选中态」图标从旧品牌橙重着色为甄房交互蓝
|
||||
# 图标为单色 + alpha 通道,按 alpha 保留、RGB 整体替换即可无损改色
|
||||
# 用法: python _design-test/recolor-tabbar.py (预演)
|
||||
# python _design-test/recolor-tabbar.py --write (真写)
|
||||
import os, sys, collections
|
||||
from PIL import Image
|
||||
|
||||
D = r'e:\KF_7\ZhenOS-ZF\zhenfang-uni\src\static\tabbar'
|
||||
TARGET = (0x00, 0x70, 0xF2) # #0070F2 甄房交互蓝
|
||||
WRITE = '--write' in sys.argv
|
||||
|
||||
def dominant(im):
|
||||
c = collections.Counter()
|
||||
for px in im.getdata():
|
||||
if px[3] > 200:
|
||||
c[px[:3]] += 1
|
||||
return c.most_common(1)[0][0] if c else None
|
||||
|
||||
for f in sorted(os.listdir(D)):
|
||||
if not f.endswith('.png') or '_blue' not in f:
|
||||
continue
|
||||
p = os.path.join(D, f)
|
||||
im = Image.open(p).convert('RGBA')
|
||||
before = dominant(im)
|
||||
if before == TARGET:
|
||||
print(' %-22s 已是目标色,跳过' % f)
|
||||
continue
|
||||
px = im.load()
|
||||
for y in range(im.height):
|
||||
for x in range(im.width):
|
||||
r, g, b, a = px[x, y]
|
||||
px[x, y] = (TARGET[0], TARGET[1], TARGET[2], a)
|
||||
after = dominant(im)
|
||||
print(' %-22s #%02X%02X%02X -> #%02X%02X%02X' % (f, *before, *after))
|
||||
if WRITE:
|
||||
im.save(p)
|
||||
|
||||
print('\n' + ('已写入' if WRITE else '预演,未写入(加 --write 生效)'))
|
||||
@@ -0,0 +1,65 @@
|
||||
// 扫描 ZF/ruoyi-ui 中残留的小虎租房旧色值,输出「色值 -> 出现次数 / 文件数」清单
|
||||
// 仅统计,不修改任何文件
|
||||
// 用法: node _design-test/scan-old-colors.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = 'e:/KF_7/ZhenOS-ZF/ruoyi-ui/src';
|
||||
const EXT = new Set(['.vue', '.scss', '.css', '.js', '.ts']);
|
||||
|
||||
// 小虎租房旧色板(品牌橙 + 暖中性 + 语义 + 业务梯度)
|
||||
const CAND = new Set([
|
||||
'#ff7a1a', '#e86a0c', '#d96408', '#ff8a3d', '#ffc48a', '#ffe8d1', '#fff6ec', '#f6ead8',
|
||||
'#faf7f2', '#e9d3b0', '#efe7dc', '#8a7f73', '#c9beb0', '#2a2118', '#3a322a', '#756a5e',
|
||||
'#9a8e80', '#b8ac9e', '#2f2820', '#4a4036', '#ffb31a', '#fff6e0', '#d9481c', '#fdeee8',
|
||||
'#2e9e5b', '#ecf7f0', '#f08060', '#fbbf24', '#1f8a5b', '#fbf5ea', '#d3dce6', '#99a9bf'
|
||||
]);
|
||||
|
||||
const stats = new Map(); // hex -> { n, files:Set }
|
||||
const rgbStats = new Map();
|
||||
|
||||
function walk(dir) {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) { walk(p); continue; }
|
||||
if (!EXT.has(path.extname(e.name).toLowerCase())) continue;
|
||||
const txt = fs.readFileSync(p, 'utf8');
|
||||
const rel = path.relative(ROOT, p).replace(/\\/g, '/');
|
||||
// 十六进制
|
||||
const re = /#([0-9a-fA-F]{6})\b/g;
|
||||
let m;
|
||||
while ((m = re.exec(txt))) {
|
||||
const hex = '#' + m[1].toLowerCase();
|
||||
if (!CAND.has(hex)) continue;
|
||||
if (!stats.has(hex)) stats.set(hex, { n: 0, files: new Set() });
|
||||
const s = stats.get(hex); s.n++; s.files.add(rel);
|
||||
}
|
||||
// rgb()/rgba() 三元组
|
||||
const re2 = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/g;
|
||||
while ((m = re2.exec(txt))) {
|
||||
const key = [m[1], m[2], m[3]].join(',');
|
||||
const hex = '#' + [m[1], m[2], m[3]].map(v => (+v).toString(16).padStart(2, '0')).join('');
|
||||
if (!CAND.has(hex) && !['255,122,26', '255,179,26', '255,138,61'].includes(key)) continue;
|
||||
if (!rgbStats.has(key)) rgbStats.set(key, { n: 0, files: new Set(), hex });
|
||||
const s = rgbStats.get(key); s.n++; s.files.add(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(ROOT);
|
||||
|
||||
const rows = [...stats.entries()].map(([hex, s]) => ({ hex, n: s.n, f: s.files.size }))
|
||||
.sort((a, b) => b.n - a.n);
|
||||
const rgbRows = [...rgbStats.entries()].map(([k, s]) => ({ k, hex: s.hex, n: s.n, f: s.files.size }))
|
||||
.sort((a, b) => b.n - a.n);
|
||||
|
||||
console.log('=== 十六进制旧色值(' + rows.length + ' 种)===');
|
||||
let sum = 0, filesMax = 0;
|
||||
rows.forEach(r => { sum += r.n; filesMax = Math.max(filesMax, r.f); console.log(' ' + r.hex + ' ' + String(r.n).padStart(4) + ' 次 ' + String(r.f).padStart(3) + ' 文件'); });
|
||||
console.log(' 小计 ' + sum + ' 次');
|
||||
|
||||
console.log('\n=== rgb()/rgba() 旧三元组(' + rgbRows.length + ' 种)===');
|
||||
let sum2 = 0;
|
||||
rgbRows.forEach(r => { sum2 += r.n; console.log(' rgb(' + r.k + ') ' + String(r.n).padStart(4) + ' 次 ' + String(r.f).padStart(3) + ' 文件'); });
|
||||
console.log(' 小计 ' + sum2 + ' 次');
|
||||
|
||||
console.log('\n合计 ' + (sum + sum2) + ' 处');
|
||||
@@ -0,0 +1,44 @@
|
||||
// 临时静态服务器(仅用于本机预览 _design-test 产物)
|
||||
// 用法: node _design-test/serve.js [port]
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const PORT = Number(process.argv[2] || 55501);
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2'
|
||||
};
|
||||
|
||||
http.createServer((req, res) => {
|
||||
// 供浏览器栅格化后回传 PNG 落盘(仅允许写入 _design-test/logo/ 下)
|
||||
if (req.method === 'POST' && req.url.indexOf('/__save') === 0) {
|
||||
const rel = decodeURIComponent((req.url.split('path=')[1] || ''));
|
||||
const base = path.join(ROOT, '_design-test', 'logo');
|
||||
const dest = path.join(base, rel);
|
||||
if (!dest.startsWith(base)) { res.writeHead(403).end('forbidden'); return; }
|
||||
const chunks = [];
|
||||
req.on('data', c => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, Buffer.concat(chunks));
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('ok ' + rel + ' ' + Buffer.concat(chunks).length + 'B');
|
||||
});
|
||||
return;
|
||||
}
|
||||
let p = decodeURIComponent(req.url.split('?')[0]);
|
||||
if (p === '/') p = '/_design-test/zhenfang-logo-picker.html';
|
||||
let file = path.join(ROOT, p);
|
||||
if (!fs.existsSync(file) && fs.existsSync(path.join(ROOT, '_design-test', p))) {
|
||||
file = path.join(ROOT, '_design-test', p); // 允许直接用 /xxx.html 访问 _design-test 下的产物
|
||||
}
|
||||
if (!file.startsWith(ROOT)) { res.writeHead(403).end('forbidden'); return; }
|
||||
fs.readFile(file, (err, buf) => {
|
||||
if (err) { res.writeHead(404).end('not found: ' + p); return; }
|
||||
res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' });
|
||||
res.end(buf);
|
||||
});
|
||||
}).listen(PORT, () => console.log('serving ' + ROOT + ' at http://localhost:' + PORT + '/'));
|
||||
@@ -0,0 +1,849 @@
|
||||
// 甄房业务系统 · UI 规范与标准(定稿 v1.2)
|
||||
// 品牌资产(LOGO 定稿)+ SAP Fiori Morning Horizon(官方实测色值)+ 组件规范 + ElementUI 落地清单
|
||||
//
|
||||
// ⚠ 数据来源纪律:本文件所有 Fiori 色值均取自官方主题包实测,不做记忆引用。
|
||||
// npm @sap-theming/theming-base-content content/Base/baseLib/sap_horizon/css_variables.css
|
||||
// npm element-ui@2.15.14 packages/theme-chalk/src/common/var.scss
|
||||
// CSS 变量、令牌文件、规范表格三处均由下方 T 表统一生成,杜绝三处不一致。
|
||||
//
|
||||
// 用法: node _design-test/spec/build-spec.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const HERE = __dirname;
|
||||
const LOGO = path.join(HERE, '..', 'logo');
|
||||
const FZ = JSON.parse(fs.readFileSync(path.join(LOGO, 'final-z.json'), 'utf8'));
|
||||
const MS = JSON.parse(fs.readFileSync(path.join(LOGO, 'mark-scales.json'), 'utf8'));
|
||||
const PCT = {};
|
||||
MS.stages.forEach(s => { PCT[s.pct] = s; });
|
||||
const MAIN = PCT[80];
|
||||
|
||||
const H04 = 'M3 11.99v2.51c0 3.3 0 4.95 1.025 5.975S6.7 21.5 10 21.5h4c3.3 0 4.95 0 5.975-1.025S21 17.8 21 14.5v-2.51c0-1.682 0-2.522-.356-3.25s-1.02-1.244-2.346-2.276l-2-1.555C14.233 3.303 13.2 2.5 12 2.5s-2.233.803-4.298 2.409l-2 1.555C4.375 7.496 3.712 8.012 3.356 8.74S3 10.308 3 11.99';
|
||||
const FG = 'translate(5.3304 5.3294) scale(2.22222)';
|
||||
const zScale = s => 'translate(' + MS.center[0] + ' ' + MS.center[1] + ') scale(' + s.toFixed(4) +
|
||||
') translate(' + (-MS.center[0]) + ' ' + (-MS.center[1]) + ')';
|
||||
const mark = (pct, ink, size) =>
|
||||
'<svg viewBox="0 0 64 64" width="' + size + '" height="' + size + '">' +
|
||||
'<g transform="' + FG + '"><path d="' + H04 + '" fill="none" stroke="' + ink +
|
||||
'" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g>' +
|
||||
'<g transform="' + zScale(PCT[pct].scale) + '"><path d="' + FZ.final + '" fill="' + ink + '"/></g></svg>';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// T:全部设计令牌(唯一数据源)
|
||||
// v = 取值 src = 官方来源变量 / 「派生」
|
||||
// use = 用途 dev = 与官方的偏离说明(可选)
|
||||
// c = [底色, 门槛] 对比度检查(可选)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const GROUP = [
|
||||
['品牌层', '甄房自有色彩,用于标识与导航。绝不进入交互色 —— 界面交互一律用主色,标识一律用墨蓝', [
|
||||
['--zf-brand-ink', '#0C2340', '甄房自有', '品牌主色 · LOGO / 侧栏底 / 登录页底', ['#FFFFFF', 4.5]],
|
||||
['--zf-brand-ink-deep', '#071527', '甄房自有', '最深底 · 应用图标、启动图', null],
|
||||
['--zf-brand-gold', '#C6A15B', '甄房自有', '品牌点缀 · 选中指示条、关键数字,占比 ≤1%', null],
|
||||
['--zf-brand-gold-soft', '#E3CB99', '甄房自有', '金色浅阶 · 深底上的次级强调', null]
|
||||
]],
|
||||
['交互层', '主色取官方强调按钮底色;悬停加深一档;<b>按下态官方是「白底蓝字蓝框」</b>,不是更深的蓝', [
|
||||
['--zf-primary', '#0070F2', 'sapButton_Emphasized_Background', '主按钮底 · 关键图标', ['#FFFFFF', 4.5]],
|
||||
['--zf-primary-hover', '#0064D9', 'sapButton_Emphasized_Hover_Background', '主按钮悬停底', ['#FFFFFF', 4.5]],
|
||||
['--zf-primary-active-bg', '#FFFFFF', 'sapButton_Emphasized_Active_Background', '主按钮按下底(官方为白)', null],
|
||||
['--zf-primary-active-ink', '#0064D9', 'sapButton_Emphasized_Active_TextColor', '主按钮按下时的字与框色', ['#FFFFFF', 4.5]],
|
||||
['--zf-link', '#0064D9', 'sapLinkColor', '链接色(官方链接比主色深一档,两者不同色)', ['#FFFFFF', 4.5]]
|
||||
]],
|
||||
['表面层', '列表/表格悬停是<b>中性灰</b>,选中才是浅蓝 —— 与「悬停也用蓝」的常见做法不同', [
|
||||
['--zf-bg-base', '#FFFFFF', 'sapList_Background', '卡片、表格、内容容器底', null],
|
||||
['--zf-bg-sub', '#F5F6F7', 'sapBackgroundColor', '页面底 · 表头 · 斑马纹(sapList_AlternatingBackground 同值)', null],
|
||||
['--zf-bg-hover', '#EAECEE', 'sapList_Hover_Background', '行/项悬停底(中性灰)', null],
|
||||
['--zf-bg-active', '#DEE2E5', 'sapList_Active_Background', '行/项按下底', null],
|
||||
['--zf-bg-selected', '#EBF8FF', 'sapList_SelectionBackgroundColor', '行/项选中底(极浅蓝)', null]
|
||||
]],
|
||||
['边框', '分两类:<b>功能性描边</b>(输入框,深色保证可辨识)与<b>分隔线</b>(表格卡片,浅色)', [
|
||||
['--zf-border', '#E5E5E5', 'sapList_BorderColor', '分隔线 · 表格线 · 卡片边框', null],
|
||||
['--zf-border-field', '#556B81', 'sapField_BorderColor', '输入框/下拉/日期等控件描边(功能性,需 ≥3:1)', ['#FFFFFF', 3]],
|
||||
['--zf-border-strong', '#758CA4', 'sapContent_ForegroundBorderColor', '强分隔 · 未选中控件 · 分组边界', ['#FFFFFF', 3]]
|
||||
]],
|
||||
['文字', '白底上能过 AA 4.5:1 的灰最深只到 #5F7590,所以二级与三级灰必然接近 —— 拉开层级要靠字号字重,不是继续变浅', [
|
||||
['--zf-text-1', '#131E29', 'sapTextColor', '一级文字 · 标题、正文、数字(同时也是 sapField_TextColor)', ['#FFFFFF', 4.5]],
|
||||
['--zf-text-2', '#556B82', 'sapContent_LabelColor', '二级文字 · 表单标签、表头、说明', ['#FFFFFF', 4.5]],
|
||||
['--zf-text-3', '#5F7590', '派生', '三级文字 · 单位、时间戳、辅助信息', ['#FFFFFF', 4.5],
|
||||
'官方 Neutral #788FA6 白底仅 3.34:1,不能用于正文级小字;此值按 4.5:1 上限反推'],
|
||||
['--zf-text-placeholder', '#5F7590', '派生(官方为 #556B82)', '占位符 · 禁用态文字', ['#FFFFFF', 4.5],
|
||||
'官方占位符与二级文字同色,会让人误以为已填内容;这里刻意浅一档以示区别'],
|
||||
['--zf-text-inverse', '#FFFFFF', 'sapContent_ContrastTextColor', '深底上的文字', ['#0C2340', 4.5]],
|
||||
['--zf-text-inverse-2', '#BBC1CA', '派生(rgba(255,255,255,.72) 在 #0C2340 上的合成值)',
|
||||
'深底上的二级文字 · 侧栏未选中项', ['#0C2340', 4.5]]
|
||||
]],
|
||||
['语义色', '一律「描边 + 浅底」,不用实心高饱和色块。<b>只有成功/错误能直接当正文色</b>,警告与中性只能作图形', [
|
||||
['--zf-success', '#256F3A', 'sapPositiveColor', '成功 · 有效 · 已完成(正文可用)', ['#FFFFFF', 4.5]],
|
||||
['--zf-error', '#AA0808', 'sapNegativeColor', '错误 · 已下架 · 危险操作(正文可用)', ['#FFFFFF', 4.5]],
|
||||
['--zf-warning', '#E76500', 'sapCriticalColor', '警告 · 维护中 · 待处理(仅描边/底色,正文需配深色字)', ['#FFFFFF', 3]],
|
||||
['--zf-info', '#0070F2', 'sapInformativeColor', '信息提示(同主色)', ['#FFFFFF', 4.5]],
|
||||
['--zf-neutral', '#788FA6', 'sapNeutralColor', '中性 · 仅图形与 ≥18px 大字,禁用于正文', ['#FFFFFF', 3]]
|
||||
]],
|
||||
['形状与层级', '圆角全部来自官方 rem 值(根字号 16px);阴影官方未提供 UI 级令牌,为派生值', [
|
||||
['--zf-radius-s', '4px', 'sapField_BorderCornerRadius(.25rem)', '输入类控件 · 标签 · 复选框', null],
|
||||
['--zf-radius', '8px', 'sapButton_BorderCornerRadius(.5rem)', '按钮 · 卡片(基准)', null],
|
||||
['--zf-radius-l', '12px', 'sapElement_BorderCornerRadius(.75rem)', '弹窗 · 抽屉 · 大面积容器', null],
|
||||
['--zf-radius-tile', '16px', 'sapTile_BorderCornerRadius(1rem)', '大卡片 · 磁贴', null],
|
||||
['--zf-radius-pill', '999px', '派生', '状态标签 · 头像', null],
|
||||
['--zf-shadow-1', '0 1px 2px rgba(34,53,72,.08)', '派生', '卡片默认(官方无 UI 阴影令牌)', null],
|
||||
['--zf-shadow-2', '0 2px 8px rgba(34,53,72,.12)', '派生', '下拉 · 弹窗 · 浮层', null]
|
||||
]]
|
||||
];
|
||||
|
||||
// 展开成便于生成 CSS 的扁平表
|
||||
const TOKENS = [];
|
||||
GROUP.forEach(([g, desc, list]) => list.forEach(([n, v, src, use, c, dev]) =>
|
||||
TOKENS.push({ g, gdesc: desc, n, v, src, use, c, dev })));
|
||||
|
||||
// 字号阶梯(除 caption 外均来自官方 rem 值,根字号 16px)
|
||||
const TYPE = [
|
||||
['display', 24, 600, 1.35, 'sapFontHeader3Size(1.5rem)', '页面主标题'],
|
||||
['title', 20, 600, 1.4, 'sapFontHeader4Size(1.25rem)', '模块标题 · 抽屉标题'],
|
||||
['title-s', 16, 600, 1.45, 'sapFontHeader5Size / sapFontLargeSize(1rem)', '卡片标题 · 分区小标题'],
|
||||
['body', 14, 400, 1.6, 'sapFontSize(.875rem)', '正文基准 · 表格内容 · 表单输入值'],
|
||||
['body-s', 12, 400, 1.6, 'sapFontSmallSize(.75rem)', '次级说明 · 表格辅助行'],
|
||||
['caption', 11, 400, 1.5, '派生', '标签 · 单位 · 时间戳(不要再小)']
|
||||
];
|
||||
|
||||
const SPACE = [4, 8, 12, 16, 20, 24, 32, 40];
|
||||
|
||||
// 无障碍检查清单
|
||||
const A11Y = [
|
||||
['一级文字 / 白底', '#131E29', '#FFFFFF', 4.5],
|
||||
['二级文字 / 白底', '#556B82', '#FFFFFF', 4.5],
|
||||
['三级文字 / 白底', '#5F7590', '#FFFFFF', 4.5],
|
||||
['一级文字 / 次级底', '#131E29', '#F5F6F7', 4.5],
|
||||
['二级文字 / 次级底', '#556B82', '#F5F6F7', 4.5],
|
||||
['一级文字 / 选中底', '#131E29', '#EBF8FF', 4.5],
|
||||
['白字 / 交互主色(按钮)', '#FFFFFF', '#0070F2', 4.5],
|
||||
['白字 / 主色悬停', '#FFFFFF', '#0064D9', 4.5],
|
||||
['链接色 / 白底', '#0064D9', '#FFFFFF', 4.5],
|
||||
['成功 / 白底(正文)', '#256F3A', '#FFFFFF', 4.5],
|
||||
['错误 / 白底(正文)', '#AA0808', '#FFFFFF', 4.5],
|
||||
['警告 / 白底(仅图形)', '#E76500', '#FFFFFF', 3],
|
||||
['中性 / 白底(仅图形)', '#788FA6', '#FFFFFF', 3],
|
||||
['控件描边 / 白底', '#556B81', '#FFFFFF', 3],
|
||||
['白字 / 品牌墨蓝(侧栏)', '#FFFFFF', '#0C2340', 4.5],
|
||||
['侧栏二级字 / 品牌墨蓝', '#BBC1CA', '#0C2340', 4.5]
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ElementUI 2.15.14 落地清单
|
||||
// m = 必改(不改主题就是半成品) a = 自动派生(无需覆盖) d = 刻意偏离官方
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const ELMAP = [
|
||||
['$--color-primary', '--zf-primary', '#0070F2', 'm', '主色,ElementUI 全组件取此值'],
|
||||
['$--color-success', '--zf-success', '#256F3A', 'm', ''],
|
||||
['$--color-warning', '--zf-warning', '#E76500', 'm', '同时须改白字,见下方风险项'],
|
||||
['$--color-danger', '--zf-error', '#AA0808', 'm', '官方 Negative 色'],
|
||||
['$--color-info', '--zf-text-3', '#5F7590', 'd', '官方 Informative 与主色同值,若照搬会让「信息按钮」与「主按钮」无法区分,故退为中性灰'],
|
||||
['$--color-text-primary', '--zf-text-1', '#131E29', 'm', ''],
|
||||
['$--color-text-regular', '--zf-text-1', '#131E29', 'd', '官方正文只有一档 sapTextColor;ElementUI 分 primary/regular 两级,这里刻意拉平'],
|
||||
['$--color-text-secondary', '--zf-text-3', '#5F7590', 'm', '同时决定表头文字色'],
|
||||
['$--color-text-placeholder', '--zf-text-placeholder', '#5F7590', 'd', '官方与二级文字同色,会与已填内容混淆'],
|
||||
['$--border-color-base', '--zf-border-field', '#556B81', 'm', '功能性描边。官方输入框描边本就深,这是 Horizon 的高辨识度来源'],
|
||||
['$--border-color-light', '--zf-border', '#E5E5E5', 'm', ''],
|
||||
['$--border-color-lighter', '--zf-border', '#E5E5E5', 'm', '同时决定表格线'],
|
||||
['$--border-color-extra-light', '--zf-bg-active', '#DEE2E5', 'm', ''],
|
||||
['$--border-color-hover', '--zf-border-strong', '#758CA4', 'm', ''],
|
||||
['$--background-color-base', '--zf-bg-sub', '#F5F6F7', 'm', '同时是表格悬停的默认值,必须连带覆盖下一行'],
|
||||
['$--border-radius-base', '--zf-radius', '8px', 'm', '官方按钮圆角 .5rem'],
|
||||
['$--border-radius-small', '--zf-radius-s', '4px', 'm', '官方输入类圆角 .25rem'],
|
||||
['$--button-border-radius', '--zf-radius', '8px', 'm', '自动继承 base,写出只为明确'],
|
||||
['$--font-size-base', '--zf-fs-body', '14px', 'm', '官方 sapFontSize = .875rem,与 ElementUI 默认一致,<b>无需改动</b>'],
|
||||
['$--font-size-small', '--zf-fs-body-s', '12px', 'd', '官方小号 = .75rem。由 ElementUI 默认的 13px 降到 12px,会影响部分组件,需目测回归'],
|
||||
['$--font-size-extra-small', '--zf-fs-body-s', '12px', 'm', ''],
|
||||
['$--font-size-medium', '--zf-fs-title-s', '16px', 'm', ''],
|
||||
['$--font-size-large', '--zf-fs-title', '20px', 'd', '官方 Header4 = 1.25rem。由 ElementUI 默认 18px 提到 20px,会改变标题观感'],
|
||||
['$--font-size-extra-large', '--zf-fs-display', '24px', 'm', '官方 Header3 = 1.5rem'],
|
||||
['$--table-border-color', '--zf-border', '#E5E5E5', 'm', ''],
|
||||
['$--table-font-color', '--zf-text-1', '#131E29', 'm', '默认继承 text-regular,已同上'],
|
||||
['$--table-header-font-color', '--zf-text-3', '#5F7590', 'm', '默认继承 text-secondary,已同上'],
|
||||
['$--table-header-background-color', '--zf-bg-sub', '#F5F6F7', 'm', '<b>必改</b>:默认是纯白,不改则表头与内容无法区分'],
|
||||
['$--table-row-hover-background-color', '--zf-bg-hover', '#EAECEE', 'm', '<b>必改</b>:默认继承 background-color-base,不改则悬停变灰底而非中性灰阶'],
|
||||
['$--table-current-row-background-color', '--zf-bg-selected', '#EBF8FF', 'm', '默认由 color-primary-light-9 派生,显式写死更可控'],
|
||||
['$--box-shadow-base', '--zf-shadow-2', '0 2px 8px rgba(34,53,72,.12)', 'm', ''],
|
||||
['$--menu-background-color', '--zf-brand-ink', '#0C2340', 'm', '深色侧栏底色'],
|
||||
['$--color-primary-light-1 … -9', '—', '自动派生', 'a', '由 <code>mix($--color-white, $--color-primary, N*10%)</code> 基于覆盖后的主色计算,<b>顺序正确即无需覆盖</b>:必须在 @import theme-chalk 之前定义 $--color-primary'],
|
||||
['$--color-success-light / -lighter 等 8 个', '—', '自动派生', 'a', '同上,按语义色 mix 得到'],
|
||||
['$--table-border', '—', '自动派生', 'a', '= 1px solid $--table-border-color'],
|
||||
['$--button-small-border-radius / -mini-border-radius', '—', '自动派生', 'a', '= $--border-radius-base - 1 = 7px,官方无对应值,如需一致请显式设为 6px']
|
||||
];
|
||||
|
||||
// 落地风险项(必须人工处理,光改变量解决不了)
|
||||
const RISKS = [
|
||||
['警告按钮白字不达标', '#E76500 底 + 白字实测只有 3.36:1,连正文 4.5:1 都不到(官方 Fiori 也没有橙底白字的实心按钮)。两条出路:① 橙色只用于描边/文字按钮;② 保留实心橙底但把 <code>$--button-warning-font-color</code> 改成深色 <code>#131E29</code>(对 #E76500 为 4.6:1)'],
|
||||
['按钮悬停方向与官方相反', 'ElementUI 的主按钮悬停 = <code>mix(白, 主色, 20%)</code> 即<b>变浅</b>,按下 = <code>mix(黑, 主色, 10%)</code> 变深;而 Fiori 是悬停 <b>#0064D9 变深</b>、按下变白底蓝框。二者不可兼得:<br>· 若照 ElementUI 走,只需改色,但规范里「悬停 #0064D9」不成立,按钮观感偏「淡」;<br>· 若照 Fiori 走,必须给 <code>.el-button--primary</code> 写自定义 hover/active 样式(变量层面做不到)。<br><b>建议按 Fiori 走并写自定义样式</b>,否则主按钮的悬停反馈会与全站其余悬停(深一档)不一致'],
|
||||
['深色侧栏变量不够用', 'ElementUI 只暴露 4 个菜单变量(<code>$--menu-background-color</code>、<code>$--menu-item-font-color</code>、<code>$--menu-item-hover-fill</code>、<code>$--menu-item-font-size</code>)。改成墨蓝底后至少还要把 <code>$--menu-item-font-color</code> 设为白色(否则默认 #131E29 在墨蓝上几乎看不见),且子菜单底色、选中态仍无变量可用,必须补自定义 CSS'],
|
||||
['表格只有一种边框色', 'ElementUI 全表共用 <code>$--table-border-color</code>,做不出「外框深、内线浅」的两级分隔。这不是配置问题,要两级就必须写自定义样式']
|
||||
];
|
||||
|
||||
// 项目现有主题文件里已经失效的变量(实测 ElementUI 2.15.14 中不存在)
|
||||
const DEADVARS = [
|
||||
['$--color-primary-dark-2', 'Element Plus 才有此变量;ElementUI 的按下态由 <code>mix(黑, 主色, 10%)</code> 算出'],
|
||||
['$--border-radius-round', '不存在;ElementUI 只有 <code>$--border-radius-circle: 100%</code>'],
|
||||
['$--table-header-bg', '不存在;正确名为 <code>$--table-header-background-color</code>'],
|
||||
['$--table-row-hover-bg', '不存在;正确名为 <code>$--table-row-hover-background-color</code>'],
|
||||
['$--table-current-row-bg', '不存在;正确名为 <code>$--table-current-row-background-color</code>']
|
||||
];
|
||||
|
||||
// 图表分类色板(v1.2 新增)—— ECharts 多序列用,色相拉开 + 明度错位,相邻序差异最大
|
||||
const CHART = [
|
||||
['#0057D2', '蓝', 'sapShell_Category_1(主色系,与品牌呼应)'],
|
||||
['#E76500', '橙', 'sapCriticalColor'],
|
||||
['#188918', '绿', 'sapShell_Category_8'],
|
||||
['#DF1278', '洋红', 'sapShell_Category_2'],
|
||||
['#07838F', '青', 'sapShell_Category_6'],
|
||||
['#D27700', '琥珀', 'sapAccentColor1'],
|
||||
['#7858FF', '紫', 'sapShell_Category_12'],
|
||||
['#D20A0A', '红', 'sapShell_Category_11'],
|
||||
['#5B738B', '蓝灰', 'sapShell_Category_10(中性序列)'],
|
||||
['#C6A15B', '品牌金', '甄房品牌色']
|
||||
];
|
||||
|
||||
// 业务语义色(v1.2 定稿)
|
||||
const BIZ = [
|
||||
['房源等级 A(优质)', '#C6A15B', '品牌金 —— 金色表达「优质/尊贵」,比红色更贴合甄房调性'],
|
||||
['房源等级 B(一般)', '#E76500', 'sapCriticalColor'],
|
||||
['房源等级 C(低质)', '#788FA6', 'sapNeutralColor'],
|
||||
['客源热度 H(热)', '#AA0808', 'sapNegativeColor —— 温度用红,与等级的质量语义区分开'],
|
||||
['客源热度 M(温)', '#E76500', 'sapCriticalColor'],
|
||||
['客源热度 L(冷)', '#788FA6', 'sapNeutralColor'],
|
||||
['跟进 · 3 天内', '#256F3A', 'sapPositiveColor'],
|
||||
['跟进 · 7 天内', '#0070F2', '品牌交互蓝'],
|
||||
['跟进 · 15 天内', '#E76500', 'sapCriticalColor'],
|
||||
['跟进 · 超 15 天', '#AA0808', 'sapNegativeColor']
|
||||
];
|
||||
|
||||
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// ── 由 TOKENS 生成 CSS :root ────────────────────────────────────
|
||||
const cssRoot = () => GROUP.map(([g, , list], i) =>
|
||||
' /* ── ' + g + ' ── */\n' + list.map(t => ' ' + t[0] + ': ' + t[1] + ';').join('\n')
|
||||
).join('\n\n');
|
||||
|
||||
const FS = [['caption', 11], ['body-s', 12], ['body', 14], ['title-s', 16], ['title', 20], ['display', 24]];
|
||||
const cssFontSizes = () => FS.map(([k, v]) => ' --zf-fs-' + k + ': ' + v + 'px;').join('\n');
|
||||
const cssSpace = () => SPACE.map((v, i) => ' --zf-sp-' + (i + 1) + ': ' + v + 'px;').join(' ');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 页面样式
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const CSS = `
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
${cssRoot()}
|
||||
|
||||
${cssFontSizes()}
|
||||
${cssSpace()}
|
||||
--zf-font:"72","PingFang SC","Microsoft YaHei","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
body{background:#0a0d13;color:#e9edf5;font-family:var(--zf-font);padding:40px 24px 90px;line-height:1.6}
|
||||
.wrap{max-width:1420px;margin:0 auto}
|
||||
h1{font-size:26px;letter-spacing:.2px}h1 b{color:#E3CB99}
|
||||
.hdr{border-bottom:1px solid rgba(255,255,255,.12);padding-bottom:18px;margin-bottom:6px}
|
||||
.meta{display:flex;gap:22px;flex-wrap:wrap;font-size:12px;color:#8b94a7;margin-top:12px}
|
||||
.meta b{color:#cfd6e4;font-weight:500}
|
||||
h2{font-size:19px;margin:48px 0 8px;padding-left:11px;border-left:4px solid #C6A15B}
|
||||
h2 em{font-size:11px;font-weight:400;color:#8b94a7;font-style:normal;margin-left:10px}
|
||||
h3{font-size:15px;margin:26px 0 10px;color:#E3CB99}
|
||||
p.note{font-size:12.5px;color:#8b94a7;margin:8px 0 16px;line-height:1.9}
|
||||
p.note b{color:#cfd6e4}
|
||||
code{font-family:Menlo,Consolas,monospace;font-size:11.5px}
|
||||
|
||||
table.doc{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:12px}
|
||||
table.doc th,table.doc td{border:1px solid rgba(255,255,255,.09);padding:8px 11px;text-align:left;vertical-align:top}
|
||||
table.doc th{background:rgba(255,255,255,.045);font-weight:600;color:#cfd6e4;font-size:11.5px;white-space:nowrap}
|
||||
table.doc td.k{color:#E3CB99;font-family:Menlo,Consolas,monospace;font-size:11.5px}
|
||||
table.doc td.v{font-family:Menlo,Consolas,monospace;font-size:11.5px;white-space:nowrap}
|
||||
table.doc td.s{font-family:Menlo,Consolas,monospace;font-size:10.5px;color:#8b94a7}
|
||||
table.doc td.u{color:#98a1b3}
|
||||
table.doc tr.hi td{background:rgba(94,224,138,.10)}
|
||||
table.doc tr.hi td:first-child{box-shadow:inset 3px 0 0 #5ee08a}
|
||||
.chip{display:inline-block;width:13px;height:13px;border-radius:4px;vertical-align:-2px;margin-right:7px;
|
||||
border:1px solid rgba(255,255,255,.22)}
|
||||
.pass,.fail{font-size:10px;padding:1px 7px;border-radius:20px;border:1px solid;white-space:nowrap}
|
||||
.pass{color:#5ee08a;border-color:rgba(94,224,138,.42)}
|
||||
.fail{color:#f0a55c;border-color:rgba(240,165,92,.45)}
|
||||
.tag2{display:inline-block;font-size:10px;padding:1px 7px;border-radius:20px;border:1px solid;white-space:nowrap}
|
||||
.tag2.m{color:#f0a55c;border-color:rgba(240,165,92,.5)}
|
||||
.tag2.d{color:#8ab4f8;border-color:rgba(138,180,248,.5)}
|
||||
.tag2.a{color:#8b94a7;border-color:rgba(139,148,167,.4)}
|
||||
|
||||
.stage{background:var(--zf-bg-base);color:var(--zf-text-1);border-radius:var(--zf-radius-l);
|
||||
border:1px solid rgba(255,255,255,.13);padding:22px;margin-top:14px;font-family:var(--zf-font)}
|
||||
.stage.dark{background:var(--zf-brand-ink);color:#fff}
|
||||
.stage.plain{background:var(--zf-bg-sub)}
|
||||
.row{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
|
||||
|
||||
.btn{font-family:inherit;font-size:var(--zf-fs-body);border-radius:var(--zf-radius);padding:7px 16px;
|
||||
border:1px solid var(--zf-border);background:var(--zf-bg-base);color:var(--zf-text-1);cursor:default;white-space:nowrap}
|
||||
.btn.pri{background:var(--zf-primary);border-color:var(--zf-primary);color:#fff}
|
||||
.btn.pri.h{background:var(--zf-primary-hover);border-color:var(--zf-primary-hover)}
|
||||
.btn.pri.a{background:var(--zf-primary-active-bg);border-color:var(--zf-primary-active-ink);color:var(--zf-primary-active-ink)}
|
||||
.btn.gho{border-color:var(--zf-primary);color:var(--zf-primary);background:transparent}
|
||||
.btn.tx{border-color:transparent;background:transparent;color:var(--zf-link);padding:7px 6px}
|
||||
.btn.dan{background:var(--zf-error);border-color:var(--zf-error);color:#fff}
|
||||
.btn.dis{background:var(--zf-bg-sub);border-color:var(--zf-border);color:var(--zf-text-placeholder)}
|
||||
.btn.sm{font-size:var(--zf-fs-body-s);padding:4px 11px}
|
||||
|
||||
.fld{display:flex;flex-direction:column;gap:5px}
|
||||
.fld label{font-size:var(--zf-fs-body-s);color:var(--zf-text-2)}
|
||||
.fld label .req{color:var(--zf-error);margin-left:3px}
|
||||
.fld input,.fld select{font-family:inherit;font-size:var(--zf-fs-body);color:var(--zf-text-1);
|
||||
background:var(--zf-bg-base);border:1px solid var(--zf-border-field);border-radius:var(--zf-radius-s);
|
||||
padding:8px 11px;min-width:190px}
|
||||
.fld input::placeholder{color:var(--zf-text-placeholder)}
|
||||
.fld input:focus,.fld select:focus{outline:none;border-color:var(--zf-primary);box-shadow:0 0 0 3px var(--zf-bg-selected)}
|
||||
.fld .hint{font-size:var(--zf-fs-caption);color:var(--zf-text-3)}
|
||||
.fld.err input{border-color:var(--zf-error)}
|
||||
.fld.err .hint{color:var(--zf-error)}
|
||||
.fld.dis input{background:var(--zf-bg-sub);color:var(--zf-text-placeholder);border-color:var(--zf-border)}
|
||||
|
||||
.tbl{width:100%;border-collapse:collapse;font-size:var(--zf-fs-body);font-variant-numeric:tabular-nums;color:var(--zf-text-1)}
|
||||
.tbl th{text-align:left;font-weight:600;font-size:var(--zf-fs-body-s);color:var(--zf-text-3);
|
||||
background:var(--zf-bg-sub);padding:9px 12px;border-bottom:1px solid var(--zf-border);white-space:nowrap}
|
||||
.tbl td{padding:9px 12px;border-bottom:1px solid var(--zf-border)}
|
||||
.tbl tbody tr:nth-child(even){background:var(--zf-bg-sub)}
|
||||
.tbl tbody tr.hov{background:var(--zf-bg-hover)}
|
||||
.tbl tbody tr.sel{background:var(--zf-bg-selected)}
|
||||
.tbl tbody tr.sel td:first-child{box-shadow:inset 3px 0 0 var(--zf-primary)}
|
||||
.tbl .lk{color:var(--zf-link)}
|
||||
.tbl .sub{display:block;font-size:var(--zf-fs-caption);color:var(--zf-text-3);margin-top:2px}
|
||||
.tbl .num{text-align:right}
|
||||
.tbl .act a{color:var(--zf-link);text-decoration:none;margin-right:10px;font-size:var(--zf-fs-body-s)}
|
||||
.tbl .act a.dan{color:var(--zf-error)}
|
||||
|
||||
.tg{display:inline-block;font-size:var(--zf-fs-caption);padding:2px 9px;border-radius:var(--zf-radius-pill);
|
||||
border:1px solid;white-space:nowrap;background:var(--zf-bg-base)}
|
||||
.tg.ok{color:var(--zf-success);border-color:var(--zf-success);background:#F1F7F3}
|
||||
.tg.wn{color:#B44F00;border-color:var(--zf-warning);background:#FDF1E8}
|
||||
.tg.er{color:var(--zf-error);border-color:var(--zf-error);background:#FBF0F0}
|
||||
.tg.pr{color:var(--zf-primary);border-color:var(--zf-primary);background:var(--zf-bg-selected)}
|
||||
.tg.nu{color:var(--zf-text-2);border-color:var(--zf-border-strong);background:var(--zf-bg-base)}
|
||||
.tg.gd{color:#8a6a2f;border-color:var(--zf-brand-gold);background:#FAF6EE}
|
||||
|
||||
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--zf-border)}
|
||||
.tabs a{font-size:var(--zf-fs-body);padding:9px 14px;color:var(--zf-text-2);text-decoration:none;border-bottom:2px solid transparent}
|
||||
.tabs a.on{color:var(--zf-primary);border-bottom-color:var(--zf-primary);font-weight:600}
|
||||
|
||||
.pg{min-width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;
|
||||
border:1px solid var(--zf-border);border-radius:var(--zf-radius-s);background:var(--zf-bg-base);
|
||||
color:var(--zf-text-1);font-size:var(--zf-fs-body-s);margin-right:6px}
|
||||
.pg.on{background:var(--zf-primary);border-color:var(--zf-primary);color:#fff;font-weight:600}
|
||||
|
||||
.al{display:flex;gap:9px;align-items:flex-start;font-size:var(--zf-fs-body);padding:11px 14px;
|
||||
border-radius:var(--zf-radius);border:1px solid;border-left-width:3px;color:var(--zf-text-1);margin-bottom:9px}
|
||||
.al b{font-weight:600}
|
||||
.al.i{border-color:var(--zf-primary);background:var(--zf-bg-selected)}
|
||||
.al.s{border-color:var(--zf-success);background:#F1F7F3}
|
||||
.al.w{border-color:var(--zf-warning);background:#FDF1E8}
|
||||
.al.e{border-color:var(--zf-error);background:#FBF0F0}
|
||||
|
||||
.card{background:var(--zf-bg-base);border:1px solid var(--zf-border);border-radius:var(--zf-radius);
|
||||
box-shadow:var(--zf-shadow-1);padding:16px}
|
||||
.kpi{background:var(--zf-bg-base);border:1px solid var(--zf-border);border-radius:var(--zf-radius);
|
||||
padding:13px 15px;box-shadow:var(--zf-shadow-1)}
|
||||
.kpi .t{font-size:var(--zf-fs-body-s);color:var(--zf-text-3)}
|
||||
.kpi .v{font-size:var(--zf-fs-display);font-weight:600;margin-top:4px;font-variant-numeric:tabular-nums}
|
||||
.kpi .v em{font-size:var(--zf-fs-body-s);font-style:normal;font-weight:400;color:var(--zf-text-3);margin-left:4px}
|
||||
.kpi .d{font-size:var(--zf-fs-caption);margin-top:3px;color:var(--zf-success)}
|
||||
.kpi .d.dn{color:var(--zf-error)}
|
||||
.kpi.gold .v{color:#8a6a2f}
|
||||
|
||||
.app{display:grid;grid-template-columns:200px 1fr;border:1px solid var(--zf-border);
|
||||
border-radius:var(--zf-radius-l);overflow:hidden;background:var(--zf-bg-base)}
|
||||
.app .side{background:var(--zf-brand-ink);padding:14px 0}
|
||||
.app .side .brand{display:flex;align-items:center;gap:10px;padding:2px 15px 16px;color:#fff}
|
||||
.app .side .brand b{font-size:var(--zf-fs-title-s);letter-spacing:1px}
|
||||
.app .side .brand em{display:block;font-size:8px;letter-spacing:1.4px;opacity:.6;font-style:normal}
|
||||
.app .side a{display:flex;align-items:center;gap:9px;color:var(--zf-text-inverse-2);text-decoration:none;
|
||||
font-size:var(--zf-fs-body);padding:9px 15px;border-left:3px solid transparent}
|
||||
.app .side a.on{background:rgba(255,255,255,.10);color:#fff;border-left-color:var(--zf-brand-gold);font-weight:600}
|
||||
.app .side a .dot{width:6px;height:6px;border-radius:2px;background:currentColor;opacity:.55}
|
||||
.app .main{min-width:0}
|
||||
.app .head{display:flex;align-items:center;gap:14px;padding:11px 18px;border-bottom:1px solid var(--zf-border)}
|
||||
.app .head .cb{font-size:var(--zf-fs-body);color:var(--zf-text-2)}
|
||||
.app .head .cb b{color:var(--zf-text-1)}
|
||||
.app .head .usr{margin-left:auto;display:flex;align-items:center;gap:9px;font-size:var(--zf-fs-body-s);color:var(--zf-text-2)}
|
||||
.app .head .av{width:26px;height:26px;border-radius:50%;background:var(--zf-bg-selected);
|
||||
color:var(--zf-primary);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600}
|
||||
.app .bd{padding:16px 18px;background:var(--zf-bg-sub)}
|
||||
|
||||
.st{border:1px dashed var(--zf-border-strong);border-radius:var(--zf-radius);padding:26px;
|
||||
text-align:center;color:var(--zf-text-3);font-size:var(--zf-fs-body)}
|
||||
.st .ic{font-size:26px;display:block;margin-bottom:9px;opacity:.55}
|
||||
.st b{display:block;color:var(--zf-text-2);font-size:var(--zf-fs-body);margin-bottom:5px;font-weight:600}
|
||||
.sk{background:var(--zf-bg-sub);border-radius:var(--zf-radius-s);height:13px;margin:7px 0}
|
||||
|
||||
.dont{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:12px;margin-top:12px}
|
||||
.dont div{background:rgba(240,92,92,.06);border:1px solid rgba(240,92,92,.3);border-radius:10px;
|
||||
padding:12px;text-align:center;font-size:11px;color:#f0a5a5}
|
||||
.dont div .pv{height:78px;display:flex;align-items:center;justify-content:center;background:#fff;
|
||||
border-radius:7px;margin-bottom:9px;overflow:hidden}
|
||||
.risk{background:rgba(240,165,92,.08);border:1px solid rgba(240,165,92,.32);border-radius:12px;
|
||||
padding:14px 16px;font-size:12.5px;line-height:1.95;color:#f2c48a;margin-top:10px}
|
||||
.risk b{color:#ffd9a8}
|
||||
.risk code{background:rgba(255,255,255,.07);padding:1px 5px;border-radius:4px}
|
||||
pre{background:#0d1220;border:1px solid rgba(255,255,255,.09);border-radius:11px;padding:14px 16px;
|
||||
font-size:11.5px;line-height:1.85;color:#cfd6e4;overflow-x:auto;margin-top:12px;font-family:Menlo,Consolas,monospace}
|
||||
a.dl{color:#8ab4f8}
|
||||
`;
|
||||
|
||||
// ── 令牌表 ──
|
||||
const tokenTables = GROUP.map(([g, desc, list]) => `
|
||||
<h3>${g}</h3>
|
||||
<p class="note" style="margin:0 0 4px">${desc}</p>
|
||||
<table class="doc"><thead><tr><th>令牌</th><th>取值</th><th>官方来源</th><th>用途</th><th>对比度</th></tr></thead><tbody>
|
||||
${list.map(([n, v, src, use, c, dev]) => `<tr>
|
||||
<td class="k">${n}</td>
|
||||
<td class="v"><span class="chip" style="background:${v}"></span>${v}</td>
|
||||
<td class="s">${src}</td>
|
||||
<td class="u">${use}${dev ? '<br><span style="color:#f0a55c">⚠ 偏离官方:' + dev + '</span>' : ''}</td>
|
||||
<td>${c ? '<span class="cc" data-fg="' + v + '" data-bg="' + c[0] + '" data-req="' + c[1] + '"></span>' : '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`).join('');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const html = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">
|
||||
<title>甄房业务系统 · UI 规范与标准(定稿 v1.2)</title>
|
||||
<style>${CSS}</style></head><body><div class="wrap">
|
||||
|
||||
<div class="hdr">
|
||||
<h1>甄房业务系统 · <b>UI 规范与标准</b></h1>
|
||||
<div class="meta">
|
||||
<span>版本 <b>v1.2(定稿)</b></span>
|
||||
<span>色彩体系 <b>SAP Fiori Morning Horizon</b></span>
|
||||
<span>LOGO <b>Forte Z + 甄房墨蓝 · Z 占比 80%</b></span>
|
||||
<span>UI 库 <b>element-ui 2.15.14(Vue 2.6)</b></span>
|
||||
<span>日期 <b>2026-09-19</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note" style="margin-top:18px">
|
||||
<b>本页是该项目的唯一权威 UI 标准。</b>任何界面实现以本页的令牌名与取值为准;不要直接写十六进制色值,不要引入第二套色彩。<br>
|
||||
<b>色值来源纪律</b>:全部 Fiori 色值取自官方主题包
|
||||
<code>@sap-theming/theming-base-content → sap_horizon/css_variables.css</code> 实测,ElementUI 变量名与默认值取自
|
||||
<code>element-ui@2.15.14 → theme-chalk/src/common/var.scss</code> 实测,逐条标注官方来源变量名,<b>无记忆引用</b>。
|
||||
对比度由页面运行时真实计算(WCAG 2.1)。
|
||||
</p>
|
||||
|
||||
<h2>一、品牌资产 · LOGO<em>已定稿</em></h2>
|
||||
<p class="note">主 logo 采用 <b>Z 在房框内 80% 档</b>;房框与描边粗细恒定,四档只缩放框内 Z。全部资产为矢量转曲路径。</p>
|
||||
<div class="stage">
|
||||
<div class="row" style="gap:26px">
|
||||
<div style="text-align:center">${mark(80, '#0C2340', 88)}<div style="font-size:11px;color:#8b94a7;margin-top:8px">主 logo · 浅底</div></div>
|
||||
<div style="text-align:center;background:#0C2340;padding:14px;border-radius:10px">${mark(80, '#FFFFFF', 88)}<div style="font-size:11px;color:rgba(255,255,255,.6);margin-top:8px">主 logo · 深底反白</div></div>
|
||||
<div style="text-align:center">${mark(80, '#C6A15B', 88)}<div style="font-size:11px;color:#8b94a7;margin-top:8px">金色点缀版(限定场景)</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>四档占比</h3>
|
||||
<table class="doc"><thead><tr><th>档位</th><th>Z 外接框(64 网格)</th><th>相对主 logo</th><th>用途</th></tr></thead><tbody>
|
||||
${MS.stages.map(s => {
|
||||
const use = { 90: '需要更强视觉重量时:小图标位、深色底上的白标(不要用于 32px 以下)',
|
||||
80: '<b>主 logo,一律默认用这一档</b>', 70: '大幅面(≥1200px)与印刷物料',
|
||||
60: '名片、信封、大留白版式' }[s.pct];
|
||||
return `<tr${s.pct === 80 ? ' class="hi"' : ''}>
|
||||
<td class="k">${s.pct}%${s.pct === 80 ? ' ★' : ''}</td>
|
||||
<td class="v">${s.size[0].toFixed(1)} × ${s.size[1].toFixed(1)}</td>
|
||||
<td class="v">×${s.vsMain.toFixed(2)}</td><td class="u">${use}</td></tr>`;
|
||||
}).join('')}
|
||||
</tbody></table>
|
||||
|
||||
<h3>规则</h3>
|
||||
<table class="doc"><thead><tr><th>项</th><th>标准</th></tr></thead><tbody>
|
||||
<tr><td class="k">配色</td><td class="u">标识只用 <b>#0C2340</b>(浅底)或 <b>#FFFFFF</b>(深底)。<b style="color:#f0a55c">严禁使用交互蓝 #0070F2 作为标识色</b> —— 否则用户无法区分「品牌标记」与「可点元素」。<br>注意:v1.1 起链接色已改为 #0064D9,标识色也<b>不要</b>用它</td></tr>
|
||||
<tr><td class="k">最小尺寸</td><td class="u">图标主形 ≥ 24px;横版锁定标 ≥ 120px 宽。16px 场景用 favicon 专用版,不要硬缩主形</td></tr>
|
||||
<tr><td class="k">安全留白</td><td class="u">四周留白 ≥ 图标高度的 25%(64 网格里留 16)</td></tr>
|
||||
<tr><td class="k">深色场景</td><td class="u">用反白版;不要在深底上用墨蓝正片</td></tr>
|
||||
<tr><td class="k">禁止</td><td class="u">拉伸变形 · 改比例 · 加描边/阴影/发光 · 换非品牌色 · 改动框与 Z 的相对大小 · 在花哨照片上直接压标</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<div class="dont">
|
||||
<div><div class="pv"><div style="transform:scaleX(1.5)">${mark(80, '#0C2340', 62)}</div></div>拉伸变形</div>
|
||||
<div><div class="pv"><div style="transform:rotate(-12deg)">${mark(80, '#0C2340', 62)}</div></div>旋转</div>
|
||||
<div><div class="pv">${mark(80, '#0070F2', 62)}</div>用交互蓝作标识色</div>
|
||||
<div><div class="pv"><div style="filter:drop-shadow(2px 2px 3px rgba(0,0,0,.55))">${mark(80, '#0C2340', 62)}</div></div>加阴影</div>
|
||||
<div><div class="pv">${mark(90, '#0C2340', 62)}</div>在 24px 以下用 90% 档(易糊线)</div>
|
||||
<div><div class="pv" style="background:#9aa4b8">${mark(80, '#0C2340', 62)}</div>在中间调灰底上用墨蓝</div>
|
||||
</div>
|
||||
|
||||
<h2>二、色彩系统<em>SAP Fiori Morning Horizon · 官方实测值</em></h2>
|
||||
<p class="note">
|
||||
核心策略:<b>品牌色与交互色分离</b>。甄房墨蓝 <b>#0C2340</b> 承担品牌与导航,Fiori 的 <b>#0070F2</b> 承担交互反馈。
|
||||
两者的明度差足够大 —— 墨蓝在界面里读作「近黑的中性色」,不会与亮蓝争夺注意力,也不会被误读为可点击元素。
|
||||
</p>
|
||||
${tokenTables}
|
||||
|
||||
<h3>用色配比</h3>
|
||||
<table class="doc"><thead><tr><th>层</th><th>占比</th><th>说明</th></tr></thead><tbody>
|
||||
<tr><td class="k">中性(白 / 灰)</td><td class="v">≈ 75%</td><td class="u">页面基底与分隔靠灰阶承担,这是数据密集型界面不累的前提</td></tr>
|
||||
<tr><td class="k">品牌墨蓝</td><td class="v">≈ 15%</td><td class="u">侧栏与页头;形成稳定视觉锚点,让导航边界一眼可辨</td></tr>
|
||||
<tr><td class="k">交互蓝</td><td class="v">≈ 7%</td><td class="u">主操作、链接、选中态。一屏内主操作建议 ≤ 1 个</td></tr>
|
||||
<tr><td class="k">语义色</td><td class="v">≈ 2%</td><td class="u">状态标签、提示条。一律描边+浅底,不用实心块</td></tr>
|
||||
<tr><td class="k">品牌金</td><td class="v">≤ 1%</td><td class="u">只放在「点」上:选中指示条、关键数字。铺面即俗</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<h2>三、字体与文字层级<em>官方 rem 值,根字号 16px</em></h2>
|
||||
<p class="note">
|
||||
字族:<code>--zf-font</code> = "72", "PingFang SC", "Microsoft YaHei", …("72" 为 Fiori 官方字族,未安装时回落系统中文黑体)。
|
||||
数字一律加 <code>font-variant-numeric: tabular-nums</code>。
|
||||
</p>
|
||||
<div class="stage">
|
||||
${TYPE.map(([n, sz, w, lh, src, u]) => `
|
||||
<div style="display:flex;align-items:baseline;gap:14px;padding:9px 0;border-bottom:1px solid #E5E5E5">
|
||||
<code style="width:104px;color:#5F7590;font-size:11px;flex:none">--zf-fs-${n}</code>
|
||||
<span style="font-size:${sz}px;font-weight:${w};line-height:${lh};flex:1;color:#131E29">甄房 · 二手房房源 1,284 套</span>
|
||||
<span style="font-size:11px;color:#5F7590;width:250px;text-align:right;flex:none">${sz}px / ${w} / 行高 ${lh} · ${u}</span>
|
||||
</div>
|
||||
<div style="font-size:10.5px;color:#8b94a7;padding:0 0 7px 104px;font-family:Menlo,Consolas,monospace">${src}</div>`).join('')}
|
||||
</div>
|
||||
<p class="note">
|
||||
<b>只有 6 档,不要再加。</b>中文最小 11px(caption)—— 再小在 Windows 上会糊。
|
||||
</p>
|
||||
|
||||
<h2>四、间距 · 圆角 · 阴影</h2>
|
||||
<h3>间距(4pt 基数)</h3>
|
||||
<div class="stage plain">
|
||||
<div class="row" style="align-items:flex-end;gap:14px">
|
||||
${SPACE.map(v => `<div style="text-align:center">
|
||||
<div style="width:${v}px;height:${v}px;background:#0070F2;border-radius:2px;margin:0 auto 6px"></div>
|
||||
<code style="font-size:10.5px;color:#5F7590">${v}</code></div>`).join('')}
|
||||
</div>
|
||||
<p class="note" style="margin:14px 0 0">
|
||||
常用:控件内边距 <b>8 / 11 / 16</b>;表单行间距 <b>12</b>;卡片内边距 <b>16</b>;区块间距 <b>20 / 24</b>;页面左右边距 <b>18 / 24</b>。
|
||||
不要出现 5、7、9、13 这类非 4 倍数。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>圆角与阴影</h3>
|
||||
<div class="stage">
|
||||
<div class="row" style="align-items:stretch">
|
||||
<div class="card" style="flex:1;border-radius:4px"><code style="font-size:11px;color:#5F7590">4px · 输入类</code><div style="font-size:12px;color:#556B82;margin-top:6px">输入框、下拉、标签、复选框</div></div>
|
||||
<div class="card" style="flex:1"><code style="font-size:11px;color:#5F7590">8px · 基准</code><div style="font-size:12px;color:#556B82;margin-top:6px">按钮、卡片</div></div>
|
||||
<div class="card" style="flex:1;border-radius:12px"><code style="font-size:11px;color:#5F7590">12px</code><div style="font-size:12px;color:#556B82;margin-top:6px">弹窗、抽屉、大容器</div></div>
|
||||
<div class="card" style="flex:1;border-radius:16px"><code style="font-size:11px;color:#5F7590">16px · 磁贴</code><div style="font-size:12px;color:#556B82;margin-top:6px">大卡片、看板磁贴</div></div>
|
||||
<div class="card" style="flex:1;border-radius:999px"><code style="font-size:11px;color:#5F7590">pill</code><div style="font-size:12px;color:#556B82;margin-top:6px">状态标签、头像</div></div>
|
||||
</div>
|
||||
<p class="note" style="margin:14px 0 0">
|
||||
阴影只用两级:<code>--zf-shadow-1</code> 卡片默认、<code>--zf-shadow-2</code> 浮层。
|
||||
<b>分隔优先用边框而不是阴影</b> —— 一屏几十张卡片都带阴影会整体发糊。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>五、组件规范</h2>
|
||||
|
||||
<h3>按钮</h3>
|
||||
<div class="stage">
|
||||
<div class="row">
|
||||
<button class="btn pri">主操作</button>
|
||||
<button class="btn pri h">悬停</button>
|
||||
<button class="btn pri a">按下</button>
|
||||
<button class="btn gho">次要操作</button>
|
||||
<button class="btn">普通按钮</button>
|
||||
<button class="btn tx">文字按钮</button>
|
||||
<button class="btn dan">危险操作</button>
|
||||
<button class="btn dis">禁用</button>
|
||||
<button class="btn pri sm">小号主操作</button>
|
||||
</div>
|
||||
<p class="note" style="margin:16px 0 0">
|
||||
<b>「按下」是白底蓝字蓝框</b> —— 这是 Fiori 官方定义(<code>sapButton_Emphasized_Active_Background: #fff</code> + 蓝字蓝框),不是做错了。
|
||||
其余状态:悬停 <code>#0064D9</code> 加深一档、禁用灰底。
|
||||
</p>
|
||||
<table class="doc" style="margin-top:14px"><thead><tr><th>类型</th><th>用途</th><th>一屏数量</th></tr></thead><tbody>
|
||||
<tr><td class="k">主操作 pri</td><td class="u">推动流程前进的唯一动作:查询、提交、保存、确认</td><td class="v">≤ 1</td></tr>
|
||||
<tr><td class="k">次要 gho</td><td class="u">辅助动作:高级筛选、批量导入</td><td class="v">≤ 2</td></tr>
|
||||
<tr><td class="k">普通 / 文字</td><td class="u">重置、取消、查看详情</td><td class="v">不限</td></tr>
|
||||
<tr><td class="k">危险 dan</td><td class="u">删除、作废、强制回收;必须二次确认</td><td class="v">≤ 1</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
|
||||
<h3>表单</h3>
|
||||
<div class="stage">
|
||||
<div class="row" style="align-items:flex-start;gap:18px">
|
||||
<div class="fld" style="width:200px"><label>房源编码</label><input placeholder="SH-ES-202609-…"><span class="hint">支持模糊匹配</span></div>
|
||||
<div class="fld" style="width:200px"><label>所属楼盘<span class="req">*</span></label><select><option>滨江壹号</option></select><span class="hint">必填</span></div>
|
||||
<div class="fld err" style="width:200px"><label>维护人<span class="req">*</span></label><input value="张"><span class="hint">请输入完整姓名(至少 2 个字)</span></div>
|
||||
<div class="fld dis" style="width:200px"><label>房源编号(系统生成)</label><input value="SH-ES-202609-00037" disabled><span class="hint">不可编辑</span></div>
|
||||
</div>
|
||||
<p class="note" style="margin:16px 0 0">
|
||||
标签在上、控件在下的纵向排布。标签 12px 不加重。错误态只改边框与提示文字色,<b>不要给整个字段加红底</b>。<br>
|
||||
控件描边用 <code>--zf-border-field #556B81</code>(深色)—— 这是 Horizon 的特色,输入框「有框」才看得清;分隔线才用浅色 <code>#E5E5E5</code>。两者不可混用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>数据表格</h3>
|
||||
<div class="stage" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>房源编码</th><th>楼盘 / 房号</th><th class="num">面积</th><th class="num">售价</th><th>状态</th><th>维护人</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="lk">SH-ES-202609-00037</td><td>滨江壹号 3幢 1802<span class="sub">滨江区 · 钱塘江畔</span></td><td class="num">128.5 ㎡</td><td class="num">1,285 万</td><td><span class="tg ok">有效</span></td><td>张伟</td><td class="act"><a>查看</a><a>编辑</a></td></tr>
|
||||
<tr class="hov"><td class="lk">SH-ES-202609-00041</td><td>钱江新城 7幢 2201<span class="sub">上城区 · 万象城</span></td><td class="num">156.0 ㎡</td><td class="num">1,860 万</td><td><span class="tg pr">待检核</span></td><td>李敏</td><td class="act"><a>查看</a><a>编辑</a></td></tr>
|
||||
<tr class="sel"><td class="lk">SH-ES-202609-00052</td><td>滨江壹号 5幢 901<span class="sub">滨江区 · 钱塘江畔</span></td><td class="num">98.2 ㎡</td><td class="num">920 万</td><td><span class="tg gd">独家</span></td><td>王强</td><td class="act"><a>查看</a><a>编辑</a></td></tr>
|
||||
<tr><td class="lk">SH-ES-202609-00058</td><td>绿城桂语 2幢 1503<span class="sub">西湖区 · 文三路</span></td><td class="num">142.6 ㎡</td><td class="num">1,420 万</td><td><span class="tg wn">维护中</span></td><td>张伟</td><td class="act"><a>查看</a><a>编辑</a></td></tr>
|
||||
<tr><td class="lk">SH-ES-202609-00063</td><td>万科未来 1幢 608<span class="sub">余杭区 · 未来科技城</span></td><td class="num">89.0 ㎡</td><td class="num">610 万</td><td><span class="tg er">已下架</span></td><td>赵磊</td><td class="act"><a>查看</a><a class="dan">删除</a></td></tr>
|
||||
</tbody></table>
|
||||
<div style="padding:13px 16px;font-size:12px;color:#556B82;display:flex;align-items:center">
|
||||
共 1,284 条<span class="pg on" style="margin-left:16px">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg">…</span><span class="pg">65</span>
|
||||
<span style="margin-left:auto;font-size:11.5px">每页 20 条</span></div>
|
||||
</div>
|
||||
<table class="doc" style="margin-top:14px"><thead><tr><th>项</th><th>标准</th></tr></thead><tbody>
|
||||
<tr><td class="k">斑马纹</td><td class="u">偶数行 <code>--zf-bg-sub #F5F6F7</code>,与白底差值仅 2–3%。<b>这是数据表最高频的疲劳源</b>,不要加大对比</td></tr>
|
||||
<tr><td class="k">悬停 / 选中</td><td class="u">悬停是<b>中性灰</b> <code>#EAECEE</code>,选中才是<b>浅蓝</b> <code>#EBF8FF</code> —— 官方如此,两者不要都做成蓝。选中行左侧加 3px 主色竖条,长表格滚动后仍可辨认</td></tr>
|
||||
<tr><td class="k">数字列</td><td class="u">右对齐 + tabular-nums;金额带千分位,单位写表头(「售价(万)」)不逐行重复</td></tr>
|
||||
<tr><td class="k">操作列</td><td class="u">固定最后一列,文字链 ≤ 3 个;破坏性动作放最后并标红</td></tr>
|
||||
<tr><td class="k">边框</td><td class="u">全表只有一种线色 <code>#E5E5E5</code>(ElementUI 也只暴露一个变量)。想要「外框深、内线浅」必须写自定义样式</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<h3>状态标签</h3>
|
||||
<div class="stage">
|
||||
<div class="row">
|
||||
<span class="tg ok">有效</span><span class="tg wn">维护中</span><span class="tg er">已下架</span>
|
||||
<span class="tg pr">待检核</span><span class="tg nu">草稿</span><span class="tg gd">独家</span>
|
||||
</div>
|
||||
<p class="note" style="margin:14px 0 0">
|
||||
一律 <b>描边 + 浅底</b>,圆角 pill,字号 11。<br>
|
||||
<b>注意警告标签的文字色是 #B44F00 而不是 #E76500</b> —— 官方 Critical 色在白底只有 3.36:1,直接当文字色不达标;
|
||||
底色仍用 <code>#E76500</code> 描边,文字压深一档。<b>不要</b>用白字压在 #E76500 上(3.36:1,同样不达标)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>页签 · 分页</h3>
|
||||
<div class="stage">
|
||||
<div class="tabs"><a class="on">全部 1284</a><a>有效 892</a><a>维护中 236</a><a>已成交 121</a><a>暂缓 35</a></div>
|
||||
<div style="margin-top:14px">
|
||||
<span class="pg on">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg">…</span><span class="pg">65</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>提示条</h3>
|
||||
<div class="stage">
|
||||
<div class="al i"><span>ℹ</span><span><b>提示:</b>该房源委托期将于 7 天后到期,请及时续签。</span></div>
|
||||
<div class="al s"><span>✓</span><span><b>成功:</b>房源 SH-ES-202609-00037 已提交检核。</span></div>
|
||||
<div class="al w"><span>⚠</span><span><b>警告:</b>钥匙编号与系统记录不一致,请核对后重试。</span></div>
|
||||
<div class="al e"><span>✕</span><span><b>错误:</b>售价不能低于政府指导价,请修改后重试。</span></div>
|
||||
</div>
|
||||
|
||||
<h3>卡片 · KPI · 页头导航</h3>
|
||||
<div class="stage plain">
|
||||
<div class="row" style="align-items:stretch;margin-bottom:16px">
|
||||
<div class="kpi" style="flex:1"><div class="t">在售房源</div><div class="v">1,284<em>套</em></div><div class="d">↑ 3.2% 较上周</div></div>
|
||||
<div class="kpi" style="flex:1"><div class="t">本周新增客源</div><div class="v">96<em>位</em></div><div class="d">↑ 12 位</div></div>
|
||||
<div class="kpi" style="flex:1"><div class="t">待跟进</div><div class="v">23<em>条</em></div><div class="d dn">↓ 5 条</div></div>
|
||||
<div class="kpi gold" style="flex:1"><div class="t">本月预估佣金</div><div class="v">86.4<em>万</em></div><div class="d">↑ 8.6%</div></div>
|
||||
</div>
|
||||
<div class="app">
|
||||
<aside class="side">
|
||||
<div class="brand">${mark(80, '#FFFFFF', 30)}<span><b>甄房</b><em>ZHENFANG</em></span></div>
|
||||
<a class="on"><span class="dot"></span>工作台</a>
|
||||
<a><span class="dot"></span>房源管理</a>
|
||||
<a><span class="dot"></span>客源管理</a>
|
||||
<a><span class="dot"></span>交易管理</a>
|
||||
<a><span class="dot"></span>钥匙管理</a>
|
||||
<a><span class="dot"></span>我的佣金</a>
|
||||
</aside>
|
||||
<div class="main">
|
||||
<div class="head">
|
||||
<div class="cb">房源管理 / <b>二手房房源</b></div>
|
||||
<div class="usr"><span class="av">张</span>张伟 · 滨江门店</div>
|
||||
</div>
|
||||
<div class="bd" style="font-size:12px;color:#5F7590">内容区底色 <code>--zf-bg-sub</code>,卡片用白底与 1px 边框区分层级</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note" style="margin:14px 0 0">
|
||||
侧栏固定用 <b>墨蓝 #0C2340</b>。这是本项目<b>刻意偏离 Fiori 官方 Shell</b> 的地方(官方 <code>sapShell_Background</code> 是浅色 <code>#EFF1F2</code>)——
|
||||
为了减少整屏亮度、形成稳定视觉锚点。浅灰侧栏在长时间使用中会与内容区「糊在一起」。
|
||||
选中项用金色 3px 左指示条 + 10% 白底。<b>代价是 ElementUI 的菜单变量不够用,需补自定义样式(见第九章)。</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>六、页面状态</h2>
|
||||
<div class="stage plain">
|
||||
<div class="row" style="align-items:stretch;gap:14px">
|
||||
<div class="st" style="flex:1;background:#fff"><span class="ic">⏳</span><b>加载中</b>用骨架屏(灰条),不要整页转圈</div>
|
||||
<div class="st" style="flex:1;background:#fff"><span class="ic">📭</span><b>没有数据</b>「还没有房源,点右上角新增」+ 主操作按钮</div>
|
||||
<div class="st" style="flex:1;background:#fff"><span class="ic">🔍</span><b>筛选无结果</b>「没有符合条件的房源,试试放宽条件」+ 重置按钮</div>
|
||||
<div class="st" style="flex:1;background:#fff"><span class="ic">⚠</span><b>加载失败</b>「数据加载失败」+ 重试按钮 + 错误码</div>
|
||||
<div class="st" style="flex:1;background:#fff"><span class="ic">🔒</span><b>无权限</b>「你没有查看该门店房源的权限」+ 申请入口</div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:14px">
|
||||
<div style="font-size:12px;color:#5F7590;margin-bottom:9px">骨架屏示例</div>
|
||||
<div class="sk" style="width:38%"></div>
|
||||
<div class="sk" style="width:72%"></div>
|
||||
<div class="sk" style="width:56%"></div>
|
||||
</div>
|
||||
<p class="note" style="margin:14px 0 0">
|
||||
五种状态都必须单独设计。「没有数据」与「筛选无结果」文案必须不同 —— 否则用户会以为自己的数据丢了。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>七、长时使用约束<em>针对经纪人每天连续使用 6–8 小时</em></h2>
|
||||
<table class="doc"><thead><tr><th>约束</th><th>做法</th><th>解决什么</th></tr></thead><tbody>
|
||||
<tr><td class="k">深色品牌侧栏</td><td class="u">侧栏统一 <code>#0C2340</code>,不用浅灰(偏离官方 Shell 浅色)</td><td class="u">形成稳定视觉锚点;浅灰侧栏会与内容区糊在一起</td></tr>
|
||||
<tr><td class="k">斑马纹极低对比</td><td class="u">条纹 <code>#F5F6F7</code>,与白底差值 2–3%</td><td class="u">帮助横向追行不串行,又不产生条纹眩光</td></tr>
|
||||
<tr><td class="k">语义色降饱和</td><td class="u">描边+浅底,不用实心块;警告文字用深一档的 #B44F00</td><td class="u">一屏几十个状态标签时的最大刺眼源</td></tr>
|
||||
<tr><td class="k">悬停与选中分工</td><td class="u">悬停中性灰 <code>#EAECEE</code>、选中浅蓝 <code>#EBF8FF</code></td><td class="u">长时间划动列表时,中性灰比蓝底更不累眼;蓝色只留给「已选定」这一个强语义</td></tr>
|
||||
<tr><td class="k">金色只做点</td><td class="u">占比 ≤1%,只用于指示条与关键数字</td><td class="u">保住品牌质感,避免大面积金色显廉价</td></tr>
|
||||
<tr><td class="k">正文不用纯黑</td><td class="u">正文 <code>#131E29</code> 而非 <code>#000</code></td><td class="u">降低硬反差带来的持续眼疲劳</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<h2>八、无障碍标准</h2>
|
||||
<p class="note">门槛:正文文字 ≥ 4.5:1,大字与图形/控件边界 ≥ 3:1。下表全部由运行时真实计算。</p>
|
||||
<table class="doc"><thead><tr><th>检查项</th><th>前景 / 背景</th><th>实测</th><th>门槛</th><th>结论</th></tr></thead><tbody>
|
||||
${A11Y.map(([n, f, b, r]) => `<tr><td class="u">${n}</td><td class="v"><span class="chip" style="background:${f}"></span>${f} / <span class="chip" style="background:${b}"></span>${b}</td>
|
||||
<td class="cc" data-fg="${f}" data-bg="${b}" data-req="${r}"></td><td class="v">≥${r}:1</td>
|
||||
<td><span class="vc" data-fg="${f}" data-bg="${b}" data-req="${r}"></span></td></tr>`).join('')}
|
||||
</tbody></table>
|
||||
|
||||
<h3>其他无障碍要求</h3>
|
||||
<table class="doc"><thead><tr><th>项</th><th>标准</th></tr></thead><tbody>
|
||||
<tr><td class="k">焦点态</td><td class="u">所有可交互元素必须有可见焦点环:<code>border-color: --zf-primary</code> + <code>box-shadow: 0 0 0 3px --zf-bg-selected</code>。禁止 <code>outline:none</code> 而不给替代</td></tr>
|
||||
<tr><td class="k">触达尺寸</td><td class="u">可点区域 ≥ 32×32px(H5 ≥ 44×44px)</td></tr>
|
||||
<tr><td class="k">不靠颜色单独表意</td><td class="u">状态标签必须带文字,不能只有色点;图表必须带数据标签或图例</td></tr>
|
||||
<tr><td class="k">键盘可达</td><td class="u">Tab 顺序符合视觉顺序;弹窗打开后焦点移入并锁定,Esc 关闭后归还触发元素</td></tr>
|
||||
<tr><td class="k">动效</td><td class="u">过渡 ≤ 200ms;尊重 <code>prefers-reduced-motion</code></td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<h2>九、落地清单<em>element-ui 2.15.14 变量映射 · 已逐条核对官方源码</em></h2>
|
||||
<p class="note">
|
||||
项目前端为 <b>RuoYi-Vue + element-ui 2.15.14 + Vue 2.6 + sass</b>,变量覆盖写在
|
||||
<code>ruoyi-ui/src/assets/styles/element-variables.scss</code>(须在 <code>@import "~element-ui/packages/theme-chalk/src/index"</code> <b>之前</b>定义)。<br>
|
||||
下表 <span class="tag2 m">必改</span> = 不改主题就是半成品 ·
|
||||
<span class="tag2 d">刻意偏离</span> = 与官方取值不同,已在行内写明原因 ·
|
||||
<span class="tag2 a">自动派生</span> = ElementUI 会基于主色自动算出,只要定义顺序正确就无需覆盖。
|
||||
</p>
|
||||
<table class="doc"><thead><tr><th>ElementUI 变量</th><th>本项目令牌</th><th>取值</th><th>类别</th><th>说明</th></tr></thead><tbody>
|
||||
${ELMAP.map(([e, t, v, k, note]) => `<tr>
|
||||
<td class="k">${e}</td><td class="k">${t}</td>
|
||||
<td class="v"><span class="chip" style="background:${v.indexOf('#') === 0 ? v : 'transparent'}"></span>${v}</td>
|
||||
<td><span class="tag2 ${k}">${k === 'm' ? '必改' : k === 'd' ? '刻意偏离' : '自动派生'}</span></td>
|
||||
<td class="u">${note || '—'}</td></tr>`).join('')}
|
||||
</tbody></table>
|
||||
|
||||
<h3>光改变量解决不了的风险项</h3>
|
||||
${RISKS.map(([t, d]) => `<div class="risk"><b>${t}</b><br>${d}</div>`).join('')}
|
||||
|
||||
<h3>项目现有主题文件中的失效变量</h3>
|
||||
<p class="note">
|
||||
现有 <a class="dl" href="../../ruoyi-ui/src/assets/styles/element-variables.scss">element-variables.scss</a> 里下面这些变量在 element-ui 2.15.14 中<b>并不存在</b>,
|
||||
Sass 不会报错、但也不会生效 —— 属于「以为改了其实没改」。同样问题也存在于另一个主题体系(橙色那套)里。
|
||||
</p>
|
||||
<table class="doc"><thead><tr><th>写法</th><th>实际情况</th></tr></thead><tbody>
|
||||
${DEADVARS.map(([v, d]) => `<tr><td class="k" style="color:#f0a55c">${v}</td><td class="u">${d}</td></tr>`).join('')}
|
||||
</tbody></table>
|
||||
|
||||
<h3>图表分类色板(ECharts)<span class="pill">v1.2 新增 · 最多 10 序列</span></h3>
|
||||
<p class="note">
|
||||
多序列图表<b>禁止把单色映射逐色套用</b> —— 旧色板里 <code>#ff7a1a</code> 映射成主色蓝后会与原有的 <code>#3b82f6</code> 撞色,
|
||||
<code>#ffb31a</code>/<code>#d9481c</code> 也会与 <code>#ef4444</code> 挤在橙红区,10 条序列将失去区分度。<br>
|
||||
下表是重配后的分类色板:色相均匀拉开、相邻序差异最大,全部取自 Fiori 官方 Category / Accent 色(最后一色为品牌金)。
|
||||
</p>
|
||||
<div class="stage" style="padding:16px 18px">
|
||||
<div style="display:flex;gap:6px">
|
||||
${CHART.map(([c, n, s], i) => `<div style="flex:1;text-align:center" title="${s}">
|
||||
<div style="height:46px;border-radius:6px;background:${c}"></div>
|
||||
<div style="font-size:10.5px;color:var(--zf-text-3);margin-top:6px">${i + 1} · ${n}</div>
|
||||
<div style="font-size:9.5px;color:var(--zf-text-3);opacity:.75">${c}</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<pre>const COLORS = ['#0057D2', '#E76500', '#188918', '#DF1278', '#07838F', '#D27700', '#7858FF', '#D20A0A', '#5B738B', '#C6A15B']</pre>
|
||||
<p class="note">序列少于 10 条时<b>按顺序取前 N 个</b>,不要跳着取 —— 相邻色的差异是按顺序优化过的。分类色只用于「无固有顺序」的维度;有顺序的量(如时间)用单色深浅渐变。</p>
|
||||
|
||||
<h3>业务语义色<span class="pill">v1.2 定稿</span></h3>
|
||||
<p class="note">「等级」是质量语义、「热度」是温度语义,两者刻意分到不同色相族,避免同屏混淆。A 级用品牌金而非红色 —— 金色表达「优质/尊贵」,与甄房墨蓝+金的调性一致。</p>
|
||||
<table class="doc"><thead><tr><th>用途</th><th>色值</th><th>来源与理由</th></tr></thead><tbody>
|
||||
${BIZ.map(([u, v, s]) => `<tr><td class="u">${u}</td><td class="v"><span class="chip" style="background:${v}"></span>${v}</td><td class="u">${s}</td></tr>`).join('')}
|
||||
</tbody></table>
|
||||
|
||||
<h3>令牌文件</h3>
|
||||
<p class="note">可直接下载 <a class="dl" href="zhenfang-tokens.css" download>zhenfang-tokens.css</a>(下列全部令牌的 CSS 变量定义),引入到项目根样式之前。</p>
|
||||
<pre>${esc(':root {\n' + cssRoot() + '\n\n' + cssFontSizes() + '\n' + cssSpace() + '\n --zf-font: "72", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;\n}')}</pre>
|
||||
|
||||
<h2>十、变更记录</h2>
|
||||
<table class="doc"><thead><tr><th>版本</th><th>日期</th><th>变更</th></tr></thead><tbody>
|
||||
<tr class="hi"><td class="k">v1.2</td><td class="v">2026-09-19</td><td class="u">
|
||||
<b>新增图表分类色板与业务语义色,并在 ZhenOS-ZF 落地。</b><br>
|
||||
· 图表:旧 ECharts 色板逐色映射会与 <code>#3b82f6</code>/<code>#ef4444</code> 撞色,改为重配的 10 色分类色板(全部 Fiori 官方 Category/Accent 色 + 品牌金);<br>
|
||||
· 业务色:<b>房源等级 A 由红色改为品牌金</b>(金色表达优质,贴合甄房调性);热度 H 保留红色,与等级的质量语义区分;<br>
|
||||
· 落地范围:ZhenOS-ZF 的 WEB(ruoyi-ui)+ H5(zhenfang-uni)双端全量换肤,含主题层 9 个 SCSS、业务页面 100+ 文件、H5 tabbar 图标重着色、logo/应用图标资源替换。
|
||||
</td></tr>
|
||||
<tr><td class="k">v1.1</td><td class="v">2026-09-19</td><td class="u">
|
||||
<b>修正 v1.0 的色值错误。</b>v1.0 的 Fiori 色值来自二手记忆,逐条核对官方主题包后发现 16 个色值里有 10 个不准(例如把
|
||||
<code>#0064D9</code> 写成 <code>#0057D2</code>、把插图/分类色 <code>#223548</code> / <code>#d5dadd</code> / <code>#a9b4be</code> 当成 UI 文字与边框令牌、链接色漏了官方单独定义的 <code>#0064D9</code>)。<br>
|
||||
本次全部改为官方实测值并逐条标注来源变量名;文字只用两级(官方 Neutral 白底 3.34:1 不能用于正文,三级灰改为按 4.5:1 上限反推的 <code>#5F7590</code>);<br>
|
||||
字号阶梯改为官方 rem 值(正文 14px 而非 13px、标题 20/24px);<br>
|
||||
ElementUI 映射从 22 条补到 37 条,区分「必改 / 刻意偏离 / 自动派生」,并新增 4 条光改变量解决不了的风险项(警告按钮白字不达标、按钮悬停方向与官方相反、深色侧栏变量不够用、表格只有一种边框色);<br>
|
||||
新增「项目现有主题文件中的失效变量」一节,列出 5 个在当前 ElementUI 版本中不存在的变量写法;<br>
|
||||
CSS 变量、令牌文件、规范表格改由同一份数据生成,杜绝三处不一致。
|
||||
</td></tr>
|
||||
<tr><td class="k">v1.0</td><td class="v">2026-09-19</td><td class="u">
|
||||
色彩体系定稿为 SAP Fiori Morning Horizon;LOGO 定稿 Z 在房框内 80% 档为主 logo,派生 90/70/60 三档;
|
||||
确立「品牌色与交互色分离」;首次发布组件规范、页面状态规范、长时使用约束与 ElementUI 变量映射。
|
||||
</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<p class="note" style="margin-top:34px;color:#6d768a">
|
||||
本页由 <code>_design-test/spec/build-spec.js</code> 生成。LOGO 数据源自 <code>mark-scales.json</code> 与 <code>final-z.json</code>;
|
||||
Fiori 色值源自 <code>@sap-theming/theming-base-content</code> 的 <code>sap_horizon/css_variables.css</code>;
|
||||
ElementUI 变量名与默认值源自 <code>element-ui@2.15.14</code> 的 <code>theme-chalk/src/common/var.scss</code>。
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
function toRgb(c){
|
||||
if (c.indexOf('rgba') === 0) return c.slice(5, c.length - 1).split(',').slice(0, 3).map(Number);
|
||||
var h = c.replace('#', '');
|
||||
if (h.length === 3) h = h.split('').map(function(x){ return x + x; }).join('');
|
||||
return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
|
||||
}
|
||||
function lum(c){
|
||||
var v = toRgb(c).map(function(x){ x /= 255; return x <= .03928 ? x/12.92 : Math.pow((x+.055)/1.055, 2.4); });
|
||||
return .2126*v[0] + .7152*v[1] + .0722*v[2];
|
||||
}
|
||||
function ratio(a, b){
|
||||
var l1 = lum(a), l2 = lum(b);
|
||||
return (Math.max(l1,l2) + .05) / (Math.min(l1,l2) + .05);
|
||||
}
|
||||
var ok = 0, bad = 0;
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.cc'), function(el){
|
||||
var r = ratio(el.dataset.fg, el.dataset.bg), req = parseFloat(el.dataset.req);
|
||||
if (r >= req) { ok++; } else { bad++; }
|
||||
el.innerHTML = '<span class="' + (r >= req ? 'pass' : 'fail') + '">' + r.toFixed(2) + ':1</span>';
|
||||
});
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.vc'), function(el){
|
||||
var r = ratio(el.dataset.fg, el.dataset.bg), req = parseFloat(el.dataset.req);
|
||||
el.innerHTML = '<span class="' + (r >= req ? 'pass' : 'fail') + '">' + (r >= req ? '通过' : '未达标') + '</span>';
|
||||
});
|
||||
document.title = document.title + '(对比度 ' + ok + '/' + (ok + bad) + ' 通过)';
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
fs.writeFileSync(path.join(HERE, 'index.html'), html);
|
||||
|
||||
// ── 令牌文件(与页面同源生成)────────────────────────────────────
|
||||
const tokensCss = `/* 甄房业务系统 · 设计令牌 v1.1(定稿 2026-09-19)
|
||||
* 色彩体系:SAP Fiori Morning Horizon(官方实测值)
|
||||
* 引入顺序:本文件应在项目根样式之前引入
|
||||
* 规则:界面实现一律使用 var(--zf-*) ,禁止直接写十六进制色值
|
||||
* 来源:@sap-theming/theming-base-content → sap_horizon/css_variables.css
|
||||
*/
|
||||
:root {
|
||||
${cssRoot()}
|
||||
|
||||
/* ── 字号(官方 rem 值,根字号 16px)── */
|
||||
${cssFontSizes()}
|
||||
|
||||
/* ── 间距(4pt 基数)── */
|
||||
${cssSpace()}
|
||||
|
||||
/* ── 字族 ── */
|
||||
--zf-font: "72", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(HERE, 'zhenfang-tokens.css'), tokensCss);
|
||||
|
||||
console.log('已生成 spec/index.html 与 spec/zhenfang-tokens.css');
|
||||
console.log(' 令牌组', GROUP.length, '| 令牌总数', TOKENS.length,
|
||||
'| 字号档', TYPE.length, '| ElementUI 映射', ELMAP.length,
|
||||
'| 风险项', RISKS.length, '| 失效变量', DEADVARS.length);
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,70 @@
|
||||
/* 甄房业务系统 · 设计令牌 v1.1(定稿 2026-09-19)
|
||||
* 色彩体系:SAP Fiori Morning Horizon(官方实测值)
|
||||
* 引入顺序:本文件应在项目根样式之前引入
|
||||
* 规则:界面实现一律使用 var(--zf-*) ,禁止直接写十六进制色值
|
||||
* 来源:@sap-theming/theming-base-content → sap_horizon/css_variables.css
|
||||
*/
|
||||
:root {
|
||||
/* ── 品牌层 ── */
|
||||
--zf-brand-ink: #0C2340;
|
||||
--zf-brand-ink-deep: #071527;
|
||||
--zf-brand-gold: #C6A15B;
|
||||
--zf-brand-gold-soft: #E3CB99;
|
||||
|
||||
/* ── 交互层 ── */
|
||||
--zf-primary: #0070F2;
|
||||
--zf-primary-hover: #0064D9;
|
||||
--zf-primary-active-bg: #FFFFFF;
|
||||
--zf-primary-active-ink: #0064D9;
|
||||
--zf-link: #0064D9;
|
||||
|
||||
/* ── 表面层 ── */
|
||||
--zf-bg-base: #FFFFFF;
|
||||
--zf-bg-sub: #F5F6F7;
|
||||
--zf-bg-hover: #EAECEE;
|
||||
--zf-bg-active: #DEE2E5;
|
||||
--zf-bg-selected: #EBF8FF;
|
||||
|
||||
/* ── 边框 ── */
|
||||
--zf-border: #E5E5E5;
|
||||
--zf-border-field: #556B81;
|
||||
--zf-border-strong: #758CA4;
|
||||
|
||||
/* ── 文字 ── */
|
||||
--zf-text-1: #131E29;
|
||||
--zf-text-2: #556B82;
|
||||
--zf-text-3: #5F7590;
|
||||
--zf-text-placeholder: #5F7590;
|
||||
--zf-text-inverse: #FFFFFF;
|
||||
--zf-text-inverse-2: #BBC1CA;
|
||||
|
||||
/* ── 语义色 ── */
|
||||
--zf-success: #256F3A;
|
||||
--zf-error: #AA0808;
|
||||
--zf-warning: #E76500;
|
||||
--zf-info: #0070F2;
|
||||
--zf-neutral: #788FA6;
|
||||
|
||||
/* ── 形状与层级 ── */
|
||||
--zf-radius-s: 4px;
|
||||
--zf-radius: 8px;
|
||||
--zf-radius-l: 12px;
|
||||
--zf-radius-tile: 16px;
|
||||
--zf-radius-pill: 999px;
|
||||
--zf-shadow-1: 0 1px 2px rgba(34,53,72,.08);
|
||||
--zf-shadow-2: 0 2px 8px rgba(34,53,72,.12);
|
||||
|
||||
/* ── 字号(官方 rem 值,根字号 16px)── */
|
||||
--zf-fs-caption: 11px;
|
||||
--zf-fs-body-s: 12px;
|
||||
--zf-fs-body: 14px;
|
||||
--zf-fs-title-s: 16px;
|
||||
--zf-fs-title: 20px;
|
||||
--zf-fs-display: 24px;
|
||||
|
||||
/* ── 间距(4pt 基数)── */
|
||||
--zf-sp-1: 4px; --zf-sp-2: 8px; --zf-sp-3: 12px; --zf-sp-4: 16px; --zf-sp-5: 20px; --zf-sp-6: 24px; --zf-sp-7: 32px; --zf-sp-8: 40px;
|
||||
|
||||
/* ── 字族 ── */
|
||||
--zf-font: "72", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
// 甄房业务系统 · 三套色彩体系对比页
|
||||
// 结构:一套 UI 演示结构 + 三套 CSS 变量作用域([data-theme]),保证对比公平
|
||||
// 对比度由页面运行时真实计算(非手写)
|
||||
// 用法: node _design-test/theme/build-theme-compare.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const HERE = __dirname;
|
||||
const LOGO = path.join(HERE, '..', 'logo');
|
||||
const FZ = JSON.parse(fs.readFileSync(path.join(LOGO, 'final-z.json'), 'utf8'));
|
||||
|
||||
const H04 = 'M3 11.99v2.51c0 3.3 0 4.95 1.025 5.975S6.7 21.5 10 21.5h4c3.3 0 4.95 0 5.975-1.025S21 17.8 21 14.5v-2.51c0-1.682 0-2.522-.356-3.25s-1.02-1.244-2.346-2.276l-2-1.555C14.233 3.303 13.2 2.5 12 2.5s-2.233.803-4.298 2.409l-2 1.555C4.375 7.496 3.712 8.012 3.356 8.74S3 10.308 3 11.99';
|
||||
const FG = 'translate(5.3304 5.3294) scale(2.22222)';
|
||||
|
||||
// 品牌标识(框 + Z),c1=框色 c2=Z色
|
||||
const mark = (c1, c2, size) =>
|
||||
'<svg viewBox="0 0 64 64" width="' + size + '" height="' + size + '">' +
|
||||
'<g transform="' + FG + '"><path d="' + H04 + '" fill="none" stroke="' + c1 +
|
||||
'" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g>' +
|
||||
'<path d="' + FZ.final + '" fill="' + c2 + '"/></svg>';
|
||||
|
||||
// ── 三套色彩令牌 ────────────────────────────────────────────────
|
||||
// 说明:品牌墨蓝 #0C2340 在三套方案中都用于「品牌/导航区」,
|
||||
// 交互色各自采用该体系的官方主色 —— 即「品牌色与交互色分离」。
|
||||
const THEMES = [
|
||||
{
|
||||
id: 'carbon', name: '方案 A · Carbon 严谨派', sys: 'IBM Carbon Design System',
|
||||
score: { dense: 9.5, comfort: 9.5, a11y: 10, brand: 8.5, cost: 7.5, proven: 9.0 },
|
||||
radius: '2px', font: '"IBM Plex Sans", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
shadow: 'none',
|
||||
desc: '以中性灰主导、蓝色只承担「行动色」的经典企业级体系。四套官方主题(White / Gray 10 / Gray 90 / Gray 100)与分层模型,为数据密集型界面而生。',
|
||||
tone: { brand:'#0C2340', primary:'#0F62FE', bg:'#FFFFFF', bg2:'#F4F4F4' },
|
||||
tags: [
|
||||
['品牌 / 导航底', '#0C2340', '甄房墨蓝(品牌区、侧栏、登录页)'],
|
||||
['交互主色', '#0F62FE', 'Blue 60 · 官方 primary action'],
|
||||
['主色悬停', '#0043CE', 'Blue 70'],
|
||||
['主色按下', '#002D9C', 'Blue 80'],
|
||||
['主色浅底', '#EDF5FF', 'Blue 10 · 高亮/选中浅底'],
|
||||
['页面底色', '#FFFFFF', '$background(White 主题)'],
|
||||
['分层底色', '#F4F4F4', 'Gray 10 · $layer-01'],
|
||||
['悬停底色', '#E8E8E8', 'Gray 10 hover'],
|
||||
['选中底色', '#E0E0E0', 'Gray 20 · $layer-selected-01'],
|
||||
['边框', '#E0E0E0', 'Gray 20'],
|
||||
['边框-强', '#8D8D8D', 'Gray 50'],
|
||||
['一级文字', '#161616', 'Gray 100'],
|
||||
['二级文字', '#525252', 'Gray 70'],
|
||||
['三级文字', '#6F6F6F', 'Gray 60'],
|
||||
['成功', '#198038', 'Green 60'],
|
||||
['警告', '#F1C21B', 'Yellow 30'],
|
||||
['错误', '#DA1E28', 'Red 60'],
|
||||
['信息', '#0F62FE', 'Blue 60'],
|
||||
['品牌金(点缀)', '#C6A15B', '仅 logo / 关键数字,占比 ≤5%']
|
||||
],
|
||||
pros: ['中性灰主导(官方原文 “Neutral Gray family is dominant”),长时间盯表格最不累',
|
||||
'明确要求「避免使用中间调」,界面层次靠灰阶深浅而非色块,密度最高',
|
||||
'无障碍是一等公民:官方每个令牌都标注对比度要求(如 $layer-selected-inverse 标 4.5:1 AA)',
|
||||
'四套官方主题 + 分层模型,深色模式与高对比模式有现成规范'],
|
||||
cons: ['圆角基本为 0(2px),偏工程感,国内用户可能觉得「硬」',
|
||||
'品牌金几乎没有落点,品牌温度靠墨蓝承担',
|
||||
'与 ElementUI 的默认圆角/间距差异大,改造成本三套中最高'],
|
||||
fit: ['数据密度极高的作业台(房源批量检核、佣金结算)',
|
||||
'需要长时间连续录入/核对的岗位',
|
||||
'对无障碍有硬性要求的场景(政企、大客户交付)'],
|
||||
// 生产落地必改项:[用途, 原值, 建议改为, 理由]
|
||||
fixes: [
|
||||
['警告色用法', '#F1C21B 作文字色', '黄只作底色(#F1C21B 底 + #161616 字)',
|
||||
'Yellow 30 在白底上仅 1.68:1;Carbon 官方本就把黄色用于警报条底色而非文字色'],
|
||||
['圆角', '2px', '按国内习惯可放宽到 4px',
|
||||
'不是超标项,属外观取舍;Carbon 的 0–2px 方角在国内后台会显「硬」']
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'fiori', name: '方案 B · Fiori 商务派', sys: 'SAP Fiori · Morning Horizon',
|
||||
final: true, // ✓ 已定稿(2026-09-19)
|
||||
score: { dense: 9.0, comfort: 8.8, a11y: 9.5, brand: 8.0, cost: 7.0, proven: 9.5 },
|
||||
radius: '8px', font: '"72", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
shadow: '0 1px 2px rgba(34,53,72,.08)',
|
||||
desc: 'ERP 领域事实标准。语义令牌分层最完整:表面 S0–S8、文字 T1–T9、边框、语义色、强调色五组,并给出可推导的对比度规则。',
|
||||
tone: { brand:'#0C2340', primary:'#0070F2', bg:'#FFFFFF', bg2:'#F5F6F7' },
|
||||
tags: [
|
||||
['品牌 / Shell Bar', '#0C2340', '甄房墨蓝(Shell Bar、应用头)'],
|
||||
['交互主色', '#0070F2', 'Blue 7 · S2 Secondary / T4 CTA'],
|
||||
['主色悬停', '#0057D2', 'Blue 8 · T7 Selected'],
|
||||
['主色按下', '#0040B0', '同体系推导(Blue 8 再压一档)'],
|
||||
['主色浅底', '#EBF4FE', 'S5 Selected Surface(官方 rgba(0,112,242,.08) 在白底上的合成值)'],
|
||||
['页面底色', '#FFFFFF', 'S0 Base Background'],
|
||||
['次级底色', '#F5F6F7', 'S4 Quaternary · Grey 1'],
|
||||
['边框', '#D5DADD', 'Grey 3'],
|
||||
['边框-强', '#A9B4BE', 'Grey 4'],
|
||||
['一级文字', '#223548', 'T1 · Grey 9'],
|
||||
['二级文字', '#475E75', 'T2 · Grey 7'],
|
||||
['三级文字', '#5B738B', 'T3 · Grey 6'],
|
||||
['成功', '#256F3A', 'Positive(同体系参考值)'],
|
||||
['警告', '#E76500', 'Critical · Mango(同体系参考值)'],
|
||||
['错误', '#D20A0A', 'Red 7 · Negative(官方确认)'],
|
||||
['信息', '#0070F2', 'Blue 7'],
|
||||
['品牌金(点缀)', '#C6A15B', '仅 logo / 关键数字,占比 ≤5%']
|
||||
],
|
||||
pros: ['唯一为 ERP 事务场景设计的体系,表格/表单/Shell 的层级划分最成熟',
|
||||
'「相隔 4 级 = 3:1、相隔 5 级 = 4.5:1」——对比度可推导,选色不用猜',
|
||||
'选中态用 8% 蓝底(而非实色块),长时间使用不刺眼',
|
||||
'品牌化路径最清晰:官方 Quick Theming 就是「替换 logo + 主品牌色」'],
|
||||
cons: ['Shell Bar + 应用头 + 页面头三层结构,垂直空间占用大,密度略低于 Carbon',
|
||||
'蓝白对比偏冷、现代感一般,年轻经纪人可能觉得「像老系统」',
|
||||
'部分语义色官方文档未完整公开,落地前需核对准确值'],
|
||||
fit: ['交易全流程、佣金结算这类强流程、强状态的模块',
|
||||
'与财务/ERP 打通、需要符合企业级规范的场景',
|
||||
'多角色、多层级权限的后台(选中态与层级表达最清楚)'],
|
||||
fixes: [] // 12/12 全部通过,无需改动
|
||||
},
|
||||
{
|
||||
id: 'antd', name: '方案 C · AntD 亲和派', sys: 'Ant Design 5',
|
||||
score: { dense: 8.0, comfort: 7.5, a11y: 8.0, brand: 8.5, cost: 9.5, proven: 9.0 },
|
||||
radius: '6px', font: '-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
shadow: '0 1px 2px rgba(0,0,0,.06)',
|
||||
desc: '国内中后台事实标准。12 色 × 10 阶色板 + 算法生成,可在品牌色基础上自动产出整条色阶;中文文档与团队熟悉度最高。',
|
||||
tone: { brand:'#0C2340', primary:'#1677FF', bg:'#FFFFFF', bg2:'#F5F5F5' },
|
||||
tags: [
|
||||
['品牌 / 侧栏', '#0C2340', '甄房墨蓝(侧栏、品牌区)'],
|
||||
['交互主色', '#1677FF', 'colorPrimary · blue-6(官方默认)'],
|
||||
['主色悬停', '#4096FF', 'blue-5'],
|
||||
['主色按下', '#0958D9', 'blue-7'],
|
||||
['主色浅底', '#E6F4FF', 'blue-1 · 选中行浅底'],
|
||||
['页面底色', '#FFFFFF', '官方容器色'],
|
||||
['次级底色', '#F5F5F5', '官方中性灰'],
|
||||
['边框', '#D9D9D9', '官方边框色'],
|
||||
['一级文字', '#1F1F1F', 'rgba(0,0,0,.88) 合成值'],
|
||||
['二级文字', '#595959', 'rgba(0,0,0,.65) 合成值'],
|
||||
['三级文字', '#8C8C8C', 'rgba(0,0,0,.45) 合成值'],
|
||||
['成功', '#52C41A', 'colorSuccess'],
|
||||
['警告', '#FAAD14', 'colorWarning'],
|
||||
['错误', '#FF4D4F', 'colorError'],
|
||||
['信息', '#1677FF', 'colorInfo'],
|
||||
['品牌金(点缀)', '#C6A15B', '仅 logo / 关键数字,占比 ≤5%']
|
||||
],
|
||||
pros: ['团队最熟、中文文档最全,与现有 Vue2 + ElementUI 生态迁移成本最低',
|
||||
'色板算法可由品牌色直接生成整条色阶(12 阶),扩展与派生最省事',
|
||||
'色彩语义与国内用户预期一致(成功绿/警告黄/错误红),零学习成本',
|
||||
'品牌金在三套中最好安排(圆角卡片 + 金色数字自然)'],
|
||||
cons: ['默认色饱和偏高:#FF4D4F 错误色、#52C41A 成功色长时间看刺激较强',
|
||||
'官方定位偏「轻盈」,高数据密度下需要额外收紧间距与层级',
|
||||
'中性灰层级不如 Carbon/Fiori 严谨,容易出现「灰得不够分明」'],
|
||||
fit: ['经纪人日常高频作业(房源/客源/跟进),要求上手快、认知零负担',
|
||||
'迭代节奏快、需要大量复用生态组件的中后台',
|
||||
'团队以国内前端为主、希望降低长期维护成本的项目'],
|
||||
// 生产落地必改项:[用途, 原值, 建议改为, 理由]
|
||||
fixes: [
|
||||
['主色(按钮底 / 链接字)', '#1677FF', '#0958D9(blue-7)',
|
||||
'官方默认主色在白底上实测仅 4.10:1,差 0.4 未达 AA 4.5:1 —— 「第 6 阶满足 4.5:1」只对部分色相成立;下移一阶到 blue-7 即 6.16:1,肉眼几乎无差别'],
|
||||
['成功色', '#52C41A', '#389E0D(green-7)',
|
||||
'#52C41A 白底仅 2.27:1,连图形类 3:1 都不到;图标与文字改 green-7(3.46:1),浅底仍可保留亮绿'],
|
||||
['警告色', '#FAAD14', '文字与描边改 #AD6800(gold-8)',
|
||||
'#FAAD14 白底仅 1.90:1;沿用「亮色只作底、深色作字」的做法,gold-8 为 4.41:1,几乎直接满足正文标准'],
|
||||
['错误色(正文)', '#FF4D4F', '正文与链接改 #CF1322(red-7)',
|
||||
'#FF4D4F 作正文仅 3.27:1;作为图形勉强及格,但表格里的错误提示是正文级文字,改 red-7 后 5.57:1;描边与底色可继续用亮红'],
|
||||
['三级文字', '#8C8C8C', '#6E6E6E',
|
||||
'rgba(0,0,0,.45) 的合成值白底 3.36:1,未达 AA;加深到 #6E6E6E 即 5.10:1'],
|
||||
['灰阶层级', '仅三级文字灰', '补一级 #BFBFBF 专用于占位符 / 禁用',
|
||||
'AntD 只有三级文字灰,导致占位符被迫复用 #8C8C8C —— 既不够浅(有对比度不达标风险)又会被误读为正文;补一级浅灰后三级文字才能安心加深']
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 加权评分(权重:密度25 舒适20 无障碍15 品牌15 成本15 验证10)
|
||||
const W = { dense: .25, comfort: .20, a11y: .15, brand: .15, cost: .15, proven: .10 };
|
||||
THEMES.forEach(t => { t.total = Object.keys(W).reduce((s, k) => s + t.score[k] * W[k], 0); });
|
||||
|
||||
const CSS = `
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#0a0d13;color:#e9edf5;font-family:"PingFang SC","Microsoft YaHei",system-ui,sans-serif;
|
||||
padding:40px 24px 90px;line-height:1.6}
|
||||
.wrap{max-width:1500px;margin:0 auto}
|
||||
h1{font-size:27px;letter-spacing:.2px}h1 b{color:#E3CB99}
|
||||
.lead{color:#8b94a7;font-size:13.5px;margin:10px 0 26px;line-height:1.95}
|
||||
h2{font-size:19px;margin:46px 0 6px;padding-left:11px;border-left:4px solid #C6A15B}
|
||||
h2 span{font-size:11px;font-weight:400;color:#8b94a7;margin-left:10px}
|
||||
h3{font-size:15px;margin:26px 0 10px;color:#E3CB99}
|
||||
p.note{font-size:12.5px;color:#8b94a7;margin:8px 0 16px;line-height:1.9}
|
||||
|
||||
/* ── 调研结论 ── */
|
||||
table.cmp{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:14px}
|
||||
table.cmp th,table.cmp td{border:1px solid rgba(255,255,255,.09);padding:9px 11px;text-align:left}
|
||||
table.cmp th{background:rgba(255,255,255,.045);font-weight:600;color:#cfd6e4}
|
||||
table.cmp td.num{text-align:center;font-variant-numeric:tabular-nums}
|
||||
table.cmp tr.win td{background:rgba(198,161,91,.10)}
|
||||
table.cmp td.tot{font-weight:700;color:#E3CB99}
|
||||
.badge{display:inline-block;font-size:10px;padding:1px 7px;border-radius:20px;margin-left:7px;
|
||||
border:1px solid rgba(198,161,91,.45);color:#E3CB99}
|
||||
.src{font-size:11px;color:#6d768a;margin-top:8px;line-height:1.9}
|
||||
.src a{color:#8ab4f8;text-decoration:none}
|
||||
|
||||
/* ── 方案卡 ── */
|
||||
.plan{border:1px solid rgba(255,255,255,.09);border-radius:16px;padding:22px;margin-top:18px;
|
||||
background:linear-gradient(180deg,rgba(255,255,255,.03),rgba(255,255,255,.012))}
|
||||
.plan .hd{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap}
|
||||
.plan .hd h3{margin:0;font-size:18px;color:#fff}
|
||||
.plan .hd .sys{font-size:11.5px;color:#8b94a7}
|
||||
.plan .hd .sc{margin-left:auto;font-size:13px;color:#E3CB99;font-weight:700}
|
||||
.plan .hd .finalmark{font-size:11px;font-weight:700;padding:2px 9px;border-radius:20px;
|
||||
background:rgba(94,224,138,.13);border:1px solid rgba(94,224,138,.5);color:#5ee08a}
|
||||
.plan.final{border-color:rgba(94,224,138,.42);box-shadow:0 0 0 1px rgba(94,224,138,.16) inset}
|
||||
.plan .d{font-size:12.5px;color:#9aa4b8;margin:9px 0 16px;line-height:1.9}
|
||||
.pc{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:14px}
|
||||
.pc>div{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:11px;padding:13px 15px}
|
||||
.pc h4{font-size:12.5px;margin-bottom:8px}
|
||||
.pc ul{list-style:none;font-size:12px;color:#98a1b3;line-height:1.95}
|
||||
.pc ul li{padding-left:15px;position:relative}
|
||||
.pc ul li:before{position:absolute;left:0;top:0}
|
||||
.pc .good li:before{content:'+';color:#5ee08a}
|
||||
.pc .bad li:before{content:'−';color:#f0a55c}
|
||||
.pc .fit li:before{content:'·';color:#8ab4f8}
|
||||
|
||||
/* ── 生产落地必改项 ── */
|
||||
table.fix{margin-top:10px;font-size:12px}
|
||||
table.fix th:nth-child(1),table.fix td:nth-child(1){width:132px}
|
||||
table.fix th:nth-child(2),table.fix td:nth-child(2){width:150px}
|
||||
table.fix th:nth-child(3),table.fix td:nth-child(3){width:250px}
|
||||
table.fix code{background:rgba(255,255,255,.06);padding:1px 5px;border-radius:4px;font-size:11px}
|
||||
table.fix td:nth-child(2) code{color:#f0a55c}
|
||||
table.fix td:nth-child(3) code{color:#5ee08a}
|
||||
.fixok{font-size:12.5px;color:#5ee08a;background:rgba(94,224,138,.07);
|
||||
border:1px solid rgba(94,224,138,.28);border-radius:9px;padding:10px 13px;margin-top:10px}
|
||||
.sumline{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}
|
||||
.sumline div{flex:1;min-width:230px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.08);
|
||||
border-radius:10px;padding:10px 13px;font-size:12px;color:#98a1b3}
|
||||
.sumline div b{color:#e9edf5}
|
||||
.sumline div em{font-style:normal;font-weight:700}
|
||||
.sumline div em.ok{color:#5ee08a}.sumline div em.no{color:#f0a55c}
|
||||
|
||||
/* ── 令牌表 ── */
|
||||
.tok{display:grid;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));gap:7px;margin-top:12px}
|
||||
.tk{display:flex;align-items:center;gap:9px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.07);
|
||||
border-radius:9px;padding:7px 10px;font-size:11.5px}
|
||||
.tk i{width:22px;height:22px;border-radius:6px;flex:none;border:1px solid rgba(255,255,255,.16)}
|
||||
.tk .n{flex:1;min-width:0}
|
||||
.tk .n b{display:block;font-weight:600;font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.tk .n code{color:#7f8899;font-size:10px;letter-spacing:.2px}
|
||||
.tk .r{font-size:9.5px;padding:1px 6px;border-radius:20px;border:1px solid;flex:none;white-space:nowrap}
|
||||
.tk .r.pass{color:#5ee08a;border-color:rgba(94,224,138,.4)}
|
||||
.tk .r.fail{color:#f0a55c;border-color:rgba(240,165,92,.4)}
|
||||
|
||||
/* ── UI 演示(三套作用域)── */
|
||||
.demo{border-radius:14px;overflow:hidden;margin-top:14px;border:1px solid rgba(255,255,255,.12);background:var(--bg);
|
||||
font-family:var(--font);color:var(--text1);font-size:12.5px}
|
||||
.demo[data-theme=carbon]{--brand:#0C2340;--primary:#0F62FE;--primary-h:#0043CE;--primary-a:#002D9C;--primary-s:#EDF5FF;
|
||||
--bg:#FFFFFF;--bg2:#F4F4F4;--hover:#E8E8E8;--sel:#E0E0E0;--bd:#E0E0E0;--bd-s:#8D8D8D;--text1:#161616;--text2:#525252;--text3:#6F6F6F;
|
||||
--ok:#198038;--warn:#F1C21B;--err:#DA1E28;--radius:2px;--sh:none;--gold:#C6A15B;
|
||||
--font:"IBM Plex Sans","PingFang SC","Microsoft YaHei",sans-serif}
|
||||
.demo[data-theme=fiori]{--brand:#0C2340;--primary:#0070F2;--primary-h:#0057D2;--primary-a:#0040B0;--primary-s:rgba(0,112,242,.08);
|
||||
--bg:#FFFFFF;--bg2:#F5F6F7;--hover:rgba(0,112,242,.06);--sel:rgba(0,112,242,.10);--bd:#D5DADD;--bd-s:#A9B4BE;--text1:#223548;--text2:#475E75;--text3:#5B738B;
|
||||
--ok:#256F3A;--warn:#E76500;--err:#D20A0A;--radius:8px;--sh:0 1px 2px rgba(34,53,72,.08);--gold:#C6A15B;
|
||||
--font:"72","PingFang SC","Microsoft YaHei",sans-serif}
|
||||
.demo[data-theme=antd]{--brand:#0C2340;--primary:#1677FF;--primary-h:#4096FF;--primary-a:#0958D9;--primary-s:#E6F4FF;
|
||||
--bg:#FFFFFF;--bg2:#F5F5F5;--hover:#F5F5F5;--sel:#E6F4FF;--bd:#D9D9D9;--bd-s:#BFBFBF;--text1:#1F1F1F;--text2:#595959;--text3:#8C8C8C;
|
||||
--ok:#52C41A;--warn:#FAAD14;--err:#FF4D4F;--radius:6px;--sh:0 1px 2px rgba(0,0,0,.06);--gold:#C6A15B;
|
||||
--font:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
|
||||
|
||||
.app{display:grid;grid-template-columns:196px 1fr;min-height:540px}
|
||||
.app .side{background:var(--brand);padding:14px 0;display:flex;flex-direction:column;gap:2px}
|
||||
.app .side .brand{display:flex;align-items:center;gap:9px;padding:2px 14px 16px;color:#fff}
|
||||
.app .side .brand b{font-size:15px;font-weight:600;letter-spacing:1px}
|
||||
.app .side .brand em{font-size:8px;letter-spacing:1.4px;opacity:.62;font-style:normal;display:block}
|
||||
.app .side a{display:flex;align-items:center;gap:9px;color:rgba(255,255,255,.72);text-decoration:none;
|
||||
font-size:12.5px;padding:9px 14px;border-left:3px solid transparent}
|
||||
.app .side a.on{background:rgba(255,255,255,.10);color:#fff;border-left-color:var(--gold);font-weight:600}
|
||||
.app .side a .d{width:6px;height:6px;border-radius:2px;background:currentColor;opacity:.55;flex:none}
|
||||
.app .main{display:flex;flex-direction:column;min-width:0}
|
||||
.app .top{display:flex;align-items:center;gap:14px;padding:11px 18px;border-bottom:1px solid var(--bd);background:var(--bg)}
|
||||
.app .top .crumb{font-size:12.5px;color:var(--text2)}
|
||||
.app .top .crumb b{color:var(--text1);font-weight:600}
|
||||
.app .top .usr{margin-left:auto;font-size:12px;color:var(--text2);display:flex;align-items:center;gap:8px}
|
||||
.app .top .usr .av{width:24px;height:24px;border-radius:50%;background:var(--primary-s);color:var(--primary);
|
||||
display:flex;align-items:center;justify-content:center;font-size:10.5px;font-weight:600}
|
||||
.app .body{padding:14px 18px 20px;background:var(--bg2);flex:1;display:flex;flex-direction:column;gap:12px}
|
||||
|
||||
.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
|
||||
.kpi{background:var(--bg);border:1px solid var(--bd);border-radius:var(--radius);padding:11px 13px;box-shadow:var(--sh)}
|
||||
.kpi .t{font-size:11px;color:var(--text3)}
|
||||
.kpi .v{font-size:20px;font-weight:600;color:var(--text1);margin-top:3px;font-variant-numeric:tabular-nums}
|
||||
.kpi .v em{font-size:11px;font-style:normal;color:var(--text3);font-weight:400;margin-left:3px}
|
||||
.kpi .d{font-size:10.5px;margin-top:2px}
|
||||
.kpi .d.up{color:var(--ok)}.kpi .d.dn{color:var(--err)}
|
||||
.kpi.gold .v{color:var(--gold)}
|
||||
|
||||
.card{background:var(--bg);border:1px solid var(--bd);border-radius:var(--radius);box-shadow:var(--sh)}
|
||||
.filters{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;padding:13px}
|
||||
.f{display:flex;flex-direction:column;gap:4px}
|
||||
.f label{font-size:11px;color:var(--text3)}
|
||||
.f input,.f select{font-family:inherit;font-size:12.5px;color:var(--text1);background:var(--bg);
|
||||
border:1px solid var(--bd);border-radius:var(--radius);padding:6px 9px;min-width:132px}
|
||||
.f input::placeholder{color:var(--text3)}
|
||||
.f input:focus,.f select:focus{outline:2px solid var(--primary);outline-offset:-1px;border-color:var(--primary)}
|
||||
.f.err input{border-color:var(--err)}
|
||||
.f .msg{font-size:10.5px;color:var(--err)}
|
||||
.btns{display:flex;gap:8px;margin-left:auto}
|
||||
.btn{font-family:inherit;font-size:12.5px;border-radius:var(--radius);padding:6px 15px;cursor:default;
|
||||
border:1px solid var(--bd);background:var(--bg);color:var(--text1);white-space:nowrap}
|
||||
.btn.pri{background:var(--primary);border-color:var(--primary);color:#fff;font-weight:500}
|
||||
.btn.pri.h{background:var(--primary-h);border-color:var(--primary-h)}
|
||||
.btn.pri.a{background:var(--primary-a);border-color:var(--primary-a)}
|
||||
.btn.dan{background:var(--err);border-color:var(--err);color:#fff}
|
||||
.btn.gho{border-color:var(--primary);color:var(--primary);background:transparent}
|
||||
.btn.tx{border-color:transparent;background:transparent;color:var(--primary);padding:6px 6px}
|
||||
.btn.dis{background:var(--bg2);border-color:var(--bd);color:var(--text3);opacity:.75}
|
||||
|
||||
.tbl{width:100%;border-collapse:collapse;font-size:12.5px;font-variant-numeric:tabular-nums}
|
||||
.tbl th{text-align:left;font-weight:600;font-size:11.5px;color:var(--text2);background:var(--bg2);
|
||||
padding:9px 11px;border-bottom:1px solid var(--bd);white-space:nowrap}
|
||||
.tbl td{padding:9px 11px;border-bottom:1px solid var(--bd);color:var(--text1)}
|
||||
.tbl tbody tr:nth-child(even){background:var(--bg2)}
|
||||
.tbl tbody tr.hov{background:var(--hover)}
|
||||
.tbl tbody tr.sel{background:var(--sel)}
|
||||
.tbl tbody tr.sel td:first-child{box-shadow:inset 2px 0 0 var(--primary)}
|
||||
.tbl .code{color:var(--primary);font-weight:500}
|
||||
.tbl .sub{color:var(--text3);font-size:11px;display:block}
|
||||
.tbl .op a{color:var(--primary);text-decoration:none;margin-right:9px;font-size:12px}
|
||||
.tbl .op a.dan{color:var(--err)}
|
||||
.tag{display:inline-block;font-size:10.5px;padding:1px 8px;border-radius:20px;border:1px solid;white-space:nowrap}
|
||||
.tag.ok{color:var(--ok);border-color:var(--ok);background:color-mix(in srgb,var(--ok) 9%,transparent)}
|
||||
.tag.wn{color:var(--warn);border-color:var(--warn);background:color-mix(in srgb,var(--warn) 12%,transparent)}
|
||||
.tag.er{color:var(--err);border-color:var(--err);background:color-mix(in srgb,var(--err) 9%,transparent)}
|
||||
.tag.pri{color:var(--primary);border-color:var(--primary);background:var(--primary-s)}
|
||||
.tag.nu{color:var(--text2);border-color:var(--bd-s);background:transparent}
|
||||
.tag.gold{color:#8a6a2f;border-color:var(--gold);background:color-mix(in srgb,var(--gold) 14%,transparent)}
|
||||
|
||||
.pager{display:flex;align-items:center;gap:7px;padding:11px 13px;font-size:12px;color:var(--text2)}
|
||||
.pg{min-width:26px;height:26px;display:flex;align-items:center;justify-content:center;border:1px solid var(--bd);
|
||||
border-radius:var(--radius);background:var(--bg);color:var(--text1)}
|
||||
.pg.on{background:var(--primary);border-color:var(--primary);color:#fff;font-weight:600}
|
||||
|
||||
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--bd);padding:0 13px}
|
||||
.tabs a{font-size:12.5px;padding:9px 13px;color:var(--text2);text-decoration:none;border-bottom:2px solid transparent}
|
||||
.tabs a.on{color:var(--primary);border-bottom-color:var(--primary);font-weight:600}
|
||||
|
||||
.alerts{display:grid;grid-template-columns:1fr 1fr;gap:9px}
|
||||
.al{display:flex;gap:8px;align-items:flex-start;font-size:12px;padding:9px 12px;border-radius:var(--radius);border:1px solid}
|
||||
.al b{font-weight:600}
|
||||
.al.i{border-color:var(--primary);background:var(--primary-s);color:var(--text1)}
|
||||
.al.s{border-color:var(--ok);background:color-mix(in srgb,var(--ok) 8%,transparent);color:var(--text1)}
|
||||
.al.w{border-color:var(--warn);background:color-mix(in srgb,var(--warn) 11%,transparent);color:var(--text1)}
|
||||
.al.e{border-color:var(--err);background:color-mix(in srgb,var(--err) 8%,transparent);color:var(--text1)}
|
||||
|
||||
/* 元素级对照 */
|
||||
.elcmp{margin-top:12px;border:1px solid rgba(255,255,255,.09);border-radius:12px;overflow:hidden}
|
||||
table.el{width:100%;border-collapse:collapse;font-size:12px}
|
||||
table.el th,table.el td{border-bottom:1px solid rgba(255,255,255,.08);padding:8px 12px;vertical-align:middle}
|
||||
table.el th{background:rgba(255,255,255,.04);color:#cfd6e4;font-size:11.5px;text-align:left}
|
||||
table.el td:first-child{color:#8b94a7;font-size:11.5px;width:118px}
|
||||
table.el td .demo{display:inline-block;margin:0;padding:9px 12px;border:none;border-radius:8px;min-width:0}
|
||||
`;
|
||||
|
||||
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// ── UI 演示结构(只写一次,三套主题注入同一份)──
|
||||
const demoBody = () => `
|
||||
<div class="app">
|
||||
<aside class="side">
|
||||
<div class="brand">${mark('#FFFFFF', '#FFFFFF', 30)}<span><b>甄房</b><em>ZHENFANG</em></span></div>
|
||||
<a class="on"><span class="d"></span>工作台</a>
|
||||
<a><span class="d"></span>房源管理</a>
|
||||
<a><span class="d"></span>客源管理</a>
|
||||
<a><span class="d"></span>交易管理</a>
|
||||
<a><span class="d"></span>钥匙管理</a>
|
||||
<a><span class="d"></span>我的佣金</a>
|
||||
<a><span class="d"></span>系统设置</a>
|
||||
</aside>
|
||||
<div class="main">
|
||||
<div class="top">
|
||||
<div class="crumb">房源管理 / <b>二手房房源</b></div>
|
||||
<div class="usr"><span class="av">张</span>张伟 · 滨江门店</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="kpis">
|
||||
<div class="kpi"><div class="t">在售房源</div><div class="v">1,284<em>套</em></div><div class="d up">↑ 3.2% 较上周</div></div>
|
||||
<div class="kpi"><div class="t">本周新增客源</div><div class="v">96<em>位</em></div><div class="d up">↑ 12 位</div></div>
|
||||
<div class="kpi"><div class="t">待跟进</div><div class="v">23<em>条</em></div><div class="d dn">↓ 5 条</div></div>
|
||||
<div class="kpi gold"><div class="t">本月预估佣金</div><div class="v">86.4<em>万</em></div><div class="d up">↑ 8.6%</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card filters">
|
||||
<div class="f"><label>房源编码</label><input placeholder="SH-ES-..."></div>
|
||||
<div class="f"><label>所属楼盘</label><select><option>滨江壹号</option><option>钱江新城</option></select></div>
|
||||
<div class="f"><label>委托起始日</label><input value="2026-09-01"></div>
|
||||
<div class="f err"><label>维护人</label><input value="张"><span class="msg">请输入完整姓名</span></div>
|
||||
<div class="btns">
|
||||
<button class="btn pri">查询</button>
|
||||
<button class="btn">重置</button>
|
||||
<button class="btn gho">高级筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="tabs">
|
||||
<a class="on">全部 1284</a><a>有效 892</a><a>维护中 236</a><a>已成交 121</a><a>暂缓 35</a>
|
||||
</div>
|
||||
<table class="tbl">
|
||||
<thead><tr>
|
||||
<th>房源编码</th><th>楼盘 / 房号</th><th>面积</th><th>售价</th><th>状态</th><th>维护人</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="code">SH-ES-202609-00037</span></td>
|
||||
<td>滨江壹号 3幢 1802<span class="sub">滨江区 · 钱塘江畔</span></td>
|
||||
<td>128.5 ㎡</td><td>1,285 万</td><td><span class="tag ok">有效</span></td><td>张伟</td>
|
||||
<td class="op"><a>查看</a><a>编辑</a></td></tr>
|
||||
<tr class="hov"><td><span class="code">SH-ES-202609-00041</span></td>
|
||||
<td>钱江新城 7幢 2201<span class="sub">上城区 · 万象城</span></td>
|
||||
<td>156.0 ㎡</td><td>1,860 万</td><td><span class="tag pri">待检核</span></td><td>李敏</td>
|
||||
<td class="op"><a>查看</a><a>编辑</a></td></tr>
|
||||
<tr class="sel"><td><span class="code">SH-ES-202609-00052</span></td>
|
||||
<td>滨江壹号 5幢 901<span class="sub">滨江区 · 钱塘江畔</span></td>
|
||||
<td>98.2 ㎡</td><td>920 万</td><td><span class="tag gold">独家</span></td><td>王强</td>
|
||||
<td class="op"><a>查看</a><a>编辑</a></td></tr>
|
||||
<tr><td><span class="code">SH-ES-202609-00058</span></td>
|
||||
<td>绿城桂语 2幢 1503<span class="sub">西湖区 · 文三路</span></td>
|
||||
<td>142.6 ㎡</td><td>1,420 万</td><td><span class="tag wn">维护中</span></td><td>张伟</td>
|
||||
<td class="op"><a>查看</a><a>编辑</a></td></tr>
|
||||
<tr><td><span class="code">SH-ES-202609-00063</span></td>
|
||||
<td>万科未来 1幢 608<span class="sub">余杭区 · 未来科技城</span></td>
|
||||
<td>89.0 ㎡</td><td>610 万</td><td><span class="tag er">已下架</span></td><td>赵磊</td>
|
||||
<td class="op"><a>查看</a><a class="dan">删除</a></td></tr>
|
||||
<tr><td><span class="code">SH-ES-202609-00071</span></td>
|
||||
<td>滨江壹号 2幢 1101<span class="sub">滨江区 · 钱塘江畔</span></td>
|
||||
<td>110.4 ㎡</td><td>1,080 万</td><td><span class="tag nu">草稿</span></td><td>李敏</td>
|
||||
<td class="op"><a>查看</a><a>编辑</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pager">共 1,284 条
|
||||
<span class="pg on">1</span><span class="pg">2</span><span class="pg">3</span>
|
||||
<span class="pg">…</span><span class="pg">65</span>
|
||||
<span style="margin-left:auto"></span>
|
||||
<span style="font-size:11.5px">每页 20 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alerts">
|
||||
<div class="al i"><span>ℹ</span><span><b>提示:</b>该房源委托期将于 7 天后到期,请及时续签。</span></div>
|
||||
<div class="al s"><span>✓</span><span><b>成功:</b>房源 SH-ES-202609-00037 已提交检核。</span></div>
|
||||
<div class="al w"><span>⚠</span><span><b>警告:</b>钥匙编号与系统记录不一致,请核对。</span></div>
|
||||
<div class="al e"><span>✕</span><span><b>错误:</b>售价不能低于政府指导价,请修改后重试。</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:13px;display:flex;gap:9px;flex-wrap:wrap;align-items:center">
|
||||
<button class="btn pri">主按钮</button>
|
||||
<button class="btn pri h">悬停态</button>
|
||||
<button class="btn pri a">按下态</button>
|
||||
<button class="btn gho">次要按钮</button>
|
||||
<button class="btn dan">危险操作</button>
|
||||
<button class="btn tx">文字按钮</button>
|
||||
<button class="btn dis">禁用</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// 元素级对照(每行一个元素,三列并排)
|
||||
const EL_ROWS = [
|
||||
['主按钮', t => '<div class="demo" data-theme="' + t + '"><button class="btn pri">查询房源</button></div>'],
|
||||
['次要按钮', t => '<div class="demo" data-theme="' + t + '"><button class="btn gho">高级筛选</button></div>'],
|
||||
['危险按钮', t => '<div class="demo" data-theme="' + t + '"><button class="btn dan">删除房源</button></div>'],
|
||||
['状态标签', t => '<div class="demo" data-theme="' + t + '"><span class="tag ok">有效</span> <span class="tag wn">维护中</span> <span class="tag er">已下架</span> <span class="tag pri">待检核</span> <span class="tag gold">独家</span></div>'],
|
||||
['表单', t => '<div class="demo" data-theme="' + t + '"><div class="f" style="width:150px"><label>房源编码</label><input placeholder="请输入"></div></div>'],
|
||||
['表单错误态', t => '<div class="demo" data-theme="' + t + '"><div class="f err" style="width:150px"><label>维护人</label><input value="张"><span class="msg">请输入完整姓名</span></div></div>'],
|
||||
['表头 / 行', t => '<div class="demo" data-theme="' + t + '"><table class="tbl" style="width:290px"><thead><tr><th>房源编码</th><th>状态</th></tr></thead><tbody><tr><td><span class="code">SH-...037</span></td><td><span class="tag ok">有效</span></td></tr><tr class="sel"><td><span class="code">SH-...052</span></td><td><span class="tag gold">独家</span></td></tr></tbody></table></div>'],
|
||||
['分页 / 页签', t => '<div class="demo" data-theme="' + t + '"><div class="tabs" style="padding:0"><a class="on">全部</a><a>有效</a></div><div class="pager" style="padding:9px 0"><span class="pg on">1</span><span class="pg">2</span><span class="pg">3</span></div></div>'],
|
||||
['提示条', t => '<div class="demo" data-theme="' + t + '"><div class="al w"><span>⚠</span><span>钥匙编号不一致,请核对。</span></div></div>']
|
||||
];
|
||||
|
||||
const posOf = t => THEMES.findIndex(x => x.id === t) + 2; // 第 1 列是元素名
|
||||
|
||||
const html = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">
|
||||
<title>甄房业务系统 · 色彩体系选型对比(Carbon / Fiori / Ant Design)</title>
|
||||
<style>${CSS}</style></head><body><div class="wrap">
|
||||
|
||||
<h1>甄房业务系统 · <b>色彩体系选型对比</b></h1>
|
||||
<div class="lead">
|
||||
面向「经纪人高频长时使用」的业务平台,从全球主流 SaaS / ERP 设计系统中筛选出综合评分最高的三套,
|
||||
各自结合已定稿的 LOGO(框内 Forte Z + 甄房墨蓝 <code>#0C2340</code>)开发完整界面色彩方案。<br>
|
||||
三套方案的界面结构完全相同,只替换色彩令牌 —— 保证对比公平。<b>所有对比度数值由页面运行时真实计算</b>,非人工填写。
|
||||
</div>
|
||||
|
||||
<h2>一、调研结论<span>数据来源:三家官方设计系统文档</span></h2>
|
||||
<p class="note">筛选维度与权重:数据密度适配 25% | 长时使用舒适度 20% | 无障碍可推导性 15% | 品牌融合度 15% | 落地迁移成本 15% | 行业验证广度 10%。<br>
|
||||
注意:「无障碍」一列评的是<b>体系提供了多少可推导的对比度规则</b>,与默认色盘的实际通过率并不同向 —— Carbon 规则最硬,但它把 Yellow 30 定位成警报条底色,被当文字色用就会不达标;AntD 规则最少,默认色盘实测反而是问题最多的。所以下面每一套都给出<b>实测通过率</b>与<b>生产落地必改项</b>。</p>
|
||||
<table class="cmp">
|
||||
<thead><tr><th>体系</th><th>定位与代表产品</th><th class="num">密度25%</th><th class="num">舒适20%</th><th class="num">无障碍15%</th><th class="num">品牌15%</th><th class="num">成本15%</th><th class="num">验证10%</th><th class="num">加权总分</th></tr></thead>
|
||||
<tbody>
|
||||
${THEMES.map(t => `<tr><td><b>${t.sys}</b></td><td style="color:#98a1b3">${
|
||||
t.id === 'carbon' ? 'IBM 全线产品 · 数据密集型企业级标杆'
|
||||
: t.id === 'fiori' ? 'SAP S/4HANA、Ariba 等 ERP 全线'
|
||||
: '阿里/蚂蚁中后台生态 · 国内事实标准'}</td>
|
||||
${['dense','comfort','a11y','brand','cost','proven'].map(k => '<td class="num">' + t.score[k].toFixed(1) + '</td>').join('')}
|
||||
<td class="num tot">${t.total.toFixed(2)}</td></tr>`).join('')}
|
||||
</tbody></table>
|
||||
<div class="src">
|
||||
依据:Carbon 官方明确「Neutral Gray family is dominant」并规定「避免使用中间调」,Blue 60 <code>#0f62fe</code> 为全局 primary action,四套官方主题(White / Gray 10 / Gray 90 / Gray 100);<br>
|
||||
Fiori Horizon 提供 S0–S8 表面、T1–T9 文字、边框、语义、强调五组语义令牌,并给出「相隔 4 级 = 3:1、相隔 5 级 = 4.5:1」的可推导对比度规则;<br>
|
||||
Ant Design 5 以 <code>colorPrimary #1677ff</code> 为种子令牌,色板由算法生成 12 色 × 10 阶,第 6 阶满足 WCAG 4.5:1。<br>
|
||||
参考:<a href="https://carbondesignsystem.com/elements/color/tokens/">Carbon Color Tokens</a> ·
|
||||
<a href="https://experience.sap.com/fiori-design-web/v1-110/theming/">SAP Fiori Theming</a> ·
|
||||
<a href="https://ant-design.antgroup.com/docs/spec/colors-cn">Ant Design 色彩</a>
|
||||
</div>
|
||||
|
||||
<h2>二、三套方案的完整界面</h2>
|
||||
<p class="note">
|
||||
核心策略:<b>品牌色与交互色分离</b> —— 三套方案都把甄房墨蓝 <code>#0C2340</code> 固定在品牌/导航区(侧栏、Shell Bar、登录页),
|
||||
交互色各自沿用体系的官方主色(<code>#0F62FE</code> / <code>#0070F2</code> / <code>#1677FF</code>)。
|
||||
这样既保住品牌识别,又拿到该体系已验证的交互反馈与无障碍保障。
|
||||
</p>
|
||||
<div class="sumline" id="wcag-summary"></div>
|
||||
${THEMES.map(t => `
|
||||
<div class="plan${t.final ? ' final' : ''}">
|
||||
<div class="hd"><h3>${t.name}</h3><span class="sys">${t.sys}</span>${t.final ? '<span class="finalmark">✓ 已定稿</span>' : ''}<span class="sc">加权 ${t.total.toFixed(2)} / 10</span></div>
|
||||
<div class="d">${t.desc}</div>
|
||||
<div class="demo" data-theme="${t.id}">${demoBody()}</div>
|
||||
|
||||
<h3>色彩令牌</h3>
|
||||
<div class="tok">
|
||||
${t.tags.map(([n, v, r]) => '<div class="tk"><i style="background:' + v + '"></i><span class="n"><b>' + n + '</b><code>' + v + '</code></span><span class="r" style="color:#7f8899;border-color:rgba(255,255,255,.14)">' + r + '</span></div>').join('')}
|
||||
</div>
|
||||
|
||||
<h3>对比度实测(WCAG,AA 正文 4.5:1 / AA 大字与图形 3:1)</h3>
|
||||
<div class="tok" id="wcag-${t.id}"></div>
|
||||
|
||||
<div class="pc">
|
||||
<div><h4 style="color:#5ee08a">优点</h4><ul class="good">${t.pros.map(x => '<li>' + x + '</li>').join('')}</ul></div>
|
||||
<div><h4 style="color:#f0a55c">代价与不足</h4><ul class="bad">${t.cons.map(x => '<li>' + x + '</li>').join('')}</ul></div>
|
||||
</div>
|
||||
<div class="pc" style="grid-template-columns:1fr"><div><h4 style="color:#8ab4f8">适用场景</h4><ul class="fit">${t.fit.map(x => '<li>' + x + '</li>').join('')}</ul></div></div>
|
||||
|
||||
<h3>生产落地必改项<span style="font-size:11px;font-weight:400;color:#8b94a7;margin-left:8px">依据上表实测结果</span></h3>
|
||||
${t.fixes.length
|
||||
? '<table class="cmp fix"><thead><tr><th>用途</th><th>原值</th><th>建议改为</th><th>理由</th></tr></thead><tbody>' +
|
||||
t.fixes.map(f => '<tr><td><b>' + f[0] + '</b></td><td><code>' + f[1] + '</code></td><td><code>' + f[2] +
|
||||
'</code></td><td style="color:#98a1b3">' + f[3] + '</td></tr>').join('') + '</tbody></table>'
|
||||
: '<div class="fixok">本方案 12 项对比度检查全部达标,无需为无障碍做任何色值改动 —— 三套中唯一「开箱即合规」的方案。</div>'}
|
||||
</div>`).join('')}
|
||||
|
||||
<h2>三、主要界面元素横向对照<span>同一元素 · 三套色彩并排</span></h2>
|
||||
<div class="elcmp"><table class="el">
|
||||
<thead><tr><th>元素</th>${THEMES.map(t => '<th>' + t.name.split('·')[1].trim() + '</th>').join('')}</tr></thead>
|
||||
<tbody>
|
||||
${EL_ROWS.map(([name, fn]) => '<tr><td>' + name + '</td>' + THEMES.map(t => '<td>' + fn(t.id) + '</td>').join('') + '</tr>').join('')}
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
<h2>四、高频长时使用的统一优化层<span>三套方案共用</span></h2>
|
||||
<p class="note">这是针对「经纪人每天连续使用 6-8 小时」这一前提,在三套体系之上叠加的四条约束。三套方案均已落实。</p>
|
||||
<table class="cmp">
|
||||
<thead><tr><th>优化项</th><th>做法</th><th>解决什么</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>深色品牌侧栏</b></td><td>侧栏统一用 <code>#0C2340</code> 而非浅灰</td><td>减少整屏亮度、形成稳定视觉锚点;浅灰侧栏在长时间使用中会与内容区「糊在一起」,找不到导航边界</td></tr>
|
||||
<tr><td><b>斑马纹极低对比</b></td><td>条纹底色与白底差值控制在 2–3%(<code>#F4F4F4</code>/<code>#F5F6F7</code>/<code>#F5F5F5</code>)</td><td>帮助横向追行不串行,但不会产生条纹眩光;这是数据表最高频的疲劳源</td></tr>
|
||||
<tr><td><b>语义色降饱和</b></td><td>状态标签用「描边+浅底」而非实心色块,浅底透明度压到 8–12%</td><td>一屏内可能有几十个状态标签,实心高饱和色块是最大的刺眼来源</td></tr>
|
||||
<tr><td><b>金色只做点</b></td><td>品牌金 <code>#C6A15B</code> 只用于 logo、关键数字、选中指示条,占比 ≤5%</td><td>保住品牌质感,同时避免金色大面积铺陈显廉价</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<h2>五、选型建议</h2>
|
||||
<p class="note"><b style="color:#5ee08a">本次结论:已定稿 方案 B · Fiori 商务派</b>(2026-09-19)。定稿理由:三套中唯一 12/12 对比度开箱合规、语义令牌分层最完整、选中态用 8% 蓝底而非实色块(长时使用最不刺眼),且官方 Quick Theming 就是「替换 logo + 主品牌色」,与已定稿的甄房墨蓝最契合。下表保留完整选型逻辑,供后续复盘与其他产品线参考。</p>
|
||||
<table class="cmp">
|
||||
<thead><tr><th>如果……</th><th>建议</th><th>理由</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>团队人少、迭代快,希望最快落地并降低长期维护成本</td><td><b>方案 C · AntD 亲和派</b></td><td>迁移成本最低、中文生态最全;代价是要按「生产落地必改项」把 6 处色值各下移一阶(改完视觉几乎无差别,但差别在无障碍上)</td></tr>
|
||||
<tr><td>需要严谨的层级与无障碍,且能接受工程感外观</td><td><b>方案 A · Carbon 严谨派</b></td><td>灰阶分层最严谨、对比度有官方硬约束;数据密度最高。仅「黄色被当文字色」与「圆角偏硬」两处需按建议调整</td></tr>
|
||||
<tr><td>交易与佣金等强流程模块、或需与财务/ERP 打通</td><td><b>方案 B · Fiori 商务派</b></td><td>状态与选中态表达最清楚,对比度规则可推导,符合企业级规范;三套中唯一 12/12 开箱合规</td></tr>
|
||||
<tr><td>想要「一次到位」但不想赌</td><td><b>混合:Carbon 骨架 + AntD 语义色</b></td><td>用 Carbon 的灰阶分层与密度打底,语义色采用国内用户熟悉的 AntD 色相并降饱和(沿用 C 的必改项色值);这是我推荐的落地路径</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
// ── 令牌数据(由 Node 侧注入)──
|
||||
var THEMES = ${JSON.stringify(THEMES.map(t => ({ id: t.id, name: t.name, sys: t.sys, total: t.total })))};
|
||||
// ── 真实计算 WCAG 对比度 ──────────────────────────────────────
|
||||
function toRgb(c){
|
||||
if (c.indexOf('rgba') === 0) {
|
||||
return c.slice(5, c.length - 1).split(',').slice(0, 3).map(Number);
|
||||
}
|
||||
var h = c.replace('#', '');
|
||||
if (h.length === 3) h = h.split('').map(function(x){ return x + x; }).join('');
|
||||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
||||
}
|
||||
function lum(c){
|
||||
var v = toRgb(c).map(function(x){ x/=255; return x <= .03928 ? x/12.92 : Math.pow((x+.055)/1.055, 2.4); });
|
||||
return .2126*v[0] + .7152*v[1] + .0722*v[2];
|
||||
}
|
||||
function ratio(a,b){
|
||||
var l1 = lum(a), l2 = lum(b);
|
||||
return (Math.max(l1,l2) + .05) / (Math.min(l1,l2) + .05);
|
||||
}
|
||||
function blend(fg, bg, alpha){
|
||||
var f = toRgb(fg), b = toRgb(bg);
|
||||
return '#' + f.map(function(x,i){ return Math.round(x*alpha + b[i]*(1-alpha)).toString(16).padStart(2,'0'); }).join('');
|
||||
}
|
||||
var PAIRS = [
|
||||
['一级文字 / 白底', 'text1', '#FFFFFF', 4.5],
|
||||
['二级文字 / 白底', 'text2', '#FFFFFF', 4.5],
|
||||
['三级文字 / 白底', 'text3', '#FFFFFF', 4.5],
|
||||
['一级文字 / 次级底', 'text1', 'bg2', 4.5],
|
||||
['白字 / 交互主色(按钮)', '#FFFFFF', 'primary', 4.5],
|
||||
['主色文字 / 白底(链接)', 'primary', '#FFFFFF', 4.5],
|
||||
['成功 / 白底', 'ok', '#FFFFFF', 3],
|
||||
['警告 / 白底', 'warn', '#FFFFFF', 3],
|
||||
['错误 / 白底', 'err', '#FFFFFF', 3],
|
||||
['错误 / 白底(正文)', 'err', '#FFFFFF', 4.5],
|
||||
['白字 / 品牌墨蓝(侧栏)', '#FFFFFF', 'brand', 4.5],
|
||||
['侧栏二级字 / 品牌墨蓝', null, 'brand', 4.5, '#B9C2D0']
|
||||
];
|
||||
var SUMMARY = [];
|
||||
THEMES.forEach(function(t){
|
||||
var el = document.getElementById('wcag-' + t.id);
|
||||
if (!el) return;
|
||||
var dummy = document.querySelector('.demo[data-theme="' + t.id + '"]');
|
||||
var cs = getComputedStyle(dummy);
|
||||
var val = function(k){ return k.charAt(0) === '#' ? k : cs.getPropertyValue('--' + k).trim(); };
|
||||
var out = [], bad = [];
|
||||
PAIRS.forEach(function(p){
|
||||
var fg = p[4] ? p[4] : val(p[1]); // p[4] 为直接色值覆盖(可选)
|
||||
var bg = val(p[2]);
|
||||
if (bg.indexOf('rgba') === 0) { bg = blend(bg, '#FFFFFF', parseFloat(bg.split(',')[3])); }
|
||||
var r = ratio(fg, bg);
|
||||
var req = p[3];
|
||||
if (r < req) bad.push(p[0] + ' ' + r.toFixed(2) + ':1');
|
||||
out.push('<div class="tk"><i style="background:' + bg + ';border-color:rgba(255,255,255,.2)"></i>' +
|
||||
'<span class="n"><b>' + p[0] + '</b><code>' + r.toFixed(2) + ':1 要求 ≥' + req + ':1</code></span>' +
|
||||
'<span class="r ' + (r >= req ? 'pass' : 'fail') + '">' + (r >= req ? '通过' : '未达标') + '</span></div>');
|
||||
});
|
||||
el.innerHTML = out.join('');
|
||||
SUMMARY.push({ id: t.id, name: t.name, sys: t.sys, total: t.total, pass: PAIRS.length - bad.length, n: PAIRS.length, bad: bad });
|
||||
});
|
||||
var sumEl = document.getElementById('wcag-summary');
|
||||
if (sumEl) {
|
||||
sumEl.innerHTML = SUMMARY.map(function(s){
|
||||
return '<div><b>' + s.sys + '</b> WCAG 实测 <em class="' + (s.bad.length ? 'no' : 'ok') + '">' +
|
||||
s.pass + ' / ' + s.n + ' 通过</em>' + (s.bad.length ? ',需修正:' + s.bad.join('、') : ',全部达标') + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
fs.writeFileSync(path.join(HERE, 'index.html'), html);
|
||||
console.log('已生成 theme/index.html ·', THEMES.length, '套方案');
|
||||
THEMES.forEach(t => console.log(' ', t.name, '加权', t.total.toFixed(2)));
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,95 @@
|
||||
// 登录页流星轨迹验证
|
||||
// 目的:确认「流星进入视口期间始终不透明」,且「透明度开始变化时整条流星已在视口外」
|
||||
// 用法: node _design-test/verify-meteor.js [视口宽] [视口高] [试验次数]
|
||||
const VW = Number(process.argv[2] || 1280)
|
||||
const VH = Number(process.argv[3] || 720)
|
||||
const N = Number(process.argv[4] || 20000)
|
||||
|
||||
// 动画时间轴(与 login.vue 的 @keyframes meteor-shower 一致)
|
||||
// 0% 透明度 0 → 0.5% 变 1 → 60% 仍为 1 → 66.7% 变 0 → 100% 保持 0
|
||||
// 位移在 0%~60% 内从起点线性走到终点,60% 之后停在终点
|
||||
const FLY_END = 0.60 // 飞行结束(到达终点)的进度
|
||||
const FADE_IN_END = 0.005 // 淡入完成的进度(0.5%)
|
||||
const FADE_OUT_START = 0.60 // 淡出开始的进度
|
||||
|
||||
const inside = (x, y) => x >= 0 && x <= VW && y >= 0 && y <= VH
|
||||
|
||||
// 判断「头部 + 拖尾」这条线段是否与视口相交(沿线采样)
|
||||
function streakVisible(hx, hy, ux, uy, tail) {
|
||||
for (let i = 0; i <= 40; i++) {
|
||||
const s = (i / 40) * tail
|
||||
if (inside(hx - ux * s, hy - uy * s)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function headAt(c) { return Math.min(c / FLY_END, 1) } // 进度 -> 路径参数
|
||||
|
||||
function build(variant) {
|
||||
const tail = 90 + Math.random() * 70
|
||||
const margin = tail + 60
|
||||
const theta = (25 + Math.random() * 30) * Math.PI / 180
|
||||
const ux = -Math.cos(theta), uy = Math.sin(theta)
|
||||
|
||||
if (variant === 'old') {
|
||||
// 旧实现:起终点都取在视口内部
|
||||
const sx = VW * (0.50 + Math.random() * 0.50)
|
||||
const sy = VH * (Math.random() * 0.35)
|
||||
const ex = VW * (Math.random() * 0.30)
|
||||
const ey = VH * (0.85 + Math.random() * 0.20)
|
||||
return { sx, sy, tx: ex - sx, ty: ey - sy, ux: (sx - ex), uy: (sy - ey), tail, margin }
|
||||
}
|
||||
// 新实现
|
||||
let sx, sy
|
||||
if (Math.random() < 0.5) { sx = VW * (0.45 + Math.random() * 0.65); sy = -margin }
|
||||
else { sx = VW + margin; sy = VH * (0.10 + Math.random() * 0.40) }
|
||||
const t = Math.min((VH + margin - sy) / uy, (-margin - sx) / ux)
|
||||
return { sx, sy, tx: ux * t, ty: uy * t, ux, uy, tail, margin }
|
||||
}
|
||||
|
||||
function analyze(variant) {
|
||||
let worstFadeWhileVisible = 0 // 可见期间透明度掉到 1 以下的最大程度(0 = 完美)
|
||||
let maxVisibleAtFlyEnd = 0 // 飞行结束时仍可见的比例
|
||||
let minEnter = 1, maxEnter = 0, minExit = 1, maxExit = 0
|
||||
const visAt = []
|
||||
for (let n = 0; n < N; n++) {
|
||||
const m = build(variant)
|
||||
const ulen = Math.hypot(m.ux, m.uy) || 1
|
||||
const ux = m.ux / ulen, uy = m.uy / ulen // 单位化的「拖尾指向」
|
||||
let enter = -1, exit = -1
|
||||
for (let i = 0; i <= 600; i++) {
|
||||
const c = i / 600
|
||||
const f = headAt(c)
|
||||
const hx = m.sx + m.tx * f, hy = m.sy + m.ty * f
|
||||
const vis = streakVisible(hx, hy, ux, uy, m.tail)
|
||||
if (vis && enter < 0) enter = c
|
||||
if (vis) exit = c
|
||||
}
|
||||
if (enter < 0) continue
|
||||
visAt.push(enter)
|
||||
if (enter < minEnter) minEnter = enter
|
||||
if (enter > maxEnter) maxEnter = enter
|
||||
if (exit < minExit) minExit = exit
|
||||
if (exit > maxExit) maxExit = exit
|
||||
// 可见窗口内透明度是否恒为 1
|
||||
if (enter < FADE_IN_END) worstFadeWhileVisible = Math.max(worstFadeWhileVisible, FADE_IN_END - enter)
|
||||
if (exit > FADE_OUT_START) worstFadeWhileVisible = Math.max(worstFadeWhileVisible, exit - FADE_OUT_START)
|
||||
maxVisibleAtFlyEnd = Math.max(maxVisibleAtFlyEnd, Math.max(0, exit - FADE_OUT_START))
|
||||
}
|
||||
visAt.sort((a, b) => a - b)
|
||||
const med = visAt[Math.floor(visAt.length / 2)] || 0
|
||||
return { minEnter, maxEnter, medEnter: med, minExit, maxExit, worstFadeWhileVisible, maxVisibleAtFlyEnd, count: visAt.length }
|
||||
}
|
||||
|
||||
for (const v of ['old', 'new']) {
|
||||
const r = analyze(v)
|
||||
const f1 = n => (n * 100).toFixed(1)
|
||||
console.log((v === 'old' ? '【旧】' : '【新】') + ' 实现 视口 ' + VW + 'x' + VH + ' 有效样本 ' + r.count)
|
||||
console.log(' 可见区间:进入 ' + f1(r.minEnter) + '%~' + f1(r.maxEnter) + '% 离开 ' +
|
||||
f1(r.minExit) + '%~' + f1(r.maxExit) + '%')
|
||||
console.log(' 可见期间透明度不足的最大窗口:' + f1(r.worstFadeWhileVisible) + '% of 周期' +
|
||||
(r.worstFadeWhileVisible <= 0.0001 ? ' ✓ 无(可见时恒为不透明)' : ' ← 会在屏内淡入/淡出'))
|
||||
console.log(' 飞行结束时仍未出屏的量:' + f1(r.maxVisibleAtFlyEnd) + '%' +
|
||||
(r.maxVisibleAtFlyEnd <= 0.0001 ? ' ✓ 整条流星已出屏' : ' ← 未出屏就已淡出'))
|
||||
console.log('')
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
@import 'e:/KF_7/ZhenOS-ZF/ruoyi-ui/src/assets/styles/variables.scss';
|
||||
@import 'e:/KF_7/ZhenOS-ZF/ruoyi-ui/src/assets/styles/mixin.scss';
|
||||
@import 'e:/KF_7/ZhenOS-ZF/ruoyi-ui/src/assets/styles/theme.scss';
|
||||
@import 'e:/KF_7/ZhenOS-ZF/ruoyi-ui/src/assets/styles/sidebar.scss';
|
||||
@import 'e:/KF_7/ZhenOS-ZF/ruoyi-ui/src/assets/styles/btn.scss';
|
||||
@import 'e:/KF_7/ZhenOS-ZF/ruoyi-ui/src/assets/styles/ruoyi.scss';
|
||||
@import 'e:/KF_7/ZhenOS-ZF/ruoyi-ui/src/assets/styles/element-ui.scss';
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,224 @@
|
||||
// 临时生成脚本:20 款底部导航图标方案(发房=新增加号)(勿提交)
|
||||
const fs = require('fs');
|
||||
const path = 'js/data/sources/';
|
||||
global.window = {};
|
||||
|
||||
const RED = '#FF6700';
|
||||
const GRAY = '#999999';
|
||||
|
||||
function load(sid) { eval(fs.readFileSync(path + sid + '.js', 'utf8')); return window['HGB_Source_' + sid]; }
|
||||
function findIcon(data, ref) {
|
||||
const namePart = ref.includes(':') ? ref.split(':')[1] : ref;
|
||||
return (data.icons || []).find(i => i.id === ref || i.name === namePart) || null;
|
||||
}
|
||||
|
||||
// style: native(path用currentColor) / fill(根fill) / stroke(根stroke) / color(自带颜色)
|
||||
const SOURCES = {
|
||||
fluent: { style: 'native' },
|
||||
'material-symbols': { style: 'native' },
|
||||
remix: { style: 'fill' },
|
||||
lucide: { style: 'stroke' },
|
||||
ionicons: { style: 'fill' },
|
||||
mingcute: { style: 'native' },
|
||||
tabler: { style: 'stroke' },
|
||||
phosphor: { style: 'native' },
|
||||
carbon: { style: 'native' },
|
||||
boxicons: { style: 'fill' },
|
||||
heroicons: { style: 'fill' },
|
||||
feather: { style: 'stroke' },
|
||||
twemoji: { style: 'color' },
|
||||
emojione: { style: 'color' },
|
||||
'fluent-emoji': { style: 'color' },
|
||||
'fluent-color': { style: 'color' },
|
||||
'flat-color-icons': { style: 'color' },
|
||||
pixelarticons: { style: 'native' },
|
||||
'icon-park': { style: 'color' },
|
||||
openmoji: { style: 'color' }
|
||||
};
|
||||
|
||||
function renderSVG(ic, style, color) {
|
||||
let attrs = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"';
|
||||
if (style === 'stroke') attrs += ' fill="none" stroke="' + color + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
|
||||
else if (style === 'fill') attrs += ' fill="' + color + '"';
|
||||
else if (style === 'native') attrs += ' style="color:' + color + '"';
|
||||
return '<svg ' + attrs + '>' + ic.svg + '</svg>';
|
||||
}
|
||||
|
||||
const M = (label, n, a) => ({ label, n, a });
|
||||
|
||||
const SOLUTIONS = [
|
||||
{ key:'01', title:'Fluent 微软圆润', desc:'微软现代风格,描边/实心双态', source:'fluent', pub:'fluent:add-circle-24-filled',
|
||||
items:[M('首页','fluent:apps-24-regular','fluent:apps-24-filled'),M('租房','fluent:building-24-regular','fluent:building-24-filled'),M('消息','fluent:chat-24-regular','fluent:chat-24-filled'),M('我的','fluent:person-24-regular','fluent:person-24-filled')] },
|
||||
{ key:'02', title:'Material Symbols 谷歌', desc:'Material Design,圆角厚重实心', source:'material-symbols', pub:'material-symbols:add-circle-rounded',
|
||||
items:[M('首页','material-symbols:apps-outline-rounded','material-symbols:apps-rounded'),M('租房','material-symbols:apartment-outline-rounded','material-symbols:apartment-rounded'),M('消息','material-symbols:chat-outline-rounded','material-symbols:chat-rounded'),M('我的','material-symbols:person-outline-rounded','material-symbols:person-rounded')] },
|
||||
{ key:'03', title:'Remix 锐克思', desc:'国内产品最常用,线条/实心双态', source:'remix', pub:'remix:add-circle-fill',
|
||||
items:[M('首页','remix:dashboard-line','remix:dashboard-fill'),M('租房','remix:building-4-line','remix:building-4-fill'),M('消息','remix:message-3-line','remix:message-3-fill'),M('我的','remix:user-3-line','remix:user-3-fill')] },
|
||||
{ key:'04', title:'Lucide 轻细线条', desc:'精致细线条,极简优雅', source:'lucide', pub:'lucide:circle-plus',
|
||||
items:[M('首页','lucide:layout-dashboard','lucide:layout-dashboard'),M('租房','lucide:building-2','lucide:building-2'),M('消息','lucide:message-square','lucide:message-square'),M('我的','lucide:user','lucide:user')] },
|
||||
{ key:'05', title:'Ionicons iOS 经典', desc:'iOS 同源,实心/描边双态', source:'ionicons', pub:'ionicons:add-circle',
|
||||
items:[M('首页','ionicons:grid-outline','ionicons:grid'),M('租房','ionicons:business-outline','ionicons:business'),M('消息','ionicons:chatbox-ellipses-outline','ionicons:chatbox-ellipses'),M('我的','ionicons:person-outline','ionicons:person')] },
|
||||
{ key:'06', title:'Mingcute 圆润可爱', desc:'圆头线条/实心双态,年轻活泼', source:'mingcute', pub:'mingcute:add-circle-fill',
|
||||
items:[M('首页','mingcute:layout-grid-line','mingcute:layout-grid-fill'),M('租房','mingcute:building-1-line','mingcute:building-1-fill'),M('消息','mingcute:chat-1-line','mingcute:chat-1-fill'),M('我的','mingcute:user-3-line','mingcute:user-3-fill')] },
|
||||
{ key:'07', title:'Tabler 现代线条', desc:'通用线条图标,简洁清晰', source:'tabler', pub:'tabler:circle-plus',
|
||||
items:[M('首页','tabler:layout-dashboard','tabler:layout-dashboard'),M('租房','tabler:building-community','tabler:building-community'),M('消息','tabler:message-2','tabler:message-2'),M('我的','tabler:user','tabler:user')] },
|
||||
{ key:'08', title:'Phosphor 多权重', desc:'可调粗细,四宫格/楼宇双态', source:'phosphor', pub:'phosphor:plus-circle-fill',
|
||||
items:[M('首页','phosphor:squares-four','phosphor:squares-four-fill'),M('租房','phosphor:buildings','phosphor:buildings-fill'),M('消息','phosphor:chat','phosphor:chat-fill'),M('我的','phosphor:user','phosphor:user-fill')] },
|
||||
{ key:'09', title:'Carbon IBM', desc:'IBM 设计语言,沉稳规范', source:'carbon', pub:'carbon:add-filled',
|
||||
items:[M('首页','carbon:dashboard','carbon:dashboard'),M('租房','carbon:building','carbon:building'),M('消息','carbon:chat','carbon:chat'),M('我的','carbon:user','carbon:user')] },
|
||||
{ key:'10', title:'Boxicons 简洁', desc:'经典简洁,圆润饱满', source:'boxicons', pub:'boxicons:bx-plus-circle',
|
||||
items:[M('首页','boxicons:bx-grid-alt','boxicons:bx-grid-alt'),M('租房','boxicons:bx-buildings','boxicons:bx-buildings'),M('消息','boxicons:bx-chat','boxicons:bx-chat'),M('我的','boxicons:bx-user','boxicons:bx-user')] },
|
||||
{ key:'11', title:'Heroicons Tailwind', desc:'Tailwind 官方,粗实心醒目', source:'heroicons', pub:'heroicons:plus-solid',
|
||||
items:[M('首页','heroicons:squares-2x2-solid','heroicons:squares-2x2-solid'),M('租房','heroicons:building-office-solid','heroicons:building-office-solid'),M('消息','heroicons:chat-bubble-left-right-solid','heroicons:chat-bubble-left-right-solid'),M('我的','heroicons:user-solid','heroicons:user-solid')] },
|
||||
{ key:'12', title:'Feather 轻盈线条', desc:'经典极简线条,干净利落', source:'feather', pub:'feather:plus-circle',
|
||||
items:[M('首页','feather:grid','feather:grid'),M('租房','feather:home','feather:home'),M('消息','feather:message-square','feather:message-square'),M('我的','feather:user','feather:user')] },
|
||||
{ key:'13', title:'Twemoji 多彩扁平', desc:'Twitter 同款扁平表情,彩色生动', source:'twemoji', pub:'twemoji:plus',
|
||||
items:[M('首页','twemoji:bar-chart','twemoji:bar-chart'),M('租房','twemoji:office-building','twemoji:office-building'),M('消息','twemoji:speech-balloon','twemoji:speech-balloon'),M('我的','twemoji:bust-in-silhouette','twemoji:bust-in-silhouette')] },
|
||||
{ key:'14', title:'EmojiOne 3D 立体', desc:'3D 渲染表情,立体质感色彩饱和', source:'emojione', pub:'emojione:heavy-plus-sign',
|
||||
items:[M('首页','emojione:bar-chart','emojione:bar-chart'),M('租房','emojione:office-building','emojione:office-building'),M('消息','emojione:speech-balloon','emojione:speech-balloon'),M('我的','emojione:bust-in-silhouette','emojione:bust-in-silhouette')] },
|
||||
{ key:'15', title:'Fluent Emoji 微软 3D', desc:'微软 Fluent 3D,圆润软萌', source:'fluent-emoji', pub:'fluent-emoji:plus',
|
||||
items:[M('首页','fluent-emoji:bar-chart','fluent-emoji:bar-chart'),M('租房','fluent-emoji:office-building','fluent-emoji:office-building'),M('消息','fluent-emoji:speech-balloon','fluent-emoji:speech-balloon'),M('我的','fluent-emoji:bust-in-silhouette','fluent-emoji:bust-in-silhouette')] },
|
||||
{ key:'16', title:'Fluent Color 彩色微软', desc:'微软彩色描边,蓝紫渐变', source:'fluent-color', pub:'fluent-color:add-circle-24',
|
||||
items:[M('首页','fluent-color:board-24','fluent-color:board-24'),M('租房','fluent-color:building-24','fluent-color:building-24'),M('消息','fluent-color:chat-24','fluent-color:chat-24'),M('我的','fluent-color:person-24','fluent-color:person-24')] },
|
||||
{ key:'17', title:'Flat Color 多彩玻璃', desc:'半透明玻璃质感,多彩扁平', source:'flat-color-icons', pub:'flat-color-icons:plus',
|
||||
items:[M('首页','flat-color-icons:grid','flat-color-icons:grid'),M('租房','flat-color-icons:home','flat-color-icons:home'),M('消息','flat-color-icons:comments','flat-color-icons:comments'),M('我的','flat-color-icons:businessman','flat-color-icons:businessman')] },
|
||||
{ key:'18', title:'Pixel 像素风', desc:'8-bit 像素,个性醒目', source:'pixelarticons', pub:'pixelarticons:add-box',
|
||||
items:[M('首页','pixelarticons:grid','pixelarticons:grid'),M('租房','pixelarticons:building','pixelarticons:building'),M('消息','pixelarticons:message','pixelarticons:message'),M('我的','pixelarticons:user','pixelarticons:user')] },
|
||||
{ key:'19', title:'Icon Park 双色', desc:'字节跳动,蓝黑双色描边', source:'icon-park', pub:'icon-park:add-one',
|
||||
items:[M('首页','icon-park:dashboard','icon-park:dashboard'),M('租房','icon-park:building-two','icon-park:building-two'),M('消息','icon-park:message','icon-park:message'),M('我的','icon-park:user','icon-park:user')] },
|
||||
{ key:'20', title:'OpenMoji 开源表情', desc:'开源 emoji,扁平简洁', source:'openmoji', pub:'openmoji:plus',
|
||||
items:[M('首页','openmoji:bar-chart','openmoji:bar-chart'),M('租房','openmoji:office-building','openmoji:office-building'),M('消息','openmoji:speech-balloon','openmoji:speech-balloon'),M('我的','openmoji:bust-in-silhouette','openmoji:bust-in-silhouette')] }
|
||||
];
|
||||
|
||||
const dataCache = {};
|
||||
for (const sid of Object.keys(SOURCES)) {
|
||||
try { dataCache[sid] = load(sid); } catch (e) { console.error('加载失败 ' + sid); process.exit(1); }
|
||||
}
|
||||
|
||||
let cards = '';
|
||||
const missList = [];
|
||||
|
||||
function pubBtn(sol) {
|
||||
const style = SOURCES[sol.source].style;
|
||||
const ic = findIcon(dataCache[sol.source], sol.pub);
|
||||
if (!ic) {
|
||||
missList.push(sol.pub);
|
||||
return { normal: '<div class="plus gray"><span>+</span></div>', active: '<div class="plus"><span>+</span></div>' };
|
||||
}
|
||||
if (style === 'color') {
|
||||
return {
|
||||
normal: '<div class="plus-colored gray">' + renderSVG(ic, 'color', '') + '</div>',
|
||||
active: '<div class="plus-colored">' + renderSVG(ic, 'color', '') + '</div>'
|
||||
};
|
||||
}
|
||||
return {
|
||||
normal: '<div class="plus gray">' + renderSVG(ic, style, '#ffffff') + '</div>',
|
||||
active: '<div class="plus">' + renderSVG(ic, style, '#ffffff') + '</div>'
|
||||
};
|
||||
}
|
||||
|
||||
for (const sol of SOLUTIONS) {
|
||||
const style = SOURCES[sol.source].style;
|
||||
const isColor = style === 'color';
|
||||
const res = [];
|
||||
for (const it of sol.items) {
|
||||
const nIc = findIcon(dataCache[sol.source], it.n);
|
||||
const aIc = findIcon(dataCache[sol.source], it.a);
|
||||
if (!nIc) missList.push(it.n);
|
||||
if (!aIc) missList.push(it.a);
|
||||
res.push({ label: it.label, normal: nIc ? renderSVG(nIc, style, GRAY) : '', active: aIc ? renderSVG(aIc, style, RED) : '' });
|
||||
}
|
||||
const plus = pubBtn(sol);
|
||||
const icCls = (r, active) => {
|
||||
if (isColor) return `<div class="ic${active ? '' : ' gray'}">${active ? r.active : r.normal}</div>`;
|
||||
return `<div class="ic${active ? ' active' : ''}">${active ? r.active : r.normal}</div>`;
|
||||
};
|
||||
const arr = [res[0], res[1], null, res[2], res[3]];
|
||||
const barNormal = `<div class="tabbar">${arr.map(r => r
|
||||
? `<div class="tabitem">${icCls(r, false)}<div class="lb">${r.label}</div></div>`
|
||||
: `<div class="tabitem"><div class="ic">${plus.active}</div><div class="lb">发房</div></div>`).join('')}</div>`;
|
||||
const barActive = `<div class="tabbar">${arr.map((r, i) => r
|
||||
? `<div class="tabitem${i === 0 ? ' active' : ''}">${icCls(r, i === 0)}<div class="lb">${r.label}</div></div>`
|
||||
: `<div class="tabitem"><div class="ic">${plus.active}</div><div class="lb">发房</div></div>`).join('')}</div>`;
|
||||
const strip = res.map(r => `<div class="state-cell">
|
||||
<div class="state-ic${isColor ? ' gray' : ''}">${r.normal}</div><div class="state-lb">${r.label}·未选</div>
|
||||
<div class="state-ic${isColor ? '' : ' active'}">${r.active}</div><div class="state-lb${isColor ? '' : ' active'}">${r.label}·选中</div>
|
||||
</div>`).join('') +
|
||||
`<div class="state-cell">
|
||||
<div class="state-ic">${plus.normal}</div><div class="state-lb">发房·未选</div>
|
||||
<div class="state-ic">${plus.active}</div><div class="state-lb active">发房·选中</div>
|
||||
</div>`;
|
||||
cards += `
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="card-title"><span class="tag">方案 ${sol.key}</span>${sol.title}</div>
|
||||
<div class="card-desc">${sol.desc}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mock-wrap">
|
||||
<div class="mock-col"><div class="mock-title">常规态(全部未选)</div><div class="phone">${barNormal}</div></div>
|
||||
<div class="mock-col"><div class="mock-title">选中态(首页高亮)</div><div class="phone">${barActive}</div></div>
|
||||
</div>
|
||||
<div class="state-strip">${strip}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>小虎租房 · 底部导航图标方案(20 款)</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; background:#f5f5f7; color:#333; padding:36px 28px 80px; }
|
||||
h1 { font-size:24px; margin-bottom:6px; }
|
||||
.sub { color:#888; font-size:13px; margin-bottom:10px; }
|
||||
.legend { font-size:12px; color:#aaa; margin-bottom:30px; line-height:1.7; }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(560px,1fr)); gap:24px; }
|
||||
.card { background:#fff; border-radius:16px; padding:22px 24px; box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
||||
.card-head { margin-bottom:16px; }
|
||||
.card-title { font-size:17px; font-weight:700; display:flex; align-items:center; gap:10px; }
|
||||
.tag { display:inline-block; background:#FF6700; color:#fff; font-size:12px; font-weight:600; border-radius:6px; padding:2px 8px; }
|
||||
.card-desc { font-size:12px; color:#999; margin-top:6px; }
|
||||
.card-body { display:flex; flex-wrap:wrap; gap:20px; align-items:flex-start; }
|
||||
.mock-wrap { display:flex; gap:16px; }
|
||||
.mock-col { text-align:center; }
|
||||
.mock-title { font-size:11px; color:#999; margin-bottom:8px; }
|
||||
.phone { width:270px; border-radius:18px; border:6px solid #222; background:#fff; overflow:hidden; }
|
||||
.phone::before { content:""; display:block; height:6px; }
|
||||
.tabbar { display:flex; height:52px; background:#fff; border-top:1px solid #f0f0f0; }
|
||||
.tabitem { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; }
|
||||
.tabitem .ic { width:23px; height:23px; color:#999; display:flex; align-items:center; justify-content:center; }
|
||||
.tabitem .ic svg { width:23px; height:23px; display:block; }
|
||||
.tabitem .ic.active { color:#FF6700; }
|
||||
.tabitem .lb { font-size:10px; color:#999; line-height:14px; }
|
||||
.tabitem.active .lb { color:#FF6700; }
|
||||
.gray { filter:grayscale(1); opacity:.62; }
|
||||
.plus { width:25px; height:25px; border-radius:50%; background:linear-gradient(135deg,#ff8a00,#FF6700); box-shadow:0 1px 3px rgba(255,103,0,.3); display:flex; align-items:center; justify-content:center; color:#fff; }
|
||||
.plus svg { width:17px; height:17px; display:block; }
|
||||
.plus-colored { width:32px; height:32px; margin-top:-3px; margin-left:-4px; filter:drop-shadow(0 2px 4px rgba(0,0,0,.18)); }
|
||||
.plus-colored svg { width:32px; height:32px; display:block; }
|
||||
.state-strip { display:flex; gap:14px; flex-wrap:wrap; }
|
||||
.state-cell { display:flex; flex-direction:column; align-items:center; gap:5px; background:#fafafa; border:1px solid #f0f0f0; border-radius:12px; padding:10px 8px; min-width:70px; }
|
||||
.state-ic { width:26px; height:26px; color:#999; display:flex; align-items:center; justify-content:center; }
|
||||
.state-ic svg { width:26px; height:26px; display:block; }
|
||||
.state-ic.active { color:#FF6700; }
|
||||
.state-ic .plus, .state-ic .plus-colored { margin:0; }
|
||||
.state-lb { font-size:10px; color:#aaa; }
|
||||
.state-lb.active { color:#FF6700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>小虎租房 · 底部导航图标方案(20 款)</h1>
|
||||
<div class="sub">数据来自「画个标」内置图标库 · 未选中 #999999 / 选中 ${RED}(小米橙)· 菜单:首页=工作台、租房=楼宇、发房=新增、消息、我的</div>
|
||||
<div class="legend">「发房」= 新增(加号)语义:单色方案为橙圆内置白色加号,彩色方案为大号彩色加号。彩色方案未选置灰、选中全彩;单色方案未选灰、选中橙。每卡含常规态 + 首页选中态两个手机框,以及 5 菜单双态对比格(含发房)。</div>
|
||||
<div class="grid">
|
||||
${cards}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
fs.writeFileSync('D:/SGP_KF3/ruyizu_web/docs/tabbar-icons.html', html, 'utf8');
|
||||
if (missList.length) console.log('缺失图标(' + missList.length + '): ' + missList.join(', '));
|
||||
else console.log('OK,已生成 docs/tabbar-icons.html(' + SOLUTIONS.length + ' 款方案,全部图标命中)');
|
||||
@@ -0,0 +1,220 @@
|
||||
// 临时生成脚本:从图标库提取底部导航 5 个菜单图标,生成双态对比预览页(勿提交)
|
||||
const fs = require('fs');
|
||||
const path = 'js/data/sources/';
|
||||
global.window = {};
|
||||
|
||||
const RED = '#C8102E';
|
||||
const GRAY = '#999999';
|
||||
|
||||
function load(sid) {
|
||||
eval(fs.readFileSync(path + sid + '.js', 'utf8'));
|
||||
return window['HGB_Source_' + sid];
|
||||
}
|
||||
function findIcon(data, ref) {
|
||||
const namePart = ref.includes(':') ? ref.split(':')[1] : ref;
|
||||
return (data.icons || []).find(i => i.id === ref || i.name === namePart) || null;
|
||||
}
|
||||
|
||||
// style: native(自带currentColor) / fill(根fill) / stroke(根stroke)
|
||||
const SOURCES = {
|
||||
fluent: { style: 'native', vb: 24 },
|
||||
'material-symbols': { style: 'native', vb: 24 },
|
||||
remix: { style: 'fill', vb: 24 },
|
||||
lucide: { style: 'stroke', vb: 24 },
|
||||
ionicons: { style: 'fill', vb: 512 },
|
||||
mingcute: { style: 'native', vb: 24 }
|
||||
};
|
||||
|
||||
function renderSVG(ic, src, color) {
|
||||
const { style, vb } = src;
|
||||
let attrs = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + vb + ' ' + vb + '"';
|
||||
if (style === 'stroke') attrs += ' fill="none" stroke="' + color + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
|
||||
else if (style === 'fill') attrs += ' fill="' + color + '"';
|
||||
return '<svg ' + attrs + '>' + ic.svg + '</svg>';
|
||||
}
|
||||
|
||||
const SOLUTIONS = [
|
||||
{
|
||||
key: 'A', title: 'Fluent 微软圆润', desc: '微软现代风格,描边/实心双态,与现款同源;「发房」= 房子+加号',
|
||||
source: 'fluent', pub: 'fluent:home-add-24-filled',
|
||||
items: [
|
||||
{ label: '首页', n: 'fluent:home-24-regular', a: 'fluent:home-24-filled' },
|
||||
{ label: '租房', n: 'fluent:search-24-regular', a: 'fluent:search-24-filled' },
|
||||
{ label: '消息', n: 'fluent:chat-24-regular', a: 'fluent:chat-24-filled' },
|
||||
{ label: '我的', n: 'fluent:person-24-regular', a: 'fluent:person-24-filled' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'B', title: 'Material Symbols 谷歌圆角', desc: 'Material Design 风格,圆角厚重,实心为主;「发房」= 房子+加号',
|
||||
source: 'material-symbols', pub: 'material-symbols:add-home-rounded',
|
||||
items: [
|
||||
{ label: '首页', n: 'material-symbols:home-outline-rounded', a: 'material-symbols:home-rounded' },
|
||||
{ label: '租房', n: 'material-symbols:search-outline-rounded', a: 'material-symbols:search-rounded' },
|
||||
{ label: '消息', n: 'material-symbols:chat-outline-rounded', a: 'material-symbols:chat-rounded' },
|
||||
{ label: '我的', n: 'material-symbols:person-outline-rounded', a: 'material-symbols:person-rounded' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'C', title: 'Remix 锐克思', desc: '国内产品最常见图标库,线条/实心双态;「发房」= 铅笔编辑',
|
||||
source: 'remix', pub: 'remix:edit-2-fill',
|
||||
items: [
|
||||
{ label: '首页', n: 'remix:home-5-line', a: 'remix:home-5-fill' },
|
||||
{ label: '租房', n: 'remix:search-line', a: 'remix:search-fill' },
|
||||
{ label: '消息', n: 'remix:message-3-line', a: 'remix:message-3-fill' },
|
||||
{ label: '我的', n: 'remix:user-3-line', a: 'remix:user-3-fill' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'D', title: 'Lucide 轻细线条', desc: '精致细线条风格,克制优雅;「发房」= 房子+加号(线条)',
|
||||
source: 'lucide', pub: 'lucide:house-plus',
|
||||
items: [
|
||||
{ label: '首页', n: 'lucide:house', a: 'lucide:house' },
|
||||
{ label: '租房', n: 'lucide:search', a: 'lucide:search' },
|
||||
{ label: '消息', n: 'lucide:message-square', a: 'lucide:message-square' },
|
||||
{ label: '我的', n: 'lucide:user', a: 'lucide:user' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'E', title: 'Ionicons iOS 经典', desc: 'iOS 系统同源图标,实心/描边双态;「发房」= 铅笔',
|
||||
source: 'ionicons', pub: 'ionicons:create',
|
||||
items: [
|
||||
{ label: '首页', n: 'ionicons:home-outline', a: 'ionicons:home' },
|
||||
{ label: '租房', n: 'ionicons:search-outline', a: 'ionicons:search' },
|
||||
{ label: '消息', n: 'ionicons:chatbox-ellipses-outline', a: 'ionicons:chatbox-ellipses' },
|
||||
{ label: '我的', n: 'ionicons:person-outline', a: 'ionicons:person' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'F', title: 'Mingcute 圆润可爱', desc: '圆头线条/实心双态,造型年轻活泼;「发房」= 铅笔编辑',
|
||||
source: 'mingcute', pub: 'mingcute:edit-2-fill',
|
||||
items: [
|
||||
{ label: '首页', n: 'mingcute:home-1-line', a: 'mingcute:home-1-fill' },
|
||||
{ label: '租房', n: 'mingcute:search-2-line', a: 'mingcute:search-2-fill' },
|
||||
{ label: '消息', n: 'mingcute:chat-1-line', a: 'mingcute:chat-1-fill' },
|
||||
{ label: '我的', n: 'mingcute:user-3-line', a: 'mingcute:user-3-fill' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 预加载所有源
|
||||
const dataCache = {};
|
||||
for (const sid of Object.keys(SOURCES)) {
|
||||
try { dataCache[sid] = load(sid); } catch (e) { console.error('加载失败 ' + sid); process.exit(1); }
|
||||
}
|
||||
|
||||
// 发房按钮:红色渐变圆钮 + 匹配图标(白色)
|
||||
function pubBtn(sol) {
|
||||
const src = SOURCES[sol.source];
|
||||
const ic = findIcon(dataCache[sol.source], sol.pub);
|
||||
if (!ic) { missList.push(sol.pub); return '<div class="plus"><span>+</span></div>'; }
|
||||
return '<div class="plus">' + renderSVG(ic, src, '#ffffff') + '</div>';
|
||||
}
|
||||
|
||||
let cards = '';
|
||||
const missList = [];
|
||||
for (const sol of SOLUTIONS) {
|
||||
const src = SOURCES[sol.source];
|
||||
const res = [];
|
||||
for (const it of sol.items) {
|
||||
const nIc = findIcon(dataCache[sol.source], it.n);
|
||||
const aIc = findIcon(dataCache[sol.source], it.a);
|
||||
if (!nIc) missList.push(it.n);
|
||||
if (!aIc) missList.push(it.a);
|
||||
res.push({
|
||||
label: it.label,
|
||||
normal: nIc ? renderSVG(nIc, src, GRAY) : '',
|
||||
active: aIc ? renderSVG(aIc, src, RED) : ''
|
||||
});
|
||||
}
|
||||
const plus = pubBtn(sol);
|
||||
|
||||
// 常规态 TabBar mock
|
||||
const arrNormal = [res[0], res[1], null, res[2], res[3]];
|
||||
const barActive = `<div class="tabbar">` +
|
||||
arrNormal.map((r, i) => r ? `<div class="tabitem${i === 0 ? ' active' : ''}"><div class="ic">${i === 0 ? r.active : r.normal}</div><div class="lb">${r.label}</div></div>` : `<div class="tabitem"><div class="ic">${plus}</div><div class="lb">发房</div></div>`)
|
||||
.join('') + `</div>`;
|
||||
const barNormalFull = `<div class="tabbar">` +
|
||||
arrNormal.map(r => r ? `<div class="tabitem"><div class="ic">${r.normal}</div><div class="lb">${r.label}</div></div>` : `<div class="tabitem"><div class="ic">${plus}</div><div class="lb">发房</div></div>`)
|
||||
.join('') + `</div>`;
|
||||
|
||||
const ids = [...sol.items.map(it => it.n), ...sol.items.map(it => it.a)].join('、');
|
||||
cards += `
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="card-title"><span class="tag">方案 ${sol.key}</span>${sol.title}</div>
|
||||
<div class="card-desc">${sol.desc}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mock-wrap">
|
||||
<div class="mock-col">
|
||||
<div class="mock-title">常规态</div>
|
||||
<div class="phone">${barNormalFull}</div>
|
||||
</div>
|
||||
<div class="mock-col">
|
||||
<div class="mock-title">选中态(首页高亮)</div>
|
||||
<div class="phone">${barActive}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state-strip">
|
||||
${res.map(r => `<div class="state-cell">
|
||||
<div class="state-ic">${r.normal}</div><div class="state-lb">${r.label}·未选</div>
|
||||
<div class="state-ic active">${r.active}</div><div class="state-lb active">${r.label}·选中</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="ids">图标:<code>${ids}</code></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>小虎租房 · 底部导航图标方案</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; background:#f5f5f7; color:#333; padding:36px 28px 80px; }
|
||||
h1 { font-size:24px; margin-bottom:6px; }
|
||||
.sub { color:#888; font-size:13px; margin-bottom:32px; }
|
||||
.card { background:#fff; border-radius:16px; padding:24px 28px; margin-bottom:24px; box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
||||
.card-head { margin-bottom:18px; }
|
||||
.card-title { font-size:18px; font-weight:700; display:flex; align-items:center; gap:10px; }
|
||||
.tag { display:inline-block; background:#C8102E; color:#fff; font-size:12px; font-weight:600; border-radius:6px; padding:2px 8px; }
|
||||
.card-desc { font-size:13px; color:#888; margin-top:8px; }
|
||||
.card-body { display:flex; flex-wrap:wrap; gap:28px; align-items:flex-start; }
|
||||
.mock-wrap { display:flex; gap:20px; }
|
||||
.mock-col { text-align:center; }
|
||||
.mock-title { font-size:12px; color:#999; margin-bottom:10px; }
|
||||
.phone { width:300px; border-radius:20px; border:6px solid #222; background:#fff; overflow:hidden; }
|
||||
.phone::before { content:""; display:block; height:8px; }
|
||||
.tabbar { display:flex; height:54px; background:#fff; border-top:1px solid #f0f0f0; }
|
||||
.tabitem { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; }
|
||||
.tabitem .ic { width:24px; height:24px; color:#999; }
|
||||
.tabitem .ic svg { width:24px; height:24px; display:block; }
|
||||
.tabitem .lb { font-size:10px; color:#999; line-height:14px; }
|
||||
.tabitem.active .ic { color:#C8102E; }
|
||||
.tabitem.active .lb { color:#C8102E; }
|
||||
.plus { width:26px; height:26px; border-radius:50%; background:linear-gradient(135deg,#e8433d,#C8102E); box-shadow:0 1px 3px rgba(200,16,46,.3); display:flex; align-items:center; justify-content:center; color:#fff; }
|
||||
.plus svg { width:18px; height:18px; display:block; }
|
||||
.state-strip { display:flex; gap:18px; flex-wrap:wrap; }
|
||||
.state-cell { display:flex; flex-direction:column; align-items:center; gap:6px; background:#fafafa; border:1px solid #f0f0f0; border-radius:12px; padding:12px 10px; min-width:76px; }
|
||||
.state-ic { width:26px; height:26px; color:#999; }
|
||||
.state-ic svg { width:26px; height:26px; display:block; }
|
||||
.state-ic.active { color:#C8102E; }
|
||||
.state-lb { font-size:10px; color:#aaa; }
|
||||
.state-lb.active { color:#C8102E; }
|
||||
.ids { width:100%; font-size:11px; color:#999; margin-top:6px; word-break:break-all; }
|
||||
.ids code { font-family:ui-monospace,Consolas,monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>小虎租房 · 底部导航图标方案</h1>
|
||||
<div class="sub">数据来自「画个标」内置图标库 · 共 6 套方案 · 未选中 #999999 / 选中 ${RED}(品牌红)·「发房」红色圆钮内置匹配图标(房子+加号/铅笔编辑)</div>
|
||||
${cards}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
fs.writeFileSync('D:/SGP_KF3/ruyizu_web/docs/tabbar-icons-preview.html', html, 'utf8');
|
||||
if (missList.length) console.log('缺失图标: ' + missList.join(', '));
|
||||
else console.log('OK,已生成 docs/tabbar-icons-preview.html(' + SOLUTIONS.length + ' 套方案,全部图标命中)');
|
||||
@@ -0,0 +1,197 @@
|
||||
// 临时生成脚本:富样式底部导航图标方案预览(勿提交)
|
||||
const fs = require('fs');
|
||||
const path = 'js/data/sources/';
|
||||
global.window = {};
|
||||
|
||||
const RED = '#C8102E';
|
||||
const GRAY = '#999999';
|
||||
|
||||
function load(sid) { eval(fs.readFileSync(path + sid + '.js', 'utf8')); return window['HGB_Source_' + sid]; }
|
||||
function findIcon(data, ref) {
|
||||
const namePart = ref.includes(':') ? ref.split(':')[1] : ref;
|
||||
return (data.icons || []).find(i => i.id === ref || i.name === namePart) || null;
|
||||
}
|
||||
|
||||
// 富样式源:全部 native(内容自带颜色 / currentColor),viewBox 24
|
||||
const SOURCES = {};
|
||||
['twemoji', 'emojione', 'fluent-emoji', 'flat-color-icons', 'pixelarticons', 'gameicons'].forEach(sid => { SOURCES[sid] = {}; });
|
||||
|
||||
function renderSVG(ic) {
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">' + ic.svg + '</svg>';
|
||||
}
|
||||
|
||||
const SOLUTIONS = [
|
||||
{
|
||||
key: 'G', title: 'Twemoji 多彩扁平表情', desc: 'Twitter 同款扁平表情,彩色生动、识别度高;未选中置灰、选中全彩;「发房」= 彩色铅笔', mode: 'color',
|
||||
source: 'twemoji', pub: 'twemoji:pencil',
|
||||
items: [
|
||||
{ label: '首页', n: 'twemoji:house', a: 'twemoji:house' },
|
||||
{ label: '租房', n: 'twemoji:magnifying-glass-tilted-left', a: 'twemoji:magnifying-glass-tilted-left' },
|
||||
{ label: '消息', n: 'twemoji:speech-balloon', a: 'twemoji:speech-balloon' },
|
||||
{ label: '我的', n: 'twemoji:bust-in-silhouette', a: 'twemoji:bust-in-silhouette' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'H', title: 'EmojiOne 3D 立体表情', desc: 'EmojiOne 3D 渲染表情,立体质感、色彩饱和;「发房」= 彩色铅笔', mode: 'color',
|
||||
source: 'emojione', pub: 'emojione:pencil',
|
||||
items: [
|
||||
{ label: '首页', n: 'emojione:house', a: 'emojione:house' },
|
||||
{ label: '租房', n: 'emojione:magnifying-glass-tilted-left', a: 'emojione:magnifying-glass-tilted-left' },
|
||||
{ label: '消息', n: 'emojione:speech-balloon', a: 'emojione:speech-balloon' },
|
||||
{ label: '我的', n: 'emojione:bust-in-silhouette', a: 'emojione:bust-in-silhouette' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'I', title: 'Fluent Emoji 微软 3D 卡通', desc: '微软 Fluent 3D 表情,圆润软萌、细节丰富;「发房」= 彩色铅笔', mode: 'color',
|
||||
source: 'fluent-emoji', pub: 'fluent-emoji:pencil',
|
||||
items: [
|
||||
{ label: '首页', n: 'fluent-emoji:house', a: 'fluent-emoji:house' },
|
||||
{ label: '租房', n: 'fluent-emoji:magnifying-glass-tilted-left', a: 'fluent-emoji:magnifying-glass-tilted-left' },
|
||||
{ label: '消息', n: 'fluent-emoji:speech-balloon', a: 'fluent-emoji:speech-balloon' },
|
||||
{ label: '我的', n: 'fluent-emoji:bust-in-silhouette', a: 'fluent-emoji:bust-in-silhouette' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'J', title: 'Flat Color Icons 多彩扁平', desc: '多彩半透明玻璃质感图标,商务活泼兼顾;「发房」= 彩色钥匙', mode: 'color',
|
||||
source: 'flat-color-icons', pub: 'flat-color-icons:key',
|
||||
items: [
|
||||
{ label: '首页', n: 'flat-color-icons:home', a: 'flat-color-icons:home' },
|
||||
{ label: '租房', n: 'flat-color-icons:search', a: 'flat-color-icons:search' },
|
||||
{ label: '消息', n: 'flat-color-icons:comments', a: 'flat-color-icons:comments' },
|
||||
{ label: '我的', n: 'flat-color-icons:businessman', a: 'flat-color-icons:businessman' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'K', title: 'Pixel 像素风', desc: '8-bit 像素图标,造型感强、辨识度高;「发房」= 红圆内置铅笔', mode: 'mono',
|
||||
source: 'pixelarticons', pub: 'pixelarticons:edit',
|
||||
items: [
|
||||
{ label: '首页', n: 'pixelarticons:home', a: 'pixelarticons:home' },
|
||||
{ label: '租房', n: 'pixelarticons:search', a: 'pixelarticons:search' },
|
||||
{ label: '消息', n: 'pixelarticons:message', a: 'pixelarticons:message' },
|
||||
{ label: '我的', n: 'pixelarticons:user', a: 'pixelarticons:user' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'L', title: 'Game Icons 游戏插画风', desc: '游戏插画质感、细节华丽,风格大胆独特;「发房」= 红圆内置羽毛笔', mode: 'mono',
|
||||
source: 'gameicons', pub: 'gameicons:quill-ink',
|
||||
items: [
|
||||
{ label: '首页', n: 'gameicons:house', a: 'gameicons:house' },
|
||||
{ label: '租房', n: 'gameicons:magnifying-glass', a: 'gameicons:magnifying-glass' },
|
||||
{ label: '消息', n: 'gameicons:chat-bubble', a: 'gameicons:chat-bubble' },
|
||||
{ label: '我的', n: 'gameicons:person', a: 'gameicons:person' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const dataCache = {};
|
||||
for (const sid of Object.keys(SOURCES)) {
|
||||
try { dataCache[sid] = load(sid); } catch (e) { console.error('加载失败 ' + sid); process.exit(1); }
|
||||
}
|
||||
|
||||
let cards = '';
|
||||
const missList = [];
|
||||
// 发房按钮:单色方案=红圆白图标;彩色方案=大号彩色相机图标(带投影)
|
||||
function pubBtn(sol) {
|
||||
const ic = findIcon(dataCache[sol.source], sol.pub);
|
||||
if (!ic) { missList.push(sol.pub); return '<div class="plus"><span>+</span></div>'; }
|
||||
if (sol.mode === 'mono') return '<div class="plus">' + renderSVG(ic) + '</div>';
|
||||
return '<div class="plus-colored">' + renderSVG(ic) + '</div>';
|
||||
}
|
||||
for (const sol of SOLUTIONS) {
|
||||
const res = [];
|
||||
for (const it of sol.items) {
|
||||
const nIc = findIcon(dataCache[sol.source], it.n);
|
||||
const aIc = findIcon(dataCache[sol.source], it.a);
|
||||
if (!nIc) missList.push(it.n);
|
||||
if (!aIc) missList.push(it.a);
|
||||
res.push({ label: it.label, normal: nIc ? renderSVG(nIc) : '', active: aIc ? renderSVG(aIc) : '' });
|
||||
}
|
||||
const mono = sol.mode === 'mono';
|
||||
const plus = pubBtn(sol);
|
||||
const icCls = (r, active) => mono
|
||||
? `<div class="ic${active ? ' active' : ''}">${active ? r.active : r.normal}</div>`
|
||||
: `<div class="ic${active ? '' : ' gray'}">${active ? r.active : r.normal}</div>`;
|
||||
const arr = [res[0], res[1], null, res[2], res[3]];
|
||||
const barNormal = `<div class="tabbar">${arr.map(r => r
|
||||
? `<div class="tabitem">${icCls(r, false)}<div class="lb">${r.label}</div></div>`
|
||||
: `<div class="tabitem"><div class="ic">${plus}</div><div class="lb">发房</div></div>`).join('')}</div>`;
|
||||
const barActive = `<div class="tabbar">${arr.map((r, i) => r
|
||||
? `<div class="tabitem${i === 0 ? ' active' : ''}">${icCls(r, i === 0)}<div class="lb">${r.label}</div></div>`
|
||||
: `<div class="tabitem"><div class="ic">${plus}</div><div class="lb">发房</div></div>`).join('')}</div>`;
|
||||
const strip = res.map(r => `<div class="state-cell">
|
||||
<div class="state-ic${mono ? '' : ' gray'}">${r.normal}</div><div class="state-lb">${r.label}·未选</div>
|
||||
<div class="state-ic${mono ? ' active' : ''}">${r.active}</div><div class="state-lb${mono ? ' active' : ''}">${r.label}·选中</div>
|
||||
</div>`).join('');
|
||||
const ids = [...sol.items.map(it => it.n), ...sol.items.map(it => it.a)].join('、');
|
||||
cards += `
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="card-title"><span class="tag">方案 ${sol.key}</span>${sol.title}</div>
|
||||
<div class="card-desc">${sol.desc}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mock-wrap">
|
||||
<div class="mock-col"><div class="mock-title">常规态(全部未选)</div><div class="phone">${barNormal}</div></div>
|
||||
<div class="mock-col"><div class="mock-title">选中态(首页高亮)</div><div class="phone">${barActive}</div></div>
|
||||
</div>
|
||||
<div class="state-strip">${strip}</div>
|
||||
<div class="ids">图标:<code>${ids}</code></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>小虎租房 · 底部导航图标方案(富样式篇)</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; background:#f5f5f7; color:#333; padding:36px 28px 80px; }
|
||||
h1 { font-size:24px; margin-bottom:6px; }
|
||||
.sub { color:#888; font-size:13px; margin-bottom:32px; }
|
||||
.card { background:#fff; border-radius:16px; padding:24px 28px; margin-bottom:24px; box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
||||
.card-head { margin-bottom:18px; }
|
||||
.card-title { font-size:18px; font-weight:700; display:flex; align-items:center; gap:10px; }
|
||||
.tag { display:inline-block; background:#C8102E; color:#fff; font-size:12px; font-weight:600; border-radius:6px; padding:2px 8px; }
|
||||
.card-desc { font-size:13px; color:#888; margin-top:8px; }
|
||||
.card-body { display:flex; flex-wrap:wrap; gap:28px; align-items:flex-start; }
|
||||
.mock-wrap { display:flex; gap:20px; }
|
||||
.mock-col { text-align:center; }
|
||||
.mock-title { font-size:12px; color:#999; margin-bottom:10px; }
|
||||
.phone { width:300px; border-radius:20px; border:6px solid #222; background:#fff; overflow:hidden; }
|
||||
.phone::before { content:""; display:block; height:8px; }
|
||||
.tabbar { display:flex; height:54px; background:#fff; border-top:1px solid #f0f0f0; }
|
||||
.tabitem { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; }
|
||||
.tabitem .ic { width:24px; height:24px; color:#999; }
|
||||
.tabitem .ic svg { width:24px; height:24px; display:block; }
|
||||
.tabitem .ic.active { color:#C8102E; }
|
||||
.tabitem .lb { font-size:10px; color:#999; line-height:14px; }
|
||||
.tabitem.active .lb { color:#C8102E; }
|
||||
.gray { filter:grayscale(1); opacity:.6; }
|
||||
.plus { width:26px; height:26px; border-radius:50%; background:linear-gradient(135deg,#e8433d,#C8102E); box-shadow:0 1px 3px rgba(200,16,46,.3); display:flex; align-items:center; justify-content:center; color:#fff; }
|
||||
.plus svg { width:18px; height:18px; display:block; }
|
||||
.plus-colored { width:34px; height:34px; margin-top:-4px; margin-left:-5px; filter:drop-shadow(0 2px 4px rgba(0,0,0,.18)); }
|
||||
.plus-colored svg { width:34px; height:34px; display:block; }
|
||||
.state-strip { display:flex; gap:18px; flex-wrap:wrap; }
|
||||
.state-cell { display:flex; flex-direction:column; align-items:center; gap:6px; background:#fafafa; border:1px solid #f0f0f0; border-radius:12px; padding:12px 10px; min-width:76px; }
|
||||
.state-ic { width:28px; height:28px; color:#999; }
|
||||
.state-ic svg { width:28px; height:28px; display:block; }
|
||||
.state-ic.active { color:#C8102E; }
|
||||
.state-lb { font-size:10px; color:#aaa; }
|
||||
.state-lb.active { color:#C8102E; }
|
||||
.ids { width:100%; font-size:11px; color:#999; margin-top:6px; word-break:break-all; }
|
||||
.ids code { font-family:ui-monospace,Consolas,monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>小虎租房 · 底部导航图标方案(富样式篇)</h1>
|
||||
<div class="sub">6 套更丰富的图标风格 · 彩色表情/多彩扁平:未选中置灰、选中全彩 · 像素/插画:未选中 #999999、选中 ${RED} ·「发房」彩色方案为铅笔/钥匙彩色图标、单色方案为红圆内置图标(非相机)</div>
|
||||
${cards}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
fs.writeFileSync('D:/SGP_KF3/ruyizu_web/docs/tabbar-icons-rich.html', html, 'utf8');
|
||||
if (missList.length) console.log('缺失图标: ' + missList.join(', '));
|
||||
else console.log('OK,已生成 docs/tabbar-icons-rich.html(' + SOLUTIONS.length + ' 套方案,全部图标命中)');
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/* 探测新增图标源可达性 */
|
||||
const https = require('https');
|
||||
function fetch(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { timeout: 15000 }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
return resolve(fetch(res.headers.location.startsWith('/') ? 'https://unpkg.com' + res.headers.location : res.headers.location));
|
||||
}
|
||||
if (res.statusCode !== 200) { res.resume(); return reject(new Error(res.statusCode + ' ' + url)); }
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => req.destroy(new Error('timeout')));
|
||||
});
|
||||
}
|
||||
(async () => {
|
||||
const urls = {
|
||||
bootstrap: 'https://unpkg.com/bootstrap-icons@1.11.3/bootstrap-icons.svg',
|
||||
fa_solid: 'https://unpkg.com/@fortawesome/fontawesome-free@6.5.2/sprites/solid.svg',
|
||||
fa_regular: 'https://unpkg.com/@fortawesome/fontawesome-free@6.5.2/sprites/regular.svg',
|
||||
fa_brands: 'https://unpkg.com/@fortawesome/fontawesome-free@6.5.2/sprites/brands.svg'
|
||||
};
|
||||
for (const [k, u] of Object.entries(urls)) {
|
||||
try {
|
||||
const s = await fetch(u);
|
||||
const syms = (s.match(/<symbol/g) || []).length;
|
||||
console.log(`${k}: OK ${(s.length / 1024).toFixed(0)}KB symbols=${syms}`);
|
||||
} catch (e) {
|
||||
console.log(`${k}: FAIL ${e.message}`);
|
||||
}
|
||||
}
|
||||
})();
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/* 探测 24 坐标系图标库 sprite 可达性 */
|
||||
const https = require('https');
|
||||
function fetch(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { timeout: 15000 }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
return resolve(fetch(res.headers.location.startsWith('/') ? 'https://unpkg.com' + res.headers.location : res.headers.location));
|
||||
}
|
||||
if (res.statusCode !== 200) { res.resume(); return reject(new Error(res.statusCode + ' ' + url)); }
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => req.destroy(new Error('timeout')));
|
||||
});
|
||||
}
|
||||
(async () => {
|
||||
const urls = {
|
||||
remix: 'https://unpkg.com/remixicon@4.5.0/symbols/remixicon.symbol.svg',
|
||||
phosphor: 'https://unpkg.com/phosphor-icons/core/assets/Phosphor.svg',
|
||||
bootstrap: 'https://unpkg.com/bootstrap-icons@1.11.3/bootstrap-icons.svg',
|
||||
heroicons_solid: 'https://unpkg.com/heroicons@2.1.5/24/solid.svg'
|
||||
};
|
||||
for (const [k, u] of Object.entries(urls)) {
|
||||
try {
|
||||
const s = await fetch(u);
|
||||
const syms = (s.match(/<symbol/g) || []).length;
|
||||
const vb = (s.match(/viewBox="0 0 (\d+) (\d+)"/) || [])[0] || '';
|
||||
console.log(`${k}: OK ${(s.length / 1024).toFixed(0)}KB symbols=${syms} ${vb}`);
|
||||
} catch (e) {
|
||||
console.log(`${k}: FAIL ${e.message}`);
|
||||
}
|
||||
}
|
||||
})();
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/* 用 jsDelivr API 找 24 坐标系图标库的 sprite 文件 */
|
||||
const https = require('https');
|
||||
function fetch(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { timeout: 15000 }, (res) => {
|
||||
if (res.statusCode !== 200) { res.resume(); return reject(new Error(res.statusCode + ' ' + url)); }
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => req.destroy(new Error('timeout')));
|
||||
});
|
||||
}
|
||||
async function tree(pkg) {
|
||||
try {
|
||||
const j = JSON.parse(await fetch(`https://data.jsdelivr.com/v1/packages/npm/${pkg}`));
|
||||
const files = j.files || [];
|
||||
const svgs = [];
|
||||
(function walk(list, prefix) {
|
||||
for (const f of list) {
|
||||
if (f.type === 'directory') walk(f.files || [], prefix + f.name + '/');
|
||||
else if (/\.svg$/.test(f.name)) svgs.push(prefix + f.name);
|
||||
}
|
||||
})(files, '');
|
||||
const sprite = svgs.filter(s => /sprite|symbols/i.test(s));
|
||||
console.log(`\n[${pkg}] 总svg文件 ${svgs.length} 个; sprite/symbols 候选:`);
|
||||
console.log(' ', sprite.slice(0, 8).join('\n ') || '(无)');
|
||||
console.log(' 路径示例:', svgs.slice(0, 3).join(', '));
|
||||
} catch (e) {
|
||||
console.log(`[${pkg}] FAIL ${e.message}`);
|
||||
}
|
||||
}
|
||||
(async () => {
|
||||
await tree('remixicon@4.5.0');
|
||||
await tree('@phosphor-icons/core@2.1.7');
|
||||
await tree('@material-design-icons/svg@0.14.13');
|
||||
})();
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/* 提取「员工内部移动办公 APP」候选图标 → 透明底预览页 */
|
||||
const fs = require('fs');
|
||||
const SRC = 'd:/SGP_KF/DrawLogo/js/data/sources/';
|
||||
const OUT = 'd:/SGP_KF/DrawLogo/_app-icon-pick.html';
|
||||
|
||||
const STROKE = new Set(['feather', 'lucide', 'tabler']);
|
||||
const NEG_RE = /(^|[-_])?(off|alert|error|forbidden|ban|slash|remove|disabled|cross|minus)([-_]|$)/i;
|
||||
const COLOR = '#FF6900'; // 小米橙
|
||||
|
||||
function loadSource(id) {
|
||||
const code = fs.readFileSync(SRC + id + '.js', 'utf8');
|
||||
const win = {};
|
||||
new Function('window', code)(win);
|
||||
return win['HGB_Source_' + id].icons;
|
||||
}
|
||||
|
||||
// 候选:意象 → (源, 备选名[]) ,按序取第一个存在且无负面命名的图标
|
||||
const CANDS = [
|
||||
{ label: '纯手机(实心)', src: 'mdi', names: ['cellphone'] },
|
||||
{ label: '纯手机(线条)', src: 'lucide', names: ['smartphone'] },
|
||||
{ label: '手机+对勾(办结)', src: 'mdi', names: ['cellphone-check'] },
|
||||
{ label: '手机+齿轮(设置)', src: 'mdi', names: ['cellphone-cog'] },
|
||||
{ label: '手机+聊天(沟通)', src: 'mdi', names: ['cellphone-message'] },
|
||||
{ label: '手机+声波(通知)', src: 'mdi', names: ['cellphone-sound'] },
|
||||
{ label: '手机+消息(线条)', src: 'tabler', names: ['device-mobile-message'] },
|
||||
{ label: '手机+搜索', src: 'tabler', names: ['device-mobile-search'] },
|
||||
{ label: '手机+星标', src: 'tabler', names: ['device-mobile-star'] },
|
||||
{ label: '手机+闪电', src: 'lucide', names: ['smartphone-charging'] },
|
||||
];
|
||||
|
||||
const cache = {};
|
||||
function find(src, names) {
|
||||
if (!cache[src]) cache[src] = loadSource(src);
|
||||
for (const n of names) {
|
||||
const hit = cache[src].find(ic => ic.name === n && !NEG_RE.test(ic.name) && /^\s*<svg/.test(ic.svg) === false && ic.svg.trim());
|
||||
if (hit) return hit;
|
||||
// 名称可能带数字后缀等,放宽为前缀匹配
|
||||
const hit2 = cache[src].find(ic => ic.name.startsWith(n) && (ic.name.length === n.length || /[-_0-9]/.test(ic.name[n.length] || '')) && !NEG_RE.test(ic.name));
|
||||
if (hit2) return hit2;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wrap(ic, src) {
|
||||
const stroke = STROKE.has(src);
|
||||
const attrs = stroke
|
||||
? 'fill="none" stroke="' + COLOR + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
|
||||
: 'fill="' + COLOR + '"';
|
||||
return '<svg viewBox="0 0 24 24" ' + attrs + '>' + ic.svg + '</svg>';
|
||||
}
|
||||
|
||||
const hits = [];
|
||||
for (const c of CANDS) {
|
||||
const ic = find(c.src, c.names);
|
||||
if (!ic) { console.log('未命中:', c.label, c.src, c.names.join('/')); continue; }
|
||||
hits.push({ label: c.label, src: c.src, name: ic.name, svg: wrap(ic, c.src) });
|
||||
console.log('命中:', c.label, '→', c.src + ':' + ic.name);
|
||||
}
|
||||
console.log('命中总数:', hits.length, '/', CANDS.length);
|
||||
|
||||
if (hits.length < 10) { console.error('候选不足 10,中止生成'); process.exit(1); }
|
||||
hits.length = 10;
|
||||
|
||||
const cards = hits.map((h, i) => `
|
||||
<div class="card">
|
||||
<div class="stage">${h.svg}</div>
|
||||
<div class="cap"><b>${i + 1}</b> ${h.label}</div>
|
||||
<div class="meta">${h.src}:${h.name}</div>
|
||||
</div>`).join('\n');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>手机样式图标候选 · 小米橙 · 透明底</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: "PingFang SC", "Microsoft YaHei", sans-serif; background: #fff; color: #111; padding: 28px; }
|
||||
h1 { font-size: 18px; margin-bottom: 4px; }
|
||||
p.tip { font-size: 13px; color: #6b7280; margin-bottom: 20px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; background: #fff; }
|
||||
.stage {
|
||||
height: 120px; display: flex; align-items: center; justify-content: center;
|
||||
background-image:
|
||||
linear-gradient(45deg, #f1f5f9 25%, transparent 25%, transparent 75%, #f1f5f9 75%),
|
||||
linear-gradient(45deg, #f1f5f9 25%, #fff 25%, #fff 75%, #f1f5f9 75%);
|
||||
background-size: 16px 16px; background-position: 0 0, 8px 8px; /* 棋盘格 = 透明区域示意,非底色 */
|
||||
}
|
||||
.stage svg { width: 72px; height: 72px; display: block; }
|
||||
.cap { font-size: 13px; padding: 8px 10px 2px; }
|
||||
.meta { font-size: 11px; color: #9ca3af; padding: 0 10px 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>手机样式 App 图标候选 · 小米橙 #FF6900(透明底,棋盘区域 = 透明)</h1>
|
||||
<p class="tip">10 个候选,挑好后把编号发我。小米橙渲染,可再微调。</p>
|
||||
<div class="grid">${cards}</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
fs.writeFileSync(OUT, html, 'utf8');
|
||||
console.log('已生成:', OUT, '(' + (fs.statSync(OUT).size / 1024).toFixed(1) + ' KB)');
|
||||
@@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>动态表情像素审计(逐帧非透明像素 + 帧间变化检测)</title>
|
||||
<style>
|
||||
body{font-family:monospace;font-size:12px;margin:8px;background:#111;color:#eee}
|
||||
h1{font-size:16px}h2{font-size:14px;margin:14px 0 6px}
|
||||
.src{margin:6px 0;padding:6px;border:1px solid #333;border-radius:4px}
|
||||
.src b{color:#7ee787}.src .bad{color:#ff7b72}.src .warn{color:#d29922}
|
||||
.ok{color:#7ee787}.fail{color:#ff7b72}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>动态表情像素审计</h1>
|
||||
<p>逐帧渲染表情方案:① 每帧非透明像素 > 0(无空白帧)② 帧间确有变化(动画真实生效)③ GIF 编码头校验。</p>
|
||||
<div id="out"><p>加载中…</p></div>
|
||||
|
||||
<script src="js/data/icons.js"></script>
|
||||
<script src="js/data/industries.js"></script>
|
||||
<script src="js/data/styles.js?v=20260813"></script>
|
||||
<script src="js/data/palettes.js"></script>
|
||||
<script src="js/data/fonts.js"></script>
|
||||
<script src="js/data/decorations.js"></script>
|
||||
<script src="js/core/store.js?v=20260816"></script>
|
||||
<script src="js/core/ai.js?v=20260927a"></script>
|
||||
<script src="js/core/exporter.js?v=20260812h"></script>
|
||||
<script src="js/core/generator.js?v=20260815c"></script>
|
||||
<script src="js/core/avatarGen.js?v=20260815c"></script>
|
||||
<script src="js/core/motion.js?v=20260823h"></script>
|
||||
<script src="js/core/gifEncoder.js?v=20260823e"></script>
|
||||
<script src="js/core/emojiGen.js?v=20260824a"></script>
|
||||
<script src="js/core/emojiAi.js?v=20260824a"></script>
|
||||
<script src="js/sticker-skills/core.js?v=20260823e"></script>
|
||||
<script src="js/sticker-skills/skills.js?v=20260824a"></script>
|
||||
<script src="js/data/builtin-icons.js?v=20260823g"></script>
|
||||
<script src="js/core/iconlib.js?v=20260823g"></script>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
const out = document.getElementById('out');
|
||||
let pass = 0, fail = 0;
|
||||
function check(name, ok, detail) {
|
||||
if (ok) { pass++; console.log(' ✅ ' + name); }
|
||||
else { fail++; console.log(' ❌ ' + name + (detail ? ' — ' + detail : '')); }
|
||||
}
|
||||
function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
/** 渲染一帧 SVG → canvas,返回 {canvas, alphaSum} */
|
||||
async function renderFrame(svgStr, size) {
|
||||
const canvas = await HGB_Exporter.renderCanvas(size, null, svgStr);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const data = ctx.getImageData(0, 0, size, size).data;
|
||||
let alphaSum = 0;
|
||||
for (let i = 3; i < data.length; i += 4) alphaSum += data[i];
|
||||
return { canvas, alphaSum };
|
||||
}
|
||||
/** 两帧差异像素比例 */
|
||||
function diffRatio(a, b, size) {
|
||||
const da = a.getContext('2d').getImageData(0, 0, size, size).data;
|
||||
const db = b.getContext('2d').getImageData(0, 0, size, size).data;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < da.length; i += 4) {
|
||||
if (da[i] !== db[i] || da[i+1] !== db[i+1] || da[i+2] !== db[i+2] || da[i+3] !== db[i+3]) diff++;
|
||||
}
|
||||
return diff / (size * size);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const size = 120;
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const types = qs.get('only') ? qs.get('only').split(',') : null;
|
||||
|
||||
// GIF 编码器自检(确定性)
|
||||
let gifOk = false, gifHead = '';
|
||||
try {
|
||||
const c = document.createElement('canvas'); c.width = 32; c.height = 32;
|
||||
const c1 = document.createElement('canvas'); c1.width = 32; c1.height = 32;
|
||||
const b = HGB_GifEncoder.encode([c, c1], { delay: 83 });
|
||||
gifHead = String.fromCharCode(b[0], b[1], b[2], b[3], b[4], b[5]);
|
||||
gifOk = gifHead === 'GIF89a';
|
||||
} catch (e) { gifHead = 'err:' + e.message; }
|
||||
check('GIF 编码头 = GIF89a', gifOk, gifHead);
|
||||
|
||||
// 表情技能库完整性
|
||||
const skillIds = HGB_StickerSkills.all().map(s => s.id);
|
||||
check('表情技能 12 个注册', skillIds.length === 12, '实际 ' + skillIds.length);
|
||||
check('全部技能 test 通过', skillIds.every(id => {
|
||||
try { const r = HGB_StickerSkills.test(id); return !!r && r.error === undefined && r.ok !== false; }
|
||||
catch (e) { return false; }
|
||||
}));
|
||||
|
||||
// 生成整组表情包(同图 × 全部动画类型)
|
||||
const plans = await HGB_EmojiGen.generateEmojiSet({ industry: HGB_Industries.find('food'), brand: '表情' });
|
||||
const audited = plans.filter(p => !types || types.includes(p.anim.type));
|
||||
const rows = [];
|
||||
let totalFrames = 0, totalBlank = 0, totalStatic = 0;
|
||||
|
||||
for (const plan of audited) {
|
||||
const a = plan.anim;
|
||||
const t = HGB_Motion.animTypes().find(x => x.id === a.type);
|
||||
const n = HGB_Motion.frameCount(a);
|
||||
const step = Math.max(1, Math.ceil(n / 12));
|
||||
const frameIdx = [];
|
||||
for (let i = 0; i < n; i += step) frameIdx.push(i);
|
||||
let blank = 0, err = 0;
|
||||
const canvases = [];
|
||||
for (const i of frameIdx) {
|
||||
totalFrames++;
|
||||
const tF = HGB_Motion.frameT(i, a);
|
||||
try {
|
||||
const svg = HGB_EmojiGen.buildFrameSvg(HGB_EmojiGen.markElements(plan), null, size, tF, a, {});
|
||||
const { canvas, alphaSum } = await renderFrame(svg, size);
|
||||
if (alphaSum === 0) blank++;
|
||||
canvases.push(canvas);
|
||||
} catch (e) { err++; console.log('帧失败 ' + t.id + '#' + i + ': ' + e.message); }
|
||||
}
|
||||
// 帧间差异:首帧 vs 各后续帧
|
||||
let changed = 0;
|
||||
if (canvases.length > 1) {
|
||||
for (let k = 1; k < canvases.length; k++) {
|
||||
const r = diffRatio(canvases[0], canvases[k], size);
|
||||
if (r > 0.001) changed++;
|
||||
}
|
||||
}
|
||||
if (blank) totalBlank += blank;
|
||||
if (changed === 0) totalStatic++;
|
||||
check('动画 ' + t.id + ':' + frameIdx.length + ' 帧无空白', blank === 0 && err === 0, '空白 ' + blank + ' 失败 ' + err);
|
||||
check('动画 ' + t.id + ':帧间确有变化', changed > 0, '变化帧 ' + changed + '/' + (canvases.length - 1));
|
||||
rows.push({ id: t.id, frames: frameIdx.length, blank, err, changed });
|
||||
}
|
||||
|
||||
check('全动画类型零空白帧', totalBlank === 0, '空白 ' + totalBlank + '/' + totalFrames);
|
||||
check('全动画类型均有真实运动', totalStatic === 0, '静态 ' + totalStatic + '/' + audited.length);
|
||||
|
||||
let html = '<h2>✅ 通过 ' + pass + ' / 失败 ' + fail + '(' + totalFrames + ' 帧采样)</h2>';
|
||||
rows.forEach(r => {
|
||||
const ok = r.blank === 0 && r.err === 0 && r.changed > 0;
|
||||
html += '<div class="src"><b>' + r.id + '</b>:' + r.frames + ' 帧,空白 <span class="' + (r.blank ? 'bad' : 'ok') + '">' + r.blank + '</span>,失败 ' + r.err + ',变化帧 <span class="' + (r.changed ? 'ok' : 'warn') + '">' + r.changed + '</span> → <span class="' + (ok ? 'ok' : 'fail') + '">' + (ok ? '通过' : '不通过') + '</span></div>';
|
||||
});
|
||||
out.innerHTML = html;
|
||||
document.title = '表情审计 ' + pass + ' 通过 / ' + fail + ' 失败';
|
||||
}
|
||||
run().catch(e => { out.innerHTML = '<p class="fail">运行异常:' + esc(e.message) + '</p>'; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
["academicons.js","akar-icons.js","antd.js","at-icons.js","basil.js","bitcoin-icons.js","blueprint.js","bootstrap.js","boxicons.js","bpmn.js","bxl.js","bxs.js","bytesize.js","carbon.js","catppuccin.js","charm.js","ci.js","cib.js","cif.js","cil.js","circle-flags.js","circum.js","clarity.js","codex.js","codicon.js","covid.js","cryptocurrency-color.js","cryptocurrency.js","cssgg.js","custom-brand.js","cuida.js","dashicons.js","devicon-plain.js","devicon.js","dicebear-adventurer-neutral.js","dicebear-adventurer.js","dicebear-avataaars-neutral.js","dicebear-avataaars.js","dicebear-big-ears-neutral.js","dicebear-big-ears.js","dicebear-big-smile.js","dicebear-bottts-neutral.js","dicebear-bottts.js","dicebear-croodles-neutral.js","dicebear-croodles.js","dicebear-dylan.js","dicebear-fun-emoji.js","dicebear-glass.js","dicebear-icons.js","dicebear-identicon.js","dicebear-initials.js","dicebear-lorelei-neutral.js","dicebear-lorelei.js","dicebear-micah.js","dicebear-miniavs.js","dicebear-notionists-neutral.js","dicebear-notionists.js","dicebear-open-peeps.js","dicebear-personas.js","dicebear-pixel-art.js","dicebear-rings.js","dicebear-shapes.js","dicebear-thumbs.js","dinkie-icons.js","doodle-icons.js","duo-icons.js","ei.js","el.js","emojione-monotone.js","emojione.js","eos-icons.js","ep.js","eva.js","f7.js","fa7-brands.js","fa7-regular.js","fa7-solid.js","fad.js","famicons.js","fe.js","feather.js","file-icons.js","flag.js","flagpack.js","flat-color-icons.js","flat-ui.js","flowbite.js","fluent-color.js","fluent-emoji-flat.js","fluent-emoji-high-contrast.js","fluent-emoji.js","fluent-mdl2.js","fluent.js","fluentemoji.js","fontawesome.js","fontisto.js","formkit.js","foundation.js","fxemoji.js","gameicons.js","garden.js","gcp.js","geo.js","ginetex.js","gis.js","glyphs-poly.js","glyphs.js","gophers.js","gravity-ui.js","griddy-icons.js","grommet-icons.js","guidance.js","healthicons.js","heroicons.js","hugeicons.js","humbleicons.js","ic.js","icon-park-outline.js","icon-park-solid.js","icon-park-twotone.js","icon-park.js","iconamoon.js","iconoir.js","iconpark.js","icons8.js","il.js","ionicons.js","iwwa.js","ix.js","jam.js","k8s.js","la.js","lets-icons.js","linea.js","line-md.js","lineicons.js","logos.js","ls.js","lsicon.js","lucide-lab.js","lucide.js","mage.js","majesticons.js","maki.js","map.js","marketeq.js","material-icon-theme.js","material-symbols-light.js","material-symbols.js","mdi-light.js","mdi.js","medical-icon.js","memory.js","meteocons.js","meteor-icons.js","mi.js","mingcute.js","mono-icons.js","msymbols.js","mynaui.js","nimbus.js","nonicons.js","noto-v1.js","noto.js","nrk.js","octicon.js","oi.js","ooui.js","openmoji.js","osmic.js","oui.js","pajamas.js","pepicons-pencil.js","pepicons-pop.js","pepicons-print.js","phosphor.js","picon.js","pinhead.js","pixel.js","pixelarticons.js","polaris.js","prime.js","proicons.js","qlementine-icons.js","quill.js","radix.js","raphael.js","reicon.js","remix.js","rivet-icons.js","roentgen.js","sap-fiori.js","selfhst.js","semi-icons.js","si-glyph.js","si.js","sidekickicons.js","simple-icons.js","simple-line-icons.js","skill-icons.js","solar.js","stash.js","streamline-block.js","streamline-color.js","streamline-cyber-color.js","streamline-cyber.js","streamline-emojis.js","streamline-flex-color.js","streamline-flex.js","streamline-freehand-color.js","streamline-freehand.js","streamline-kameleon-color.js","streamline-logos.js","streamline-pixel.js","streamline-plump-color.js","streamline-plump.js","streamline-sharp-color.js","streamline-sharp.js","streamline-stickies-color.js","streamline-ultimate-color.js","streamline-ultimate.js","streamline.js","subway.js","supertinyicons.js","svgl.js","system-uicons.js","tabler.js","tdesign.js","teenyicons.js","temaki.js","thesvg-color.js","thesvg.js","token-branded.js","token.js","topcoat.js","twemoji.js","uil.js","uim.js","uis.js","uit.js","uiw.js","unjs.js","vaadin.js","vadivam.js","vs.js","vscode-icons.js","weui.js","whh.js","wi.js","wpf.js","zmdi.js","zondicons.js"]
|
||||
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>图标像素审计(canvas 非透明像素检测)</title>
|
||||
<style>
|
||||
body{font-family:monospace;font-size:12px;margin:8px;background:#111;color:#eee}
|
||||
.src{margin:6px 0;padding:6px;border:1px solid #333;border-radius:4px}
|
||||
.src b{color:#7ee787}.src .bad{color:#ff7b72}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>像素审计</h1>
|
||||
<div id="out"><p>加载中…</p></div>
|
||||
<script>
|
||||
const STROKE_SOURCES = new Set(['feather','lucide','tabler']);
|
||||
function isStroke(ic, src){
|
||||
if (ic.type && ic.type !== 'icon') return false;
|
||||
if (STROKE_SOURCES.has(src)) return true;
|
||||
const svg = ic.svg || '';
|
||||
if (/stroke\s*=|\bstroke-linecap\s*=|\bstroke-linejoin\s*=/.test(svg)) return true;
|
||||
// 显式 stroke-width="0"(Carbon 等填充型源自带,防默认描边):实为填充型(与 panels.js 一致)
|
||||
if (/stroke-width="0"/.test(svg)) return false;
|
||||
// 与 panels.js isStrokeIcon 一致:全几何元素 fill=none 无 stroke → 描边型
|
||||
const geoms = svg.match(/<(?:path|circle|rect|ellipse|line|polyline|polygon)\b[^>]*>/g);
|
||||
if (geoms && geoms.length && !/fill\s*=\s*"(?!none")[^"]*"/.test(svg)) return true;
|
||||
return false;
|
||||
}
|
||||
/** 与 panels.js prepareIconBody 一致:currentColor 替换为显式色值(fill/stroke 一律实色) */
|
||||
function prepareBody(body, isSt){
|
||||
if (!body) return '';
|
||||
return body
|
||||
.replace(/fill="currentColor"/g, 'fill="#374151"')
|
||||
.replace(/stroke="currentColor"/g, 'stroke="#374151"')
|
||||
.replace(/stop-color="currentColor"/g, 'stop-color="#374151"');
|
||||
}
|
||||
function applyAttrs(el, str){
|
||||
const re = /([\w-]+)\s*=\s*"([^"]*)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(str))) el.setAttribute(m[1], m[2]);
|
||||
}
|
||||
/** 渲染 svg 字符串 → 像素统计 {blank, alphaSum},async */
|
||||
function svgToCanvas(svgStr, size){
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = size; c.height = size;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.fillStyle = 'rgba(0,0,0,0)';
|
||||
ctx.fillRect(0,0,size,size);
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
let data;
|
||||
try { data = ctx.getImageData(0,0,size,size).data; } catch(e){ resolve({blank:false, err:'getImageData受限'}); return; }
|
||||
let alphaSum = 0;
|
||||
for (let i = 3; i < data.length; i += 4) alphaSum += data[i];
|
||||
resolve({ blank: alphaSum === 0, alphaSum });
|
||||
};
|
||||
img.onerror = () => resolve({ blank: false, err: 'SVG加载失败' });
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr);
|
||||
});
|
||||
}
|
||||
async function run(){
|
||||
const out = document.getElementById('out');
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const only = qs.get('only') ? qs.get('only').split(',').map(s=>s.trim()) : null;
|
||||
const max = parseInt(qs.get('max') || '300', 10); // 每源最多图标数(防超时)
|
||||
const size = parseInt(qs.get('size') || '48', 10);
|
||||
const files = await (await fetch('/audit-files.json')).json();
|
||||
const holder = document.createElement('div');
|
||||
holder.style.cssText = 'position:fixed;left:-9999px;top:0;width:24px;height:24px;';
|
||||
document.body.appendChild(holder);
|
||||
let totalIcons = 0, totalBlank = 0, totalErr = 0;
|
||||
const rows = [];
|
||||
for (let fi = 0; fi < files.length; fi++) {
|
||||
const f = files[fi];
|
||||
const sid = f.replace(/\.js$/,'');
|
||||
if (sid.startsWith('dicebear-')) continue;
|
||||
if (only && !only.includes(sid)) continue;
|
||||
let data = null;
|
||||
try {
|
||||
const txt = await (await fetch('/js/data/sources/' + f)).text();
|
||||
const obj = txt.replace(/^[^\n]*?window\[[^\]]+\]\s*=\s*/, '').replace(/;\s*$/, '');
|
||||
data = new Function('return (' + obj + ');')();
|
||||
} catch(e){ totalErr++; rows.push({sid, error: '加载失败 ' + e.message}); continue; }
|
||||
if (!data || !Array.isArray(data.icons)) { totalErr++; rows.push({sid, error:'无 icons 数组'}); continue; }
|
||||
const icons = data.icons.slice(0, max);
|
||||
let blank = 0;
|
||||
const samples = [];
|
||||
for (const ic of icons) {
|
||||
totalIcons++;
|
||||
const preserve = ic.type && ic.type !== 'icon';
|
||||
const isSt = preserve ? false : isStroke(ic, sid);
|
||||
const attrs = isSt
|
||||
? 'fill="none" stroke="#374151" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"'
|
||||
: 'fill="#374151" stroke="none"';
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg','svg');
|
||||
svg.setAttribute('viewBox','0 0 24 24');
|
||||
applyAttrs(svg, attrs);
|
||||
// 与 panels.js 渲染一致:多色素材(emoji/品牌)保留原 body 直接渲染,不做 currentColor 替换;
|
||||
// 其余按描边/填充型处理
|
||||
const body = preserve ? ic.svg : prepareBody(ic.svg, isSt);
|
||||
svg.innerHTML = body;
|
||||
const full = new XMLSerializer().serializeToString(svg);
|
||||
const r = await svgToCanvas(full, size);
|
||||
if (r.blank) {
|
||||
blank++;
|
||||
totalBlank++;
|
||||
if (samples.length < 12) samples.push(ic.id + (r.err ? '[' + r.err + ']' : ''));
|
||||
}
|
||||
}
|
||||
if (blank) rows.push({ sid, total: icons.length, blank, samples });
|
||||
if (fi % 10 === 0) out.innerHTML = '<p>进度 ' + (fi+1) + '/' + files.length + ' …</p>';
|
||||
}
|
||||
let html = '<h2>扫描 ' + totalIcons.toLocaleString() + ' 个,空白 ' + totalBlank.toLocaleString() + ',失败 ' + totalErr + '</h2>';
|
||||
rows.sort((a,b)=> b.blank-a.blank).forEach(r=>{
|
||||
if (r.error) html += '<div class="src">✗ ' + r.sid + ':' + r.error + '</div>';
|
||||
else html += '<div class="src"><b>' + r.sid + '</b>:<span class="bad">' + r.blank + '/' + r.total + '</span>(' + (r.blank/r.total*100).toFixed(2) + '%)' +
|
||||
(r.samples.length ? '<br>' + r.samples.join('<br>') : '') + '</div>';
|
||||
});
|
||||
out.innerHTML = html;
|
||||
document.title = 'PIXEL-DONE ' + totalIcons + '/' + totalBlank + '/' + totalErr;
|
||||
}
|
||||
run();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>海报素材像素审计(canvas 非透明像素 + 原始/下载逐像素对比)</title>
|
||||
<style>
|
||||
body{font-family:monospace;font-size:12px;margin:8px;background:#111;color:#eee}
|
||||
.src{margin:6px 0;padding:6px;border:1px solid #333;border-radius:4px}
|
||||
.src b{color:#7ee787}.src .bad{color:#ff7b72}.src .ok{color:#7ee787}
|
||||
h1{font-size:16px}h2{font-size:14px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>海报素材像素审计</h1>
|
||||
<p>支持参数:<code>?only=源1,源2</code> 定向审计;<code>?max=N</code> 每源最多素材数;<code>?size=N</code> 渲染尺寸;<code>?compare=1</code> 开启「原始 vs 下载」逐像素对比。</p>
|
||||
<div id="out"><p>加载中…</p></div>
|
||||
|
||||
<script src="js/data/poster-sources.js?v=20260823e"></script>
|
||||
<script src="js/data/poster-builtin.js?v=20260823e"></script>
|
||||
<script src="js/core/posterlib.js?v=20260823e"></script>
|
||||
|
||||
<script>
|
||||
/** 渲染 svg 字符串 → 像素统计 {blank, alphaSum, data, width, height} */
|
||||
function svgToCanvas(svgStr, viewBox, size) {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = size; c.height = size;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.fillStyle = 'rgba(0,0,0,0)';
|
||||
ctx.fillRect(0,0,size,size);
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
let data;
|
||||
try { data = ctx.getImageData(0,0,size,size).data; }
|
||||
catch(e){ resolve({blank:false, err:'getImageData受限'}); return; }
|
||||
let alphaSum = 0;
|
||||
for (let i = 3; i < data.length; i += 4) alphaSum += data[i];
|
||||
resolve({ blank: alphaSum === 0, alphaSum, data });
|
||||
};
|
||||
img.onerror = () => resolve({ blank: false, err: 'SVG加载失败' });
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr);
|
||||
});
|
||||
}
|
||||
|
||||
/** 位图(PNG/JPG)→ 像素统计 */
|
||||
function imgToCanvas(src, size) {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = size; c.height = size;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.fillStyle = 'rgba(0,0,0,0)';
|
||||
ctx.fillRect(0,0,size,size);
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
let data;
|
||||
try { data = ctx.getImageData(0,0,size,size).data; }
|
||||
catch(e){ resolve({blank:false, err:'getImageData受限', width:img.naturalWidth, height:img.naturalHeight}); return; }
|
||||
let alphaSum = 0;
|
||||
for (let i = 3; i < data.length; i += 4) alphaSum += data[i];
|
||||
resolve({ blank: alphaSum === 0, alphaSum, data, width: img.naturalWidth, height: img.naturalHeight });
|
||||
};
|
||||
img.onerror = () => resolve({ blank: false, err: '位图加载失败' });
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/** 两张像素数据逐像素对比,返回差异率(0~1) */
|
||||
function pixelDiff(a, b) {
|
||||
if (!a || !b || a.length !== b.length) return 1;
|
||||
let diff = 0, total = a.length / 4;
|
||||
for (let i = 0; i < a.length; i += 4) {
|
||||
if (Math.abs(a[i]-b[i]) + Math.abs(a[i+1]-b[i+1]) + Math.abs(a[i+2]-b[i+2]) + Math.abs(a[i+3]-b[i+3]) > 8) diff++;
|
||||
}
|
||||
return total ? diff / total : 0;
|
||||
}
|
||||
|
||||
/** 由源配置重建原始 CDN URL(用于「原始 vs 下载」对比) */
|
||||
function originalUrl(src, item) {
|
||||
const name = item.name || String(item.id).split(':')[1];
|
||||
const ext = (item.mime === 'image/jpeg') ? '.jpg' : (item.mime === 'image/png' ? '.png' : '.svg');
|
||||
if (src.gh) {
|
||||
const repo = src.gh + '@' + (src.ghBranch || 'master');
|
||||
const dir = (src.dirs || [])[0] || '';
|
||||
return `https://cdn.jsdelivr.net/gh/${repo}/${dir}/${name}${ext}`;
|
||||
}
|
||||
if (src.npm) {
|
||||
const dir = (src.dirs || [])[0] || '';
|
||||
return `https://unpkg.com/${src.npm}/${dir}/${name}${ext}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wrapSvg(body, viewBox) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox || '0 0 24 24'}">${body}</svg>`;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const out = document.getElementById('out');
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const only = qs.get('only') ? qs.get('only').split(',').map(s => s.trim()) : null;
|
||||
const max = parseInt(qs.get('max') || '300', 10);
|
||||
const size = parseInt(qs.get('size') || '64', 10);
|
||||
const compare = qs.get('compare') === '1';
|
||||
|
||||
const srcMap = {};
|
||||
for (const s of (window.HGB_PosterSources || [])) srcMap[s.id] = s;
|
||||
|
||||
const builtin = window.HGB_PosterBuiltin || { sources: {} };
|
||||
const ids = Object.keys(builtin.sources || {});
|
||||
if (!ids.length) { out.innerHTML = '<p>⚠️ 暂无海报素材源(poster-builtin.js 为空),请先运行 node scripts/build-posters.js。</p>'; return; }
|
||||
|
||||
let totalItems = 0, totalBlank = 0, totalErr = 0, totalDiff = 0;
|
||||
const rows = [];
|
||||
|
||||
for (const sid of ids) {
|
||||
if (only && !only.includes(sid)) continue;
|
||||
let data = null;
|
||||
try {
|
||||
data = await HGB_PosterLib.loadSource(sid);
|
||||
} catch (e) { totalErr++; rows.push({ sid, error: '加载失败 ' + e.message }); continue; }
|
||||
if (!data || !Array.isArray(data.items)) { totalErr++; rows.push({ sid, error: '无 items 数组' }); continue; }
|
||||
|
||||
const items = data.items.slice(0, max);
|
||||
const src = srcMap[sid] || {};
|
||||
let blank = 0, diffBad = 0;
|
||||
const samples = [];
|
||||
for (const it of items) {
|
||||
totalItems++;
|
||||
// 字体无像素可审(文件完整性已由构建端 sha256/bytes 校验),跳过渲染
|
||||
if (src.type === 'font') continue;
|
||||
const viewBox = it.viewBox || (it.width && it.height ? `0 0 ${it.width} ${it.height}` : '0 0 24 24');
|
||||
let r;
|
||||
if (it.svg) {
|
||||
r = await svgToCanvas(wrapSvg(it.svg, viewBox), viewBox, size);
|
||||
} else if (it.file) {
|
||||
r = await imgToCanvas('js/data/' + it.file, size);
|
||||
// 分辨率参数校验:渲染出的原始尺寸需与构建记录一致(防止下载损坏/错位)
|
||||
if (!r.err && it.width && (r.width !== it.width || r.height !== it.height)) {
|
||||
r.resMismatch = true;
|
||||
}
|
||||
} else {
|
||||
// 配色 JSON / 字体:无像素,仅结构完整性校验
|
||||
continue;
|
||||
}
|
||||
|
||||
if (r.blank) { blank++; totalBlank++; if (samples.length < 12) samples.push(it.id + (r.err ? '[' + r.err + ']' : '')); }
|
||||
if (r.resMismatch) { if (samples.length < 12) samples.push(it.id + '[分辨率不符 ' + r.width + 'x' + r.height + '≠' + it.width + 'x' + it.height + ']'); }
|
||||
|
||||
// 「原始 vs 下载」逐像素对比:重新拉取 CDN 原始文件渲染,与下载数据比差异率
|
||||
// 注:transform 源(如 heropatterns JS 模板 → 静态 SVG)为有意格式转换,无 1:1 原始文件可对比,跳过
|
||||
if (compare && it.svg && !src.transform) {
|
||||
const orig = originalUrl(src, it);
|
||||
if (orig) {
|
||||
try {
|
||||
const raw = await (await fetch(orig)).text();
|
||||
if (!/<svg[\s>]/i.test(raw)) continue; // 404/错误页等非 SVG 响应,跳过
|
||||
const body = raw.replace(/[\s\S]*?<svg[^>]*>/, '').replace(/<\/svg>[\s\S]*/, '');
|
||||
const ro = await svgToCanvas(wrapSvg(body, viewBox), viewBox, size);
|
||||
if (ro.data && r.data) {
|
||||
const d = pixelDiff(r.data, ro.data);
|
||||
if (d > 0.01) { diffBad++; totalDiff++; if (samples.length < 12) samples.push(it.id + '[差异率 ' + (d * 100).toFixed(2) + '%]'); }
|
||||
}
|
||||
} catch (e) { /* 原始拉取失败忽略(网络/CDN) */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.push({ sid, total: items.length, blank, diffBad, samples });
|
||||
out.innerHTML = '<p>进度 ' + ids.indexOf(sid) + 1 + '/' + ids.length + ' …</p>';
|
||||
}
|
||||
|
||||
let html = '<h2>扫描 ' + totalItems.toLocaleString() + ' 个,空白 ' + totalBlank.toLocaleString() + ',失败 ' + totalErr + (compare ? ',像素差异异常 ' + totalDiff : '') + '</h2>';
|
||||
rows.sort((a, b) => b.blank - a.blank).forEach(r => {
|
||||
if (r.error) html += '<div class="src">✗ ' + r.sid + ':' + r.error + '</div>';
|
||||
else html += '<div class="src"><b>' + r.sid + '</b>:空白 <span class="' + (r.blank ? 'bad' : 'ok') + '">' + r.blank + '/' + r.total + '</span>' +
|
||||
(r.diffBad ? ' · 像素差异异常 <span class="bad">' + r.diffBad + '</span>' : '') +
|
||||
(r.samples.length ? '<br>' + r.samples.join('<br>') : '') + '</div>';
|
||||
});
|
||||
out.innerHTML = html;
|
||||
document.title = 'POSTER-AUDIT-DONE ' + totalItems + '/' + totalBlank + '/' + totalErr;
|
||||
}
|
||||
run();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>图标渲染审计(真实浏览器 getBBox + 像素检测)</title>
|
||||
<style>
|
||||
body{font-family:monospace;font-size:12px;margin:8px;background:#111;color:#eee}
|
||||
h2{font-size:14px}
|
||||
.src{margin:6px 0;padding:6px;border:1px solid #333;border-radius:4px}
|
||||
.src b{color:#7ee787}
|
||||
.src .bad{color:#ff7b72}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>全量渲染审计</h1>
|
||||
<div id="out"><p>加载中…</p></div>
|
||||
<h2>表情动画审计(独立审计页 audit-emojis.html,iframe 嵌入)</h2>
|
||||
<iframe src="audit-emojis.html" style="width:100%;height:560px;border:1px solid #333;border-radius:4px;background:#111"></iframe>
|
||||
<script>
|
||||
const STROKE_SOURCES = new Set(['feather','lucide','tabler']);
|
||||
function isStroke(ic, src){
|
||||
if (ic.type && ic.type !== 'icon') return false;
|
||||
return STROKE_SOURCES.has(src) || /stroke\s*=|\bstroke-linecap\s*=|\bstroke-linejoin\s*=/.test(ic.svg||'');
|
||||
}
|
||||
function applyAttrs(el, str){
|
||||
const re = /([\w-]+)\s*=\s*"([^"]*)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(str))) el.setAttribute(m[1], m[2]);
|
||||
}
|
||||
async function run(){
|
||||
const out = document.getElementById('out');
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const only = qs.get('only') ? qs.get('only').split(',').map(s=>s.trim()) : null;
|
||||
const files = await (await fetch('/audit-files.json')).json();
|
||||
const holder = document.createElement('div');
|
||||
holder.style.cssText = 'position:fixed;left:-9999px;top:0;width:24px;height:24px;';
|
||||
document.body.appendChild(holder);
|
||||
let totalIcons = 0, totalBlank = 0, totalErr = 0;
|
||||
const rows = [];
|
||||
for (let fi = 0; fi < files.length; fi++) {
|
||||
const f = files[fi];
|
||||
const sid = f.replace(/\.js$/,'');
|
||||
if (sid.startsWith('dicebear-')) continue;
|
||||
if (only && !only.includes(sid)) continue;
|
||||
let data = null;
|
||||
try {
|
||||
const txt = await (await fetch('/js/data/sources/' + f)).text();
|
||||
const obj = txt.replace(/^[^\n]*?window\[[^\]]+\]\s*=\s*/, '').replace(/;\s*$/, '');
|
||||
data = new Function('return (' + obj + ');')();
|
||||
} catch(e){ totalErr++; rows.push({sid, error: '加载失败 ' + e.message}); continue; }
|
||||
if (!data || !Array.isArray(data.icons)) { totalErr++; rows.push({sid, error:'无 icons 数组'}); continue; }
|
||||
let blank = 0;
|
||||
const samples = [];
|
||||
for (const ic of data.icons) {
|
||||
totalIcons++;
|
||||
const preserve = ic.type && ic.type !== 'icon';
|
||||
const isSt = preserve ? false : isStroke(ic, sid);
|
||||
const attrs = isSt
|
||||
? 'fill="none" stroke="#374151" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"'
|
||||
: 'fill="#374151" stroke="none"';
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg','svg');
|
||||
svg.setAttribute('viewBox','0 0 24 24');
|
||||
applyAttrs(svg, attrs);
|
||||
let body = preserve ? ic.svg : (ic.svg||'')
|
||||
.replace(/fill="currentColor"/g,'fill="inherit"')
|
||||
.replace(/stroke="currentColor"/g,'stroke="inherit"');
|
||||
svg.innerHTML = body;
|
||||
holder.appendChild(svg);
|
||||
let bb = null;
|
||||
try { bb = svg.getBBox(); } catch(e){}
|
||||
svg.remove();
|
||||
const geoW = bb ? bb.width : 0, geoH = bb ? bb.height : 0;
|
||||
// getBBox 为 0 → 无几何;或全部元素 opacity=0 / visibility=hidden → 视觉空白
|
||||
const invisible = /opacity="0"|visibility="hidden"|display="none"/.test(body);
|
||||
const visBlank = (geoW === 0 && geoH === 0);
|
||||
if (visBlank || (invisible && !preserve)) {
|
||||
blank++;
|
||||
totalBlank++;
|
||||
if (samples.length < 10) samples.push(ic.id + ' ' + (visBlank?'[无几何]':'[opacity0]'));
|
||||
}
|
||||
}
|
||||
if (blank) rows.push({ sid, total: data.icons.length, blank, samples });
|
||||
if (fi % 20 === 0) out.innerHTML = '<p>进度 ' + (fi+1) + '/' + files.length + ' …</p>';
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
let html = '<h2>扫描 ' + totalIcons.toLocaleString() + ' 个,空白 ' + totalBlank.toLocaleString() + ',加载失败 ' + totalErr + '</h2>';
|
||||
rows.sort((a,b)=> (b.blank||0)-(a.blank||0)).forEach(r=>{
|
||||
if (r.error) html += '<div class="src">✗ ' + r.sid + ':' + r.error + '</div>';
|
||||
else html += '<div class="src"><b>' + r.sid + '</b>:<span class="bad">' + r.blank + '/' + r.total + '</span>(' + (r.blank/r.total*100).toFixed(2) + '%)' +
|
||||
(r.samples.length ? '<br>' + r.samples.join('<br>') : '') + '</div>';
|
||||
});
|
||||
out.innerHTML = html;
|
||||
document.title = 'AUDIT-DONE ' + totalIcons + '/' + totalBlank + '/' + totalErr;
|
||||
}
|
||||
run();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="logo/favicon.svg">
|
||||
<title>画个标 · 智能头像设计</title>
|
||||
<link rel="stylesheet" href="css/style.css?v=20260815h">
|
||||
<script>window.HGB_WORKSPACE = 'avatar';</script>
|
||||
</head>
|
||||
<body class="avatar-mode">
|
||||
<!-- ===== 顶部工具栏 ===== -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="brand">
|
||||
<img class="brand-icon" src="logo/logo-mark.svg" alt="画个标" width="20" height="20">
|
||||
<span class="brand-name">画个标</span>
|
||||
<span class="brand-tag">✨ 头像设计</span>
|
||||
</div>
|
||||
<input id="project-name" class="project-name" value="我的品牌" maxlength="30" title="项目名称">
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<a class="tb-btn" href="index.html" title="LOGO 设计工作台:图形标识 + 品牌组合,AI 生成多套 LOGO 方案">🎨 LOGO 设计</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="poster.html" title="海报设计工作台:多规格画布 · AI 生成多套海报方案">📰 海报设计</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="emoji.html" title="动态表情设计工作台:GIF/WebM 动图导出">😀 表情设计</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="skills.html" title="技能中心:集中整合所有设计技能的学习来源、详细说明与调用参数">🧩 技能中心</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="sources.html" title="素材中心:分类管理图标/表情/品牌素材,来源、协议与构建信息可查">📚 素材中心</a>
|
||||
<div class="tb-divider"></div>
|
||||
<button class="tb-btn" id="btn-ai-settings" title="配置 DeepSeek API Key">🤖 AI 设置</button>
|
||||
<div class="tb-divider"></div>
|
||||
<button class="tb-btn" id="btn-export-svg" title="导出当前方案为 SVG 矢量图">导出 SVG</button>
|
||||
<button class="tb-btn primary" id="btn-export-png" title="导出当前方案为 PNG 位图">导出 PNG ⬇</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ===== 智能设计工作台 ===== -->
|
||||
<div class="design-workspace">
|
||||
|
||||
<!-- 左:配置区 -->
|
||||
<aside class="design-config">
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">🏭 选择行业</div>
|
||||
<div id="design-industry" class="industry-tabs"></div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group dc-row">
|
||||
<div class="dc-col">
|
||||
<div class="modal-label">品牌名</div>
|
||||
<input type="text" id="design-brand" class="skill-search" placeholder="如:学府教育 / EDUCATION">
|
||||
</div>
|
||||
<div class="dc-col">
|
||||
<div class="modal-label">副标题(可选)</div>
|
||||
<input type="text" id="design-sub" class="skill-search" placeholder="如:GLOBAL EDUCATION">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">💬 品牌描述(AI 设计核心依据)</div>
|
||||
<textarea id="design-desc" class="skill-search dc-desc" rows="2" maxlength="200" placeholder="如:面向高净值家庭的留学教育品牌,主打高端定制与全球视野,稳重而不失现代感…"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">🎨 风格方向(多选;不选 = 智能搭配全部风格)</div>
|
||||
<div id="design-styles" class="industry-tabs"></div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group dc-row">
|
||||
<div class="dc-col">
|
||||
<div class="modal-label">每图标套数 <span id="design-count-label-tip" class="pane-tip" style="display:inline-block;margin-left:4px;font-size:11px;vertical-align:middle;">未选图标=总套数</span></div>
|
||||
<select id="design-count" class="skill-search">
|
||||
<option value="1">1 套</option>
|
||||
<option value="2">2 套</option>
|
||||
<option value="3" selected>3 套</option>
|
||||
<option value="4">4 套</option>
|
||||
<option value="5">5 套</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dc-col dc-gen-btn">
|
||||
<div class="modal-label"> </div>
|
||||
<button class="tb-btn primary w-full" id="btn-generate">🚀 生成头像</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">🎨 选择图标(1-5 个,不选则 AI 自动选用)</div>
|
||||
<div id="design-icons" class="design-icons"></div>
|
||||
<button class="tb-btn w-full import-more-btn" id="btn-builtin-icons" title="浏览并选择内置的 32 万+ 开源图标,供 AI 设计使用">
|
||||
📦 内置库浏览选择(32 万+)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">🧩 设计流程 · 技能流水线 <span class="pane-tip" style="display:inline-block;margin-left:6px;margin-top:4px;vertical-align:middle;">洞察 → 生成 → 评审 → 迭代 → 交付,前 3 步默认启用</span></div>
|
||||
<div id="design-skills" class="skill-checks"></div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">💡 智能提示</div>
|
||||
<p class="pane-tip" id="design-tip">头像 = 纯图形标识(图标 + 容器/环/盾/几何),不含文字,用于微信/QQ/抖音/App 图标/网站 Favicon 等场景,小尺寸(64px)也必须清晰可辨。🎯 选择 1-5 个图标后进入「图标驱动」,配色/构图自动搭配。</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右:方案库 -->
|
||||
<div class="design-plans-wrap">
|
||||
<div class="plans-head">
|
||||
<span id="plans-title">👤 头像库(共 0 套)</span>
|
||||
<span class="plans-tip">点击卡片「预览」查看大图 · 智能切换配色/布局变体 · 平台规格一键导出</span>
|
||||
<span class="plans-head-ops">
|
||||
<button class="tb-btn sm" id="btn-dicebear" title="55 种 DiceBear 风格 × seed 确定性组合,一键生成个性化头像">🧸 DiceBear 头像</button>
|
||||
<button class="tb-btn sm" id="btn-save-version" title="保存当前预览方案到版本库">💾 保存版本</button>
|
||||
<button class="tb-btn sm" id="btn-clear-versions" title="清空所有版本">🧹</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="plans-grid" class="plans-grid"></div>
|
||||
<div id="plans-empty" class="plans-empty">
|
||||
🎯 配置左侧参数,点击「🚀 生成头像」,智能组合多套头像方案
|
||||
</div>
|
||||
<div class="versions-section">
|
||||
<div class="group-title">🗂 方案版本记录(本地保存)</div>
|
||||
<div id="version-list" class="version-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 方案预览弹窗 ===== -->
|
||||
<div id="plan-modal" class="modal-mask hidden">
|
||||
<div class="modal plan-modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">👁 头像预览</span>
|
||||
<span class="modal-title-sub" id="plan-modal-sub"></span>
|
||||
<button class="modal-close" data-plan-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body plan-modal-body">
|
||||
<div class="plan-areas">
|
||||
<div class="plan-area">
|
||||
<div class="plan-area-label">🔷 图形标识 <span class="plan-area-en">Mark</span></div>
|
||||
<div class="plan-big" id="plan-mark"></div>
|
||||
</div>
|
||||
<div class="plan-area plan-area-lockup">
|
||||
<div class="plan-area-label">🅰 品牌组合 <span class="plan-area-en">Lockup</span></div>
|
||||
<div class="plan-lockup-tabs" id="plan-lockup-tabs"></div>
|
||||
<div class="plan-big" id="plan-lockup"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="plan-concept" class="plan-concept-big"></div>
|
||||
<div id="plan-audit" class="plan-audit"></div>
|
||||
<div class="plan-variants">
|
||||
<div class="modal-label">🎨 变体微调(智能重排,无需手工拖拽)</div>
|
||||
<div class="variant-row">
|
||||
<button class="tb-btn" id="btn-var-color" title="切换到下一套配色方案">🎨 换配色</button>
|
||||
<button class="tb-btn" id="btn-var-layout" title="切换到下一个布局模板">🅰 换布局</button>
|
||||
<button class="tb-btn" id="btn-var-more" title="基于当前参数再生成一批方案加入方案库">➕ 更多变体入方案库</button>
|
||||
</div>
|
||||
<div class="modal-label" style="margin-top:12px">📤 导出当前头像</div>
|
||||
<div class="variant-row">
|
||||
<button class="tb-btn primary" id="btn-plan-png">导出 PNG</button>
|
||||
<button class="tb-btn" id="btn-plan-svg">导出 SVG</button>
|
||||
<button class="tb-btn" id="btn-plan-pdf">导出 PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="tb-btn" data-plan-close>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<!-- ===== DiceBear 风格头像弹窗(M4:29 风格 × seed 确定性组合) ===== -->
|
||||
<div id="dicebear-modal" class="modal-mask hidden">
|
||||
<div class="modal dicebear-modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">🧸 DiceBear 风格头像</span>
|
||||
<span class="modal-title-sub">55 种风格 × seed 确定性组合(同一风格同一 seed 输出相同)</span>
|
||||
<button class="modal-close" data-dicebear-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-label">选择风格</div>
|
||||
<div id="dicebear-styles" class="industry-tabs"></div>
|
||||
<div class="dicebear-ops">
|
||||
<input type="text" id="dicebear-seed" class="skill-search" placeholder="seed 种子(留空自动随机)">
|
||||
<button class="tb-btn" id="dicebear-random" title="随机换一批 seed">🎲 换一批</button>
|
||||
<button class="tb-btn primary" id="dicebear-gen" title="当前风格 + 随机 seed 生成 8 套">🚀 生成 8 套</button>
|
||||
<button class="tb-btn" id="dicebear-export" title="导出选中的头像为 PNG(微信/QQ/抖音/App 等规格)">📤 导出 PNG</button>
|
||||
</div>
|
||||
<div id="dicebear-grid" class="dicebear-grid"></div>
|
||||
<div class="dicebear-tip">点击卡片选中(可再次点击切换不同风格);「导出 PNG」按平台规格批量输出并圆形裁切。</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="tb-btn" data-dicebear-close>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== AI 设置弹窗 ===== -->
|
||||
<div id="ai-modal" class="modal-mask hidden">
|
||||
<div class="modal ai-modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">🤖 AI 设置(DeepSeek)</span>
|
||||
<button class="modal-close" data-ai-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-group">
|
||||
<div class="modal-label">API Key</div>
|
||||
<input type="password" id="ai-key" class="skill-search" placeholder="sk-xxxxxxxx" autocomplete="off" spellcheck="false">
|
||||
<p class="pane-tip">Key 仅保存在你本地浏览器(localStorage),不上传任何服务器。请到 platform.deepseek.com 获取。</p>
|
||||
</div>
|
||||
<div class="modal-group">
|
||||
<div class="modal-label">模型</div>
|
||||
<select id="ai-model" class="skill-search"></select>
|
||||
</div>
|
||||
<div id="ai-test-result" class="ai-test-result"></div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="tb-btn" id="btn-ai-clear">清除 Key</button>
|
||||
<button class="tb-btn" id="btn-ai-test">🔌 测试连接</button>
|
||||
<button class="tb-btn primary" id="btn-ai-save">💾 保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 导出对话框 ===== -->
|
||||
<div id="export-modal" class="modal-mask hidden">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">📤 导出头像</span>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="modal-group">
|
||||
<div class="modal-label">导出格式</div>
|
||||
<div class="format-row">
|
||||
<button class="format-btn active" data-format="png">PNG<span class="fmt-note">透明背景</span></button>
|
||||
<button class="format-btn" data-format="jpg">JPG<span class="fmt-note">白色背景</span></button>
|
||||
<button class="format-btn" data-format="svg">SVG<span class="fmt-note">矢量图</span></button>
|
||||
<button class="format-btn" data-format="pdf">PDF<span class="fmt-note">文档/印刷</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-group" id="size-group">
|
||||
<div class="modal-label">尺寸规格(可多选)</div>
|
||||
<div class="size-row">
|
||||
<label class="size-opt"><input type="checkbox" value="256" checked> 头像 256<span class="size-note">微信/微博</span></label>
|
||||
<label class="size-opt"><input type="checkbox" value="512" checked> 通用 512<span class="size-note">网站</span></label>
|
||||
<label class="size-opt"><input type="checkbox" value="1024"> 印刷 1024<span class="size-note">名片物料</span></label>
|
||||
<label class="size-opt"><input type="checkbox" value="2048"> 超清 2048<span class="size-note">海报横幅</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-group hidden" id="avatar-size-group">
|
||||
<div class="modal-label">📱 头像平台规格(可多选)</div>
|
||||
<div class="size-row" id="avatar-size-list"></div>
|
||||
<div class="size-row avatar-custom-row">
|
||||
<label class="size-opt">自定义 <input type="number" id="avatar-custom-size" value="500" min="32" max="2048" class="avatar-custom-input"> px</label>
|
||||
<label class="size-opt"><input type="checkbox" id="avatar-round" checked> 🔵 圆形裁切</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-group" id="bg-group">
|
||||
<div class="modal-label">背景选项</div>
|
||||
<div class="bg-opt-row">
|
||||
<label class="size-opt"><input type="radio" name="export-bg" value="design" checked> 方案背景</label>
|
||||
<label class="size-opt"><input type="radio" name="export-bg" value="white"> 纯白色</label>
|
||||
<label class="size-opt" id="bg-transparent-opt"><input type="radio" name="export-bg" value="transparent"> 透明(仅PNG)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="tb-btn" data-close>取消</button>
|
||||
<button class="tb-btn primary" id="btn-export-go">⬇ 立即导出</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 内置图标库浏览弹窗 ===== -->
|
||||
<div id="builtin-modal" class="modal-mask hidden">
|
||||
<div class="modal builtin-modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">📦 内置图标库(离线 32 万+)</span>
|
||||
<span class="modal-title-sub" id="builtin-head-info"></span>
|
||||
<button class="modal-close" data-builtin-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="builtin-toolbar">
|
||||
<div id="builtin-types" class="builtin-types"></div>
|
||||
<div id="builtin-sources" class="builtin-sources"></div>
|
||||
<div id="builtin-cats" class="builtin-sources builtin-cats"></div>
|
||||
<div class="builtin-search-row">
|
||||
<input type="text" id="builtin-search" class="skill-search" placeholder="🔍 搜索图标名称(英文)…" autocomplete="off">
|
||||
<span id="builtin-count" class="gh-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="builtin-grid" class="gh-grid"></div>
|
||||
<div id="builtin-more-wrap" class="gh-more-wrap">
|
||||
<button class="tb-btn" id="builtin-load-more">加载更多</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span id="builtin-foot" class="gh-foot-info"></span>
|
||||
<button class="tb-btn" data-builtin-close>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== GitHub 资源浏览弹窗(已移除:内置 32 万+ 图标库覆盖需求) ===== -->
|
||||
|
||||
<!-- 脚本 -->
|
||||
<script src="js/data/icons.js"></script>
|
||||
<script src="js/data/industries.js"></script>
|
||||
<script src="js/data/styles.js?v=20260813"></script>
|
||||
<script src="js/data/palettes.js"></script>
|
||||
<script src="js/data/fonts.js"></script>
|
||||
<script src="js/data/decorations.js"></script>
|
||||
<script src="js/core/store.js?v=20260816"></script>
|
||||
<script src="js/core/exporter.js?v=20260812h"></script>
|
||||
<script src="js/core/generator.js?v=20260815c"></script>
|
||||
<script src="js/core/avatarGen.js?v=20260815c"></script>
|
||||
<script src="js/core/dicebear.js?v=20260822e"></script>
|
||||
<script src="js/core/ai.js?v=20260927a"></script>
|
||||
<script src="js/core/aiDesigner.js?v=20260812h"></script>
|
||||
<script src="js/skills/core.js?v=20260824a"></script>
|
||||
<script src="js/skills/layout.js?v=20260823d"></script>
|
||||
<script src="js/skills/color.js?v=20260823d"></script>
|
||||
<script src="js/skills/typography.js?v=20260823d"></script>
|
||||
<script src="js/skills/icon.js?v=20260823d"></script>
|
||||
<script src="js/skills/ai.js?v=20260815b"></script>
|
||||
<script src="js/skills/presentation.js?v=20260823d"></script>
|
||||
<script src="js/skills/export.js?v=20260823d"></script>
|
||||
<script src="js/ui/panels.js?v=20260927a"></script>
|
||||
<script src="js/data/builtin-icons.js?v=20260823g"></script>
|
||||
<script src="js/core/iconlib.js?v=20260823g"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1214
File diff suppressed because it is too large
Load Diff
+223
@@ -0,0 +1,223 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="logo/favicon.svg">
|
||||
<title>画个标 · 动态表情设计</title>
|
||||
<link rel="stylesheet" href="css/style.css?v=20260815h">
|
||||
<script>window.HGB_WORKSPACE = 'emoji';</script>
|
||||
<style>
|
||||
/* ===== 动态表情工作区专用样式(独立模块,不混入 style.css) ===== */
|
||||
.plan-preview-emoji { display: flex; align-items: center; justify-content: center; background: #fff; }
|
||||
.plan-preview-emoji svg { width: 84%; height: 84%; object-fit: contain; }
|
||||
/* 选中图标芯片 */
|
||||
.design-icon { position: relative; width: 54px; height: 54px; border: 1px solid var(--border); border-radius: 9px;
|
||||
display: flex; align-items: center; justify-content: center; background: #fff; }
|
||||
.design-icon svg { width: 26px; height: 26px; color: var(--text); }
|
||||
.design-icon:hover { border-color: #a5b4fc; }
|
||||
.icon-del { position: absolute; top: -7px; right: -7px; width: 19px; height: 19px; border-radius: 50%;
|
||||
border: none; background: #ef4444; color: #fff; font-size: 11px; line-height: 1; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; padding: 0; }
|
||||
.icon-del:hover { background: #dc2626; }
|
||||
/* 预览弹窗内动画类型切换 */
|
||||
#plan-anim { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 4px; }
|
||||
/* 导出表单 */
|
||||
.export-emoji { display: flex; flex-direction: column; gap: 10px; margin-top: 10px;
|
||||
border: 1px dashed var(--border); border-radius: 10px; padding: 12px 14px; background: #fafafa; }
|
||||
.ee-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.ee-label { font-size: 12px; color: var(--text-dim); flex: 0 0 40px; }
|
||||
.ee-fps { width: 76px; }
|
||||
.ee-row .size-row, .ee-row .bg-opt-row { flex: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="emoji-mode">
|
||||
<!-- ===== 顶部工具栏 ===== -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="brand">
|
||||
<img class="brand-icon" src="logo/logo-mark.svg" alt="画个标" width="20" height="20">
|
||||
<span class="brand-name">画个标</span>
|
||||
<span class="brand-tag">😀 动态表情设计</span>
|
||||
</div>
|
||||
<input id="project-name" class="project-name" value="我的表情" maxlength="30" title="项目名称">
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<a class="tb-btn" href="index.html" title="LOGO 设计工作台:图形标识 + 品牌组合,AI 生成多套 LOGO 方案">🎨 LOGO 设计</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="avatar.html" title="头像设计工作台:纯图形标识,平台规格一键导出">👤 头像设计</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="poster.html" title="海报设计工作台:多规格画布 · AI 生成多套海报方案">📰 海报设计</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn primary" href="emoji.html" title="动态表情设计工作台:GIF/WebM 动图导出">😀 表情设计</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="skills.html" title="技能中心:集中整合所有设计技能的学习来源、详细说明与调用参数">🧩 技能中心</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="sources.html" title="素材中心:分类管理图标/表情/品牌素材,来源、协议与构建信息可查">📚 素材中心</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ===== 智能设计工作台 ===== -->
|
||||
<div class="design-workspace">
|
||||
|
||||
<!-- 左:配置区 -->
|
||||
<aside class="design-config">
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">🏭 选择行业</div>
|
||||
<div id="design-industry" class="industry-tabs"></div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">😊 选择情绪(影响动画动效,AI 与内置引擎均按情绪匹配)</div>
|
||||
<div id="design-mood" class="industry-tabs"></div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">🎨 选择图标(1-5 个,不选则 AI 从行业与内置库自动选用)</div>
|
||||
<div id="design-icons" class="design-icons"></div>
|
||||
<button class="tb-btn w-full import-more-btn" id="btn-builtin-icons" title="浏览并选择内置的 32 万+ 开源图标,供表情设计使用">
|
||||
📦 内置库浏览选择(32 万+)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dc-group dc-row">
|
||||
<div class="dc-col dc-gen-btn">
|
||||
<button class="tb-btn primary w-full" id="btn-generate">🚀 生成表情包</button>
|
||||
</div>
|
||||
<div class="dc-col dc-gen-btn">
|
||||
<button class="tb-btn w-full" id="btn-regenerate" title="重新生成一张底图(同参数换布局/配色/图标),整组动画同步更新">🎲 换一张底图</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">💡 智能提示</div>
|
||||
<p class="pane-tip" id="design-tip">动态表情包 = 一张静态头像/图形底图 × 全部动画类型(弹跳/脉冲/摇摆/漂浮/旋转/闪烁/旋摆),微信/QQ 表情包正是这种「同图多动效」形态。🎯 点「🚀 生成表情包」出一张底图并自动展开全部动效;不满意点「🎲 换一张底图」;每张卡片自动播放动画,点「预览」可切换动效并导出 GIF(微信 240 / QQ 320,透明背景)或 WebM(Telegram 512)。</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右:方案库 -->
|
||||
<div class="design-plans-wrap">
|
||||
<div class="plans-head">
|
||||
<span id="plans-title">😀 表情包(共 0 张)</span>
|
||||
<span class="plans-tip">同一张底图 × 全部动画类型 · 卡片自动播放 · 点「预览」切换动效并导出 GIF / WebM</span>
|
||||
</div>
|
||||
<div id="plans-grid" class="plans-grid"></div>
|
||||
<div id="plans-empty" class="plans-empty">
|
||||
🎯 配置左侧参数,点击「🚀 生成表情包」——一张底图自动展开全部动画动效(弹跳/脉冲/摇摆/漂浮/旋转/闪烁/旋摆)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 方案预览弹窗 ===== -->
|
||||
<div id="plan-modal" class="modal-mask hidden">
|
||||
<div class="modal plan-modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">😀 表情预览</span>
|
||||
<span class="modal-title-sub" id="plan-modal-sub"></span>
|
||||
<button class="modal-close" data-plan-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body plan-modal-body">
|
||||
<div class="plan-areas">
|
||||
<div class="plan-area">
|
||||
<div class="plan-area-label">🔷 动态图形 <span class="plan-area-en">Mark + Motion</span></div>
|
||||
<div class="plan-big" id="plan-mark"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-label" style="margin-top:6px">🎬 动画类型(预览内切换)</div>
|
||||
<div id="plan-anim"></div>
|
||||
<div id="plan-concept" class="plan-concept-big"></div>
|
||||
<div id="plan-audit" class="plan-audit"></div>
|
||||
<div class="plan-variants">
|
||||
<div class="modal-label">🎨 换底图变体(整组动画同步更新)</div>
|
||||
<div class="variant-row">
|
||||
<button class="tb-btn" id="btn-var-color" title="切换到下一套配色/风格">🎨 换配色</button>
|
||||
<button class="tb-btn" id="btn-var-layout" title="切换到下一个布局模板">🅰 换布局</button>
|
||||
</div>
|
||||
<div class="export-emoji">
|
||||
<div class="ee-row">
|
||||
<span class="ee-label">尺寸</span>
|
||||
<div class="size-row">
|
||||
<label class="size-opt"><input type="radio" name="em-size" value="240" checked> 240<span class="size-note">微信</span></label>
|
||||
<label class="size-opt"><input type="radio" name="em-size" value="320"> 320<span class="size-note">QQ</span></label>
|
||||
<label class="size-opt"><input type="radio" name="em-size" value="512"> 512<span class="size-note">Telegram</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ee-row">
|
||||
<span class="ee-label">帧率</span>
|
||||
<input type="number" id="em-fps" class="skill-search ee-fps" value="12" min="6" max="24" step="1">
|
||||
<span class="size-note">fps(GIF/WebM 共用)</span>
|
||||
</div>
|
||||
<div class="ee-row">
|
||||
<span class="ee-label">背景</span>
|
||||
<div class="bg-opt-row">
|
||||
<label class="size-opt"><input type="radio" name="em-bg" value="design" checked> 方案背景</label>
|
||||
<label class="size-opt"><input type="radio" name="em-bg" value="white"> 纯白</label>
|
||||
<label class="size-opt"><input type="radio" name="em-bg" value="transparent"> 透明</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="variant-row">
|
||||
<button class="tb-btn" id="btn-export-png" title="导出当前静态帧为 PNG">导出 PNG</button>
|
||||
<button class="tb-btn primary" id="btn-export-gif" title="导出 GIF 动图(微信/QQ 表情主流格式,透明背景)">导出 GIF ⬇</button>
|
||||
<button class="tb-btn" id="btn-export-webm" title="导出 WebM 视频(Telegram 贴纸格式)">导出 WebM</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="tb-btn" data-plan-close>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 内置图标库浏览弹窗 ===== -->
|
||||
<div id="builtin-modal" class="modal-mask hidden">
|
||||
<div class="modal builtin-modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">📦 内置图标库(离线 32 万+)</span>
|
||||
<span class="modal-title-sub">点击选择图标(最多 5 个),用于表情方案</span>
|
||||
<button class="modal-close" data-builtin-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="builtin-toolbar">
|
||||
<div class="builtin-search-row">
|
||||
<input type="text" id="builtin-search" class="skill-search" placeholder="🔍 搜索图标名称(英文),留空浏览已加载源…" autocomplete="off">
|
||||
<span id="builtin-count" class="gh-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="builtin-grid" class="gh-grid"></div>
|
||||
<div id="builtin-more-wrap" class="gh-more-wrap">
|
||||
<button class="tb-btn" id="builtin-load-more">加载更多</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span class="gh-foot-info">已选图标会立即反映到左侧「选择图标」区</span>
|
||||
<button class="tb-btn" data-builtin-close>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<!-- 脚本(表情模块:core → 数据 → 生成 → 动画 → 编码 → 技能 → 图标库 → UI) -->
|
||||
<script src="js/data/icons.js"></script>
|
||||
<script src="js/data/industries.js"></script>
|
||||
<script src="js/data/styles.js?v=20260813"></script>
|
||||
<script src="js/data/palettes.js"></script>
|
||||
<script src="js/data/fonts.js"></script>
|
||||
<script src="js/data/decorations.js"></script>
|
||||
<script src="js/core/store.js?v=20260816"></script>
|
||||
<script src="js/core/ai.js?v=20260927a"></script>
|
||||
<script src="js/core/exporter.js?v=20260812h"></script>
|
||||
<script src="js/core/generator.js?v=20260815c"></script>
|
||||
<script src="js/core/avatarGen.js?v=20260815c"></script>
|
||||
<script src="js/core/motion.js?v=20260823h"></script>
|
||||
<script src="js/core/gifEncoder.js?v=20260823e"></script>
|
||||
<script src="js/core/emojiGen.js?v=20260824a"></script>
|
||||
<script src="js/core/emojiAi.js?v=20260824a"></script>
|
||||
<script src="js/sticker-skills/core.js?v=20260823e"></script>
|
||||
<script src="js/sticker-skills/skills.js?v=20260824a"></script>
|
||||
<script src="js/data/builtin-icons.js?v=20260823g"></script>
|
||||
<script src="js/core/iconlib.js?v=20260823g"></script>
|
||||
<script src="js/ui/emoji.js?v=20260824a"></script>
|
||||
</body>
|
||||
</html>
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="logo/favicon.svg">
|
||||
<title>画个标 · 智能 LOGO 设计</title>
|
||||
<link rel="stylesheet" href="css/style.css?v=20260815h">
|
||||
<script>window.HGB_WORKSPACE = 'logo';</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- ===== 顶部工具栏 ===== -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="brand">
|
||||
<img class="brand-icon" src="logo/logo-mark.svg" alt="画个标" width="20" height="20">
|
||||
<span class="brand-name">画个标</span>
|
||||
<span class="brand-tag">✨ 智能设计</span>
|
||||
</div>
|
||||
<input id="project-name" class="project-name" value="我的品牌" maxlength="30" title="项目名称">
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<a class="tb-btn" href="avatar.html" title="头像设计工作台:纯图形标识 · 圆形预览 · 微信/QQ/抖音/App图标等平台规格一键导出">🖼 头像设计</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="poster.html" title="海报设计工作台:多规格画布 · AI 生成多套海报方案">📰 海报设计</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="emoji.html" title="动态表情设计工作台:GIF/WebM 动图导出">😀 表情设计</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="skills.html" title="技能中心:集中整合所有设计技能的学习来源、详细说明与调用参数">🧩 技能中心</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="sources.html" title="素材中心:分类管理图标/表情/品牌素材,来源、协议与构建信息可查">📚 素材中心</a>
|
||||
<div class="tb-divider"></div>
|
||||
<a class="tb-btn" href="materials.html" title="素材库:按行预览 LOGO/头像/海报素材,快速浏览大致样式">🗃 素材库</a>
|
||||
<div class="tb-divider"></div>
|
||||
<button class="tb-btn" id="btn-ai-settings" title="配置 DeepSeek API Key">🤖 AI 设置</button>
|
||||
<div class="tb-divider"></div>
|
||||
<button class="tb-btn" id="btn-export-svg" title="导出当前方案为 SVG 矢量图">导出 SVG</button>
|
||||
<button class="tb-btn primary" id="btn-export-png" title="导出当前方案为 PNG 位图">导出 PNG ⬇</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ===== 智能设计工作台 ===== -->
|
||||
<div class="design-workspace">
|
||||
|
||||
<!-- 左:配置区 -->
|
||||
<aside class="design-config">
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">🏭 选择行业</div>
|
||||
<div id="design-industry" class="industry-tabs"></div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group dc-row">
|
||||
<div class="dc-col">
|
||||
<div class="modal-label">品牌名</div>
|
||||
<input type="text" id="design-brand" class="skill-search" placeholder="如:学府教育 / EDUCATION">
|
||||
</div>
|
||||
<div class="dc-col">
|
||||
<div class="modal-label">副标题(可选)</div>
|
||||
<input type="text" id="design-sub" class="skill-search" placeholder="如:GLOBAL EDUCATION">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">💬 品牌描述(AI 设计核心依据)</div>
|
||||
<textarea id="design-desc" class="skill-search dc-desc" rows="2" maxlength="200" placeholder="如:面向高净值家庭的留学教育品牌,主打高端定制与全球视野,稳重而不失现代感…"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">🎨 风格方向(多选;不选 = 智能搭配全部风格)</div>
|
||||
<div id="design-styles" class="industry-tabs"></div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group dc-row">
|
||||
<div class="dc-col">
|
||||
<div class="modal-label">每图标套数 <span id="design-count-label-tip" class="pane-tip" style="display:inline-block;margin-left:4px;font-size:11px;vertical-align:middle;">未选图标=总套数</span></div>
|
||||
<select id="design-count" class="skill-search">
|
||||
<option value="1">1 套</option>
|
||||
<option value="2">2 套</option>
|
||||
<option value="3" selected>3 套</option>
|
||||
<option value="4">4 套</option>
|
||||
<option value="5">5 套</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dc-col dc-gen-btn">
|
||||
<div class="modal-label"> </div>
|
||||
<button class="tb-btn primary w-full" id="btn-generate">🤖 AI 生成方案</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">🎨 选择图标(1-5 个,不选则 AI 自动选用)</div>
|
||||
<div id="design-icons" class="design-icons"></div>
|
||||
<button class="tb-btn w-full import-more-btn" id="btn-builtin-icons" title="浏览并选择内置的 32 万+ 开源图标,供 AI 设计使用">
|
||||
📦 内置库浏览选择(32 万+)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">🧩 设计流程 · 技能流水线 <span class="pane-tip" style="display:inline-block;margin-left:6px;margin-top:4px;vertical-align:middle;">洞察 → 生成 → 评审 → 迭代 → 交付,前 3 步默认启用</span></div>
|
||||
<div id="design-skills" class="skill-checks"></div>
|
||||
</div>
|
||||
|
||||
<div class="dc-group">
|
||||
<div class="modal-label">💡 智能提示</div>
|
||||
<p class="pane-tip" id="design-tip">未选图标:AI 基于「品牌描述 + 行业 + 内置图标库 + 启用技能」自主设计多套方案。🎯 选择 1-5 个图标后进入「图标驱动」:每个选中图标固定作为核心图形、各产出「每图标套数」套(总方案 = 图标数 × 套数),配色/字体/构图自动搭配。</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右:方案库 -->
|
||||
<div class="design-plans-wrap">
|
||||
<div class="plans-head">
|
||||
<span id="plans-title">📚 方案库(共 0 套)</span>
|
||||
<span class="plans-tip">点击卡片「预览」查看大图 · 智能切换配色/布局变体 · 一键导出</span>
|
||||
<span class="plans-head-ops">
|
||||
<button class="tb-btn sm hidden" id="btn-dicebear" title="55 种 DiceBear 风格 × seed 确定性组合,一键生成个性化头像(仅头像模式)">🧸 DiceBear 头像</button>
|
||||
<button class="tb-btn sm" id="btn-save-version" title="保存当前预览方案到版本库">💾 保存版本</button>
|
||||
<button class="tb-btn sm" id="btn-clear-versions" title="清空所有版本">🧹</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="plans-grid" class="plans-grid"></div>
|
||||
<div id="plans-empty" class="plans-empty">
|
||||
🎯 配置左侧参数,点击「🚀 生成方案」,智能组合多套 LOGO 方案
|
||||
</div>
|
||||
<div class="versions-section">
|
||||
<div class="group-title">🗂 方案版本记录(本地保存)</div>
|
||||
<div id="version-list" class="version-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 方案预览弹窗 ===== -->
|
||||
<div id="plan-modal" class="modal-mask hidden">
|
||||
<div class="modal plan-modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">👁 方案预览</span>
|
||||
<span class="modal-title-sub" id="plan-modal-sub"></span>
|
||||
<button class="modal-close" data-plan-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body plan-modal-body">
|
||||
<div class="plan-areas">
|
||||
<div class="plan-area">
|
||||
<div class="plan-area-label">🔷 图形标识 <span class="plan-area-en">Mark</span></div>
|
||||
<div class="plan-big" id="plan-mark"></div>
|
||||
</div>
|
||||
<div class="plan-area plan-area-lockup">
|
||||
<div class="plan-area-label">🅰 品牌组合 <span class="plan-area-en">Lockup</span></div>
|
||||
<div class="plan-lockup-tabs" id="plan-lockup-tabs"></div>
|
||||
<div class="plan-big" id="plan-lockup"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="plan-concept" class="plan-concept-big"></div>
|
||||
<div id="plan-audit" class="plan-audit"></div>
|
||||
<div class="plan-variants">
|
||||
<div class="modal-label">🎨 变体微调(智能重排,无需手工拖拽)</div>
|
||||
<div class="variant-row">
|
||||
<button class="tb-btn" id="btn-var-color" title="切换到下一套配色方案">🎨 换配色</button>
|
||||
<button class="tb-btn" id="btn-var-layout" title="切换到下一个布局模板">🅰 换布局</button>
|
||||
<button class="tb-btn" id="btn-var-more" title="基于当前参数再生成一批方案加入方案库">➕ 更多变体入方案库</button>
|
||||
</div>
|
||||
<div class="modal-label" style="margin-top:12px">📤 导出当前方案</div>
|
||||
<div class="variant-row">
|
||||
<button class="tb-btn primary" id="btn-plan-png">导出 PNG</button>
|
||||
<button class="tb-btn" id="btn-plan-svg">导出 SVG</button>
|
||||
<button class="tb-btn" id="btn-plan-pdf">导出 PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="tb-btn" data-plan-close>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<!-- ===== DiceBear 风格头像弹窗(M4:29 风格 × seed 确定性组合) ===== -->
|
||||
<div id="dicebear-modal" class="modal-mask hidden">
|
||||
<div class="modal dicebear-modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">🧸 DiceBear 风格头像</span>
|
||||
<span class="modal-title-sub">55 种风格 × seed 确定性组合(同一风格同一 seed 输出相同)</span>
|
||||
<button class="modal-close" data-dicebear-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-label">选择风格</div>
|
||||
<div id="dicebear-styles" class="industry-tabs"></div>
|
||||
<div class="dicebear-ops">
|
||||
<input type="text" id="dicebear-seed" class="skill-search" placeholder="seed 种子(留空自动随机)">
|
||||
<button class="tb-btn" id="dicebear-random" title="随机换一批 seed">🎲 换一批</button>
|
||||
<button class="tb-btn primary" id="dicebear-gen" title="当前风格 + 随机 seed 生成 8 套">🚀 生成 8 套</button>
|
||||
<button class="tb-btn" id="dicebear-export" title="导出选中的头像为 PNG(微信/QQ/抖音/App 等规格)">📤 导出 PNG</button>
|
||||
</div>
|
||||
<div id="dicebear-grid" class="dicebear-grid"></div>
|
||||
<div class="dicebear-tip">点击卡片选中(可再次点击切换不同风格);「导出 PNG」按平台规格批量输出并圆形裁切。</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="tb-btn" data-dicebear-close>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== AI 设置弹窗 ===== -->
|
||||
<div id="ai-modal" class="modal-mask hidden">
|
||||
<div class="modal ai-modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">🤖 AI 设置(DeepSeek)</span>
|
||||
<button class="modal-close" data-ai-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-group">
|
||||
<div class="modal-label">API Key</div>
|
||||
<input type="password" id="ai-key" class="skill-search" placeholder="sk-xxxxxxxx" autocomplete="off" spellcheck="false">
|
||||
<p class="pane-tip">Key 仅保存在你本地浏览器(localStorage),不上传任何服务器。请到 platform.deepseek.com 获取。</p>
|
||||
</div>
|
||||
<div class="modal-group">
|
||||
<div class="modal-label">模型</div>
|
||||
<select id="ai-model" class="skill-search"></select>
|
||||
</div>
|
||||
<div id="ai-test-result" class="ai-test-result"></div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="tb-btn" id="btn-ai-clear">清除 Key</button>
|
||||
<button class="tb-btn" id="btn-ai-test">🔌 测试连接</button>
|
||||
<button class="tb-btn primary" id="btn-ai-save">💾 保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 导出对话框 ===== -->
|
||||
<div id="export-modal" class="modal-mask hidden">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">📤 导出 LOGO</span>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="modal-group">
|
||||
<div class="modal-label">导出格式</div>
|
||||
<div class="format-row">
|
||||
<button class="format-btn active" data-format="png">PNG<span class="fmt-note">透明背景</span></button>
|
||||
<button class="format-btn" data-format="jpg">JPG<span class="fmt-note">白色背景</span></button>
|
||||
<button class="format-btn" data-format="svg">SVG<span class="fmt-note">矢量图</span></button>
|
||||
<button class="format-btn" data-format="pdf">PDF<span class="fmt-note">文档/印刷</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-group" id="size-group">
|
||||
<div class="modal-label">尺寸规格(可多选)</div>
|
||||
<div class="size-row">
|
||||
<label class="size-opt"><input type="checkbox" value="256" checked> 头像 256<span class="size-note">微信/微博</span></label>
|
||||
<label class="size-opt"><input type="checkbox" value="512" checked> 通用 512<span class="size-note">网站</span></label>
|
||||
<label class="size-opt"><input type="checkbox" value="1024"> 印刷 1024<span class="size-note">名片物料</span></label>
|
||||
<label class="size-opt"><input type="checkbox" value="2048"> 超清 2048<span class="size-note">海报横幅</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-group hidden" id="avatar-size-group">
|
||||
<div class="modal-label">📱 头像平台规格(可多选)</div>
|
||||
<div class="size-row" id="avatar-size-list"></div>
|
||||
<div class="size-row avatar-custom-row">
|
||||
<label class="size-opt">自定义 <input type="number" id="avatar-custom-size" value="500" min="32" max="2048" class="avatar-custom-input"> px</label>
|
||||
<label class="size-opt"><input type="checkbox" id="avatar-round" checked> 🔵 圆形裁切</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-group" id="bg-group">
|
||||
<div class="modal-label">背景选项</div>
|
||||
<div class="bg-opt-row">
|
||||
<label class="size-opt"><input type="radio" name="export-bg" value="design" checked> 方案背景</label>
|
||||
<label class="size-opt"><input type="radio" name="export-bg" value="white"> 纯白色</label>
|
||||
<label class="size-opt" id="bg-transparent-opt"><input type="radio" name="export-bg" value="transparent"> 透明(仅PNG)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="tb-btn" data-close>取消</button>
|
||||
<button class="tb-btn primary" id="btn-export-go">⬇ 立即导出</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 内置图标库浏览弹窗 ===== -->
|
||||
<div id="builtin-modal" class="modal-mask hidden">
|
||||
<div class="modal builtin-modal">
|
||||
<div class="modal-head">
|
||||
<span class="modal-title">📦 内置图标库(离线 32 万+)</span>
|
||||
<span class="modal-title-sub" id="builtin-head-info"></span>
|
||||
<button class="modal-close" data-builtin-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="builtin-toolbar">
|
||||
<div id="builtin-types" class="builtin-types"></div>
|
||||
<div id="builtin-sources" class="builtin-sources"></div>
|
||||
<div id="builtin-cats" class="builtin-sources builtin-cats"></div>
|
||||
<div class="builtin-search-row">
|
||||
<input type="text" id="builtin-search" class="skill-search" placeholder="🔍 搜索图标名称(英文)…" autocomplete="off">
|
||||
<span id="builtin-count" class="gh-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="builtin-grid" class="gh-grid"></div>
|
||||
<div id="builtin-more-wrap" class="gh-more-wrap">
|
||||
<button class="tb-btn" id="builtin-load-more">加载更多</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span id="builtin-foot" class="gh-foot-info"></span>
|
||||
<button class="tb-btn" data-builtin-close>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== GitHub 资源浏览弹窗(已移除:内置 32 万+ 图标库覆盖需求) ===== -->
|
||||
|
||||
<!-- 脚本 -->
|
||||
<script src="js/data/icons.js"></script>
|
||||
<script src="js/data/industries.js"></script>
|
||||
<script src="js/data/styles.js?v=20260813"></script>
|
||||
<script src="js/data/palettes.js"></script>
|
||||
<script src="js/data/fonts.js"></script>
|
||||
<script src="js/data/decorations.js"></script>
|
||||
<script src="js/core/store.js?v=20260816"></script>
|
||||
<script src="js/core/exporter.js?v=20260812h"></script>
|
||||
<script src="js/core/generator.js?v=20260815c"></script>
|
||||
<script src="js/core/avatarGen.js?v=20260815c"></script>
|
||||
<script src="js/core/dicebear.js?v=20260822e"></script>
|
||||
<script src="js/core/ai.js?v=20260927a"></script>
|
||||
<script src="js/core/aiDesigner.js?v=20260812h"></script>
|
||||
<script src="js/skills/core.js?v=20260824a"></script>
|
||||
<script src="js/skills/layout.js?v=20260824a"></script>
|
||||
<script src="js/skills/color.js?v=20260824a"></script>
|
||||
<script src="js/skills/typography.js?v=20260824a"></script>
|
||||
<script src="js/skills/icon.js?v=20260824a"></script>
|
||||
<script src="js/skills/ai.js?v=20260815b"></script>
|
||||
<script src="js/skills/presentation.js?v=20260824a"></script>
|
||||
<script src="js/skills/export.js?v=20260824a"></script>
|
||||
<script src="js/ui/panels.js?v=20260927a"></script>
|
||||
<script src="js/data/builtin-icons.js?v=20260823g"></script>
|
||||
<script src="js/core/iconlib.js?v=20260823g"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 画个标 · 应用入口(纯智能设计模式)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function boot() {
|
||||
// 初始化 Store(恢复本地缓存的项目名等)
|
||||
HGB_Store.init(true);
|
||||
|
||||
// 初始化智能设计工作台 UI
|
||||
HGB_UI.init();
|
||||
|
||||
// 顶栏项目名初始同步
|
||||
const nameInput = document.getElementById('project-name');
|
||||
if (nameInput && HGB_Store.get().name) {
|
||||
nameInput.value = HGB_Store.get().name;
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 画个标 · AI 客户端:DeepSeek API 接入
|
||||
* 职责:API Key 管理(localStorage)、对话调用、错误分类、连接测试
|
||||
*/
|
||||
const HGB_AI = (function () {
|
||||
'use strict';
|
||||
|
||||
const LS_KEY = 'hgb_ai_key';
|
||||
const LS_MODEL = 'hgb_ai_model';
|
||||
const ENDPOINT = 'https://api.deepseek.com/chat/completions';
|
||||
const MODELS = ['deepseek-v4-flash', 'deepseek-chat', 'deepseek-v4-pro'];
|
||||
|
||||
/**
|
||||
* 不内置任何 Key:Key 仅由使用者在「AI 设置」中填写,存本机 localStorage,只发给 DeepSeek。
|
||||
* 因此源码、仓库与部署产物里都不含 Key,公网也无法被他人读取(BYOK 模式)。
|
||||
*/
|
||||
const DEFAULT_KEY = '';
|
||||
|
||||
/* ---------- Key 管理 ---------- */
|
||||
function getKey() {
|
||||
try {
|
||||
const v = localStorage.getItem(LS_KEY);
|
||||
// 未配置时返回空串,由调用方提示「未配置 API Key」并降级为内置引擎
|
||||
if (v) return v;
|
||||
return DEFAULT_KEY;
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
function setKey(key) {
|
||||
try { localStorage.setItem(LS_KEY, (key || '').trim()); } catch (e) { /* ignore */ }
|
||||
}
|
||||
function hasKey() { return !!getKey(); }
|
||||
function getModel() {
|
||||
try { return localStorage.getItem(LS_MODEL) || 'deepseek-v4-flash'; } catch (e) { return 'deepseek-v4-flash'; }
|
||||
}
|
||||
function setModel(m) { try { localStorage.setItem(LS_MODEL, m); } catch (e) { /* ignore */ } }
|
||||
function clearKey() {
|
||||
// 清除本机保存的 Key(已无内置 Key 可回落,清除后即降级为内置引擎)
|
||||
try { localStorage.removeItem(LS_KEY); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
/* ---------- 调用 ---------- */
|
||||
/**
|
||||
* 调用 DeepSeek 对话补全
|
||||
* @param {Array} messages [{role:'system'|'user', content}]
|
||||
* @param {object} opts { json?: boolean, maxTokens?: number, temperature?: number }
|
||||
* @returns {Promise<string>} 返回内容文本
|
||||
*/
|
||||
async function chat(messages, opts) {
|
||||
const key = getKey();
|
||||
if (!key) {
|
||||
const err = new Error('未配置 API Key,请先在「AI 设置」中填写');
|
||||
err.code = 'NO_KEY';
|
||||
throw err;
|
||||
}
|
||||
opts = opts || {};
|
||||
const body = {
|
||||
model: getModel(),
|
||||
messages,
|
||||
max_tokens: opts.maxTokens || 4096,
|
||||
temperature: opts.temperature != null ? opts.temperature : 0.5,
|
||||
stream: false
|
||||
};
|
||||
if (opts.json) {
|
||||
body.response_format = { type: 'json_object' };
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + key
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
} catch (e) {
|
||||
const err = new Error('网络请求失败:' + e.message + '(请检查网络/代理)');
|
||||
err.code = 'NETWORK';
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try { detail = (await res.json()).error?.message || ''; } catch (e) { /* ignore */ }
|
||||
const err = new Error(detail || 'HTTP ' + res.status);
|
||||
if (res.status === 401) err.code = 'AUTH';
|
||||
else if (res.status === 402) err.code = 'BALANCE';
|
||||
else if (res.status === 429) err.code = 'RATE_LIMIT';
|
||||
else err.code = 'API';
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
const err = new Error('AI 返回内容为空');
|
||||
err.code = 'EMPTY';
|
||||
throw err;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/** 测试连接:发送最小请求验证 Key 是否有效 */
|
||||
async function testConnection() {
|
||||
const content = await chat([
|
||||
{ role: 'user', content: '回复两个字:正常' }
|
||||
], { maxTokens: 16, temperature: 0 });
|
||||
return content;
|
||||
}
|
||||
|
||||
return { getKey, setKey, hasKey, getModel, setModel, clearKey, chat, testConnection, MODELS };
|
||||
})();
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* 画个标 · AI 智能设计器
|
||||
* 核心:把品牌需求 + 内置资源库(图标/布局/配色/风格/字体)作为白名单交给 DeepSeek,
|
||||
* AI 输出设计决策 JSON,系统校验后映射为完整方案(复用 generator 渲染)
|
||||
*/
|
||||
const HGB_AIDesigner = (function () {
|
||||
'use strict';
|
||||
|
||||
/* ---------- 内置库解析 ---------- */
|
||||
/** 从已加载的素材源按 "source:name" 解析图标对象 {id,name,svg,type}(懒加载适配:仅搜索已加载源,未加载返回 null) */
|
||||
function resolveIcon(ref) {
|
||||
if (!ref || typeof ref !== 'string') return null;
|
||||
const hit = HGB_IconLib.findIcon(ref);
|
||||
return hit ? { id: hit.id, name: hit.name, svg: hit.svg, type: hit.type } : null;
|
||||
}
|
||||
|
||||
/** 预加载 AI 输出引用的素材源(懒加载适配:AI 图标 ref 带源前缀,先加载对应源文件再同步查找) */
|
||||
async function preloadReferencedSources(data) {
|
||||
const refs = (data.plans || []).map(p => p.icon).filter(Boolean);
|
||||
const sids = [...new Set(refs.map(r => String(r).split(':')[0]))].filter(sid =>
|
||||
typeof HGB_BuiltinIcons !== 'undefined' && !!(HGB_BuiltinIcons.sources || {})[sid]);
|
||||
if (sids.length) await HGB_IconLib.preload(sids);
|
||||
}
|
||||
|
||||
/** AI 可选的精选图标(带中文名,杜绝 AI 乱猜英文 id) */
|
||||
const ICON_DEMO = [
|
||||
['lucide:star', '星星'], ['lucide:rocket', '火箭'], ['lucide:globe', '地球'],
|
||||
['lucide:home', '房子'], ['lucide:heart', '爱心'], ['lucide:book', '书本'],
|
||||
['lucide:graduation-cap', '学士帽'], ['lucide:briefcase', '公文包'],
|
||||
['lucide:building-2', '大楼'], ['lucide:shield', '盾牌'], ['lucide:crown', '皇冠'],
|
||||
['lucide:diamond', '钻石'], ['lucide:target', '靶心'], ['lucide:sun', '太阳'],
|
||||
['lucide:moon', '月亮'], ['lucide:flame', '火焰'], ['lucide:leaf', '叶子'],
|
||||
['lucide:tree-pine', '松树'], ['lucide:cloud', '云朵'], ['lucide:droplet', '水滴'],
|
||||
['lucide:zap', '闪电'], ['lucide:sparkles', '星光'], ['lucide:medal', '奖牌'],
|
||||
['lucide:trophy', '奖杯'], ['lucide:award', '勋章'], ['lucide:plane', '飞机'],
|
||||
['lucide:compass', '指南针'], ['lucide:map', '地图'], ['lucide:music', '音符'],
|
||||
['lucide:coffee', '咖啡'], ['lucide:utensils', '餐具'], ['lucide:shopping-bag', '购物袋'],
|
||||
['lucide:palette', '调色板'], ['lucide:pen-tool', '画笔'], ['lucide:laptop', '笔记本'],
|
||||
['lucide:phone', '手机'], ['lucide:camera', '相机'], ['lucide:key', '钥匙'],
|
||||
['lucide:lock', '锁'], ['lucide:eye', '眼睛'], ['lucide:bell', '铃铛'],
|
||||
['lucide:gift', '礼物'], ['lucide:party-popper', '礼花'], ['lucide:hand-heart', '手心爱心'],
|
||||
['tabler:building', '楼宇'], ['tabler:chart-bar', '柱状图'], ['tabler:currency-yuan', '货币'],
|
||||
['tabler:school', '学校'], ['tabler:heartbeat', '心跳'], ['feather:activity', '活动波']
|
||||
];
|
||||
|
||||
/** 规范化布局决策:非法修正 + 多样性(≥3 种布局、圆形环绕上限头像 4 / LOGO 2);头像模式布局池限定头像白名单 */
|
||||
function normalizeLayoutDecisions(decisions, brand, avatar) {
|
||||
// 图形标识(Mark)模板不再内嵌品牌名,任何品牌名长度都可用;
|
||||
// 仅"环形环绕 Lockup"需要短品牌名 + 圆形 Mark
|
||||
const ROUND = ['badge', 'seal', 'orb', 'duo', 'burst'];
|
||||
const roundMax = avatar ? 4 : 2; // 头像模式允许更多圆形构图(差异化)
|
||||
// 布局池:头像模式 = 头像白名单;LOGO 模式 = 全部布局排除头像专有
|
||||
const pool = avatar
|
||||
? HGB_AvatarGen.AVATAR_LAYOUTS
|
||||
: HGB_Generator.LAYOUTS.filter(l => !l.avatarOnly).map(l => l.id);
|
||||
// 1) 非法布局 → 默认(圆标容器 / 头像白名单首个)
|
||||
for (const d of decisions) {
|
||||
if (!pool.includes(d.layout)) {
|
||||
d._layoutInvalid = true;
|
||||
d.layout = avatar ? pool[0] : HGB_Generator.defaultLayout(brand).id;
|
||||
}
|
||||
}
|
||||
// 2) 圆形 Mark 数 > 上限 → 多余的替换为其他布局(保证形状多样性)
|
||||
const roundIdx = decisions.map((d, i) => ROUND.includes(d.layout) ? i : -1).filter(i => i >= 0);
|
||||
if (roundIdx.length > roundMax) {
|
||||
const others = pool.filter(id => !ROUND.includes(id));
|
||||
for (const i of roundIdx.slice(roundMax)) {
|
||||
decisions[i]._layoutInvalid = true;
|
||||
decisions[i].layout = others[i % others.length];
|
||||
}
|
||||
}
|
||||
// 3) 布局种类 < 3 → 用未用过的布局补齐
|
||||
const uniq = new Set(decisions.map(d => d.layout));
|
||||
if (uniq.size < 3 && decisions.length >= 3) {
|
||||
const used = [...uniq];
|
||||
const avail = pool.filter(id => !used.includes(id));
|
||||
for (const d of decisions) {
|
||||
if (!avail.length) break;
|
||||
if (d.layout === used[0] && uniq.size < 3) {
|
||||
d._layoutInvalid = true;
|
||||
d.layout = avail.shift();
|
||||
uniq.add(d.layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Prompt 组装 ---------- */
|
||||
function buildPrompt(opt) {
|
||||
const ind = opt.industry;
|
||||
const avatar = !!opt.avatar; // 头像模式:纯图形、无文字、布局限定头像白名单
|
||||
const brand = (opt.brand || '').trim();
|
||||
const sub = (opt.sub || '').trim();
|
||||
const desc = (opt.desc || '').trim();
|
||||
const userIcons = (opt.icons || []).map(i => i.id).join('、') || '(未指定)';
|
||||
// 本次启用的技能(用户勾选):把技能标识信息注入,AI 须按技能规范设计
|
||||
const skillList = (opt.skills || []).map(id => {
|
||||
const s = HGB_Skills.get(id);
|
||||
return s ? `${s.emoji} ${s.name}(${s.scene})` : null;
|
||||
}).filter(Boolean).join('\n') || '(未启用特定技能,按行业最佳实践设计)';
|
||||
|
||||
// 布局/风格/字体白名单(头像模式:布局只列头像白名单纯图形布局;LOGO 模式排除头像专有布局)
|
||||
const layoutPool = avatar
|
||||
? HGB_Generator.LAYOUTS.filter(l => HGB_AvatarGen.isAvatarLayout(l.id))
|
||||
: HGB_Generator.LAYOUTS.filter(l => !l.avatarOnly);
|
||||
const layoutList = layoutPool.map(l => `${l.id}:${l.name}(${l.desc})`).join('\n');
|
||||
const lockupList = HGB_Generator.LOCKUP_POS.map(lp => `${lp.pos}:${lp.name}`).join('\n');
|
||||
// 风格白名单:用户指定风格方向时仅列出所选风格
|
||||
const userStyles = (opt.styleIds || []).filter(id => HGB_Styles.some(s => s.id === id));
|
||||
const styleList = HGB_Styles.filter(s => !userStyles.length || userStyles.includes(s.id))
|
||||
.map(s => `${s.id}:${s.name}(设计运动:${s.movement}|${s.desc};气质:${s.moods.join('/')})`).join('\n');
|
||||
const stylePref = userStyles.length
|
||||
? `用户指定了风格方向:${HGB_Styles.filter(s => userStyles.includes(s.id)).map(s => `${s.emoji}${s.name}`).join('、')}。styleId 必须从这些风格中选择;多套方案尽量覆盖不同的风格,展示该方向下的多种可能。`
|
||||
: '(用户未限定风格方向,请在全部风格中挑选契合行业与品牌描述的风格,并让多套方案使用不同的风格方向)';
|
||||
const fontList = (ind.fontFamilies || []).join('、') +
|
||||
';通用中文字体:SimHei、Microsoft YaHei、SimSun、KaiTi;通用英文字体:Arial、Georgia、Impact、Verdana、Montserrat、Playfair Display';
|
||||
// 图标白名单:精选(带中文名)+ 当前行业图标 + 用户已选图标
|
||||
const iconWhitelist = [
|
||||
...ICON_DEMO.map(([id, zh]) => `${id}(${zh})`),
|
||||
...(ind.icons || []).map(i => `${ind.id}:${i.id}(行业图标)`),
|
||||
...(opt.icons || []).map(i => `${i.id}(用户倾向图标)`)
|
||||
].join('\n');
|
||||
|
||||
const sys = avatar ? `你是顶级的品牌头像设计师。请基于用户的品牌信息,从"内置设计资源库"中选择素材,设计多套专业头像(Avatar)方案。
|
||||
|
||||
架构理念(必须严格遵守):
|
||||
- 头像 = 纯图形标识(图标 + 容器/环/盾/几何),不含任何文字。头像用于微信/QQ/抖音/App 图标/网站 Favicon 等场景,小尺寸(64px)也必须清晰可辨。
|
||||
|
||||
设计原则(必须严格遵守):
|
||||
1. 深刻理解品牌行业与描述,所选图标语义必须与品牌强相关(如餐饮用火焰/餐具,教育用书本/学士帽)。
|
||||
2. 如果品牌描述指定色系/风格/气质,必须优先选择契合的配色与风格;如果用户明确指定了风格方向,必须只从指定风格中选择,不得越界。
|
||||
3. 图标、布局、风格必须从各自白名单中选择,不得编造白名单之外的 id。
|
||||
4. 每个方案给出贴切的中文方案名与设计理念(concept)。
|
||||
5. 图标差异纪律:多套方案尽量使用不同的图标(可从白名单挑选语义相近但形态不同的图标),绝对避免所有方案共用同一个图标。
|
||||
6. 布局纪律:${opt.count || 5} 套方案必须覆盖至少 3 种不同布局;头像圆形构图(badge/seal/orb/duo/burst)最多 4 套,优先使用头像专有布局(fullbleed 全出血大图标 / concentric 同心圆环 / centroid 中心单环)。
|
||||
7. 构图方法论纪律:每套方案的图形标识必须建立在明确的几何构图框架上(容器/环/弧/角标/几何叠加),构图框架尽可能互不相同,禁止只靠"换颜色/换图标"制造差异。
|
||||
8. 风格气韵纪律:所选 styleId 的设计运动(movement)与色系必须贯穿整套方案——主色/背景必须贴合风格气质(如 Art Deco 金黑对称、孟菲斯撞色拼贴、Swiss 黑白留白、霓虹深底荧光、包豪斯高饱和原色),禁止"风格与配色割裂"(如极简风配高饱和渐变)。系统会自动按 styleId 的色系渲染配色,无需在 JSON 中填写 paletteName。
|
||||
9. 小尺寸可读性:图形轮廓简洁、对比清晰,避免过于纤细/密集的细节,保证缩到 64px(Favicon)仍可辨识。
|
||||
|
||||
只输出 JSON,不要输出任何其他文字。JSON 结构:
|
||||
{
|
||||
"overallConcept": "整体设计理念(一句话概括系列设计思路)",
|
||||
"plans": [
|
||||
{
|
||||
"name": "方案一的中文名",
|
||||
"concept": "这套方案的设计理念说明",
|
||||
"icon": "lucide:graduation-cap",
|
||||
"layout": "orb",
|
||||
"styleId": "minimal"
|
||||
}
|
||||
]
|
||||
}
|
||||
其中 plans 数组包含 ${opt.count || 5} 个方案。icon 必须来自下方图标白名单;layout/styleId 分别来自对应白名单(配色由系统自动跟随 styleId 的风格色系,无需填写 paletteName)。` : `你是顶级的品牌 LOGO 设计师。请基于用户的品牌信息,从"内置设计资源库"中选择素材,设计多套专业 LOGO 方案。
|
||||
|
||||
架构理念(必须严格遵守):
|
||||
- LOGO 分两层:图形标识(Mark)= 纯图形(图标+容器+环),不含任何文字;品牌组合(Lockup)= Mark + 品牌名,品牌名只摆放在图形周边(下方/右侧/上方/环绕),绝不融进图形内部。
|
||||
|
||||
设计原则(必须严格遵守):
|
||||
1. 深刻理解品牌行业与描述,所选图标语义必须与品牌强相关(如餐饮用火焰/餐具,教育用书本/学士帽)。
|
||||
2. 如果品牌描述指定色系/风格/气质,必须优先选择契合的配色与风格;如果用户明确指定了风格方向,必须只从指定风格中选择,不得越界。
|
||||
3. 图标、布局、配色、风格、字体、lockup 必须从各自白名单中选择,不得编造白名单之外的 id。
|
||||
4. 每个方案给出贴切的中文方案名与设计理念(concept)。
|
||||
5. 图标差异纪律:多套方案之间尽量使用不同的图标(可从白名单挑选语义相近但形态不同的图标),绝对避免所有方案共用同一个图标。
|
||||
6. 布局纪律:${opt.count || 5} 套方案必须覆盖至少 3 种不同 Mark 布局;圆形 Mark(badge/seal/orb/duo/burst)最多 2 套。
|
||||
7. lockup 纪律:品牌名摆放位置多样化,多套方案使用不同的 lockup 位置(如 2 套下方、1 套右侧、1 套上方);环形环绕(wrap)仅用于圆形 Mark 且品牌名 ≤6 中文字或 ≤12 西文字符。
|
||||
8. 构图方法论纪律:每套方案的 Mark 必须建立在明确的几何构图框架上(容器/环/弧/绶带/角标/几何叠加),构图框架尽可能互不相同——借鉴"精确几何构图":圆与三角的数学比例、对称平衡、网格对齐;禁止只靠"换颜色/换图标/换位置"来制造差异。
|
||||
9. 差异度纪律:多套方案必须在构图方式、视觉层次(环/盾/容器/绶带/角标/射线/拱门/几何叠加等)、配色、风格上有明显差异,呈现不同设计方向,而不是"换个颜色换个位置"。
|
||||
10. 换色纪律:同一布局若复用,必须变换配色与风格;尽可能让每套方案的构图框架(layout)不同。
|
||||
11. 风格气韵纪律:所选 styleId 的设计运动(movement)与色系必须贯穿整套方案——配色的主色/背景必须贴合风格气质(如 Art Deco 金黑对称、孟菲斯撞色拼贴、Swiss 黑白留白网格、霓虹深底荧光、包豪斯高饱和原色),禁止出现"风格与配色割裂"(如极简风配高饱和渐变)。系统会自动按 styleId 的色系渲染配色,无需在 JSON 中填写 paletteName。
|
||||
|
||||
只输出 JSON,不要输出任何其他文字。JSON 结构:
|
||||
{
|
||||
"overallConcept": "整体设计理念(一句话概括系列设计思路)",
|
||||
"plans": [
|
||||
{
|
||||
"name": "方案一的中文名",
|
||||
"concept": "这套方案的设计理念说明",
|
||||
"icon": "lucide:graduation-cap",
|
||||
"layout": "orb",
|
||||
"lockup": "below",
|
||||
"styleId": "minimal",
|
||||
"fontCn": "SimHei",
|
||||
"fontEn": "Georgia"
|
||||
}
|
||||
]
|
||||
}
|
||||
其中 plans 数组包含 ${opt.count || 5} 个方案。icon 必须来自下方图标白名单;layout/styleId 分别来自对应白名单(配色由系统自动跟随 styleId 的风格色系,无需填写 paletteName);lockup 来自品牌组合位置白名单;fontCn/fontEn 来自字体白名单。`;
|
||||
|
||||
const user = avatar ? `【品牌信息】
|
||||
- 行业:${ind.name}(${ind.desc})
|
||||
- 品牌名:${brand}
|
||||
- 品牌描述:${desc || '(无,请根据行业特点设计)'}
|
||||
- 用户倾向使用的图标:${userIcons}
|
||||
|
||||
【本次启用技能】(设计时须遵循这些技能的规范)
|
||||
${skillList}
|
||||
|
||||
【风格方向要求】
|
||||
${stylePref}
|
||||
|
||||
【图标白名单】(icon 字段从这里选,格式为 源:名称)
|
||||
${iconWhitelist}
|
||||
|
||||
【布局模板白名单】(layout 字段,仅限适合头像的纯图形布局,不含文字)
|
||||
${layoutList}
|
||||
|
||||
【设计风格白名单】(styleId 字段;配色自动跟随所选风格的色系)
|
||||
${styleList}
|
||||
|
||||
【字体白名单】(仅供方案理念参考;头像为纯图形,不渲染任何文字)
|
||||
${fontList}
|
||||
|
||||
请设计 ${opt.count || 5} 套头像方案,每套都要从白名单选择,并附上设计理念。` : `【品牌信息】
|
||||
- 行业:${ind.name}(${ind.desc})
|
||||
- 品牌名:${brand}
|
||||
- 副标题:${sub || '(无)'}
|
||||
- 品牌描述:${desc || '(无,请根据行业特点设计)'}
|
||||
- 用户倾向使用的图标:${userIcons}
|
||||
|
||||
【本次启用技能】(设计时须遵循这些技能的规范)
|
||||
${skillList}
|
||||
|
||||
【风格方向要求】
|
||||
${stylePref}
|
||||
|
||||
【图标白名单】(icon 字段从这里选,格式为 源:名称)
|
||||
${iconWhitelist}
|
||||
|
||||
【布局模板白名单】(layout 字段)
|
||||
${layoutList}
|
||||
|
||||
【品牌组合位置白名单】(lockup 字段,品牌名在图形周边的摆放方式)
|
||||
${lockupList}
|
||||
|
||||
【设计风格白名单】(styleId 字段;配色自动跟随所选风格的色系)
|
||||
${styleList}
|
||||
|
||||
【字体白名单】(fontCn / fontEn 字段)
|
||||
${fontList}
|
||||
|
||||
请设计 ${opt.count || 5} 套方案,每套都要从白名单选择,并附上设计理念。`;
|
||||
|
||||
return [
|
||||
{ role: 'system', content: sys },
|
||||
{ role: 'user', content: user }
|
||||
];
|
||||
}
|
||||
|
||||
/* ---------- 解析与校验 ---------- */
|
||||
function extractJson(content) {
|
||||
let text = (content || '').trim();
|
||||
// 剥 markdown 围栏
|
||||
text = text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '');
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start < 0 || end < start) throw new Error('AI 输出不是有效 JSON');
|
||||
return JSON.parse(text.slice(start, end + 1));
|
||||
}
|
||||
|
||||
/** 校验并规范化 AI 输出:非法字段显式标记,布局统一规范 */
|
||||
function validate(data, opt) {
|
||||
const ind = opt.industry;
|
||||
if (!data || !Array.isArray(data.plans) || !data.plans.length) {
|
||||
throw new Error('AI 未返回方案');
|
||||
}
|
||||
const plans = data.plans.slice(0, 20);
|
||||
for (const p of plans) {
|
||||
// 图标:白名单解析,失败标记(映射时回退行业/用户图标)
|
||||
if (p.icon && !resolveIcon(p.icon)) p._iconInvalid = true;
|
||||
// 布局/风格:不在白名单标记(配色自动跟随风格,无需校验 paletteName)
|
||||
const layoutOk = opt.avatar
|
||||
? HGB_AvatarGen.isAvatarLayout(p.layout)
|
||||
: HGB_Generator.LAYOUTS.some(l => l.id === p.layout && !l.avatarOnly);
|
||||
if (!layoutOk) p._layoutInvalid = true;
|
||||
if (!HGB_Styles.some(s => s.id === p.styleId)) p._styleInvalid = true;
|
||||
}
|
||||
// 布局纪律:非法修正 + 多样性保障(≥3 种布局、圆环类 ≤2)
|
||||
normalizeLayoutDecisions(plans, (opt.brand || '').trim() || HGB_Generator.sampleName(ind), !!opt.avatar);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 颜色亮度(0-1) */
|
||||
function lum(hex) {
|
||||
const c = String(hex || '#FFFFFF').replace('#', '');
|
||||
if (c.length !== 6) return 0.9;
|
||||
const r = parseInt(c.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(c.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(c.slice(4, 6), 16) / 255;
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/** 对比度修正:深色主色 + 深色背景 → 背景自动调亮 */
|
||||
function fixContrast(plan) {
|
||||
const text = plan.elements.find(el => el.type === 'text');
|
||||
const icon = plan.elements.find(el => el.type === 'icon' && el.iconId !== 'ring' && el.iconId !== 'deco-bar');
|
||||
const main = (text && text.fill) || (icon && icon.stroke);
|
||||
if (!main || !plan.bg) return;
|
||||
if (lum(plan.bg) < 0.42 && lum(main) < 0.45) {
|
||||
plan.bg = '#FFFFFF';
|
||||
plan._bgFixed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 AI 决策映射为完整方案(非法字段显式修正并记录警告) */
|
||||
function mapToPlan(p, opt, idx) {
|
||||
const ind = opt.industry;
|
||||
const avatar = !!opt.avatar;
|
||||
const brand = (opt.brand || '').trim() || HGB_Generator.sampleName(ind);
|
||||
const sub = (opt.sub || '').trim();
|
||||
const userIcons = (opt.icons || []).filter(Boolean);
|
||||
const aiIcon = resolveIcon(p.icon);
|
||||
// 图标容错:AI 图标非法时,按方案序号轮换用户/行业图标,避免"全套同一图标"
|
||||
const fallbackPool = userIcons.length ? userIcons : ((ind.icons || []));
|
||||
const icons = aiIcon
|
||||
? [aiIcon]
|
||||
: (fallbackPool.length ? [fallbackPool[idx % fallbackPool.length]] : []);
|
||||
|
||||
// 显式修正非法字段(不做随机回退);头像模式布局限定头像白名单
|
||||
const layoutOk = avatar
|
||||
? HGB_AvatarGen.isAvatarLayout(p.layout)
|
||||
: HGB_Generator.LAYOUTS.some(l => l.id === p.layout && !l.avatarOnly);
|
||||
const layoutId = layoutOk
|
||||
? p.layout
|
||||
: (avatar ? HGB_AvatarGen.AVATAR_LAYOUTS[0] : HGB_Generator.defaultLayout(brand).id);
|
||||
// 风格:非法时优先回退用户指定风格池,其次行业推荐
|
||||
const userStyleIds = (opt.styleIds || []).filter(id => HGB_Styles.some(s => s.id === id));
|
||||
const styleId = HGB_Styles.some(s => s.id === p.styleId)
|
||||
? p.styleId
|
||||
: (userStyleIds.length
|
||||
? userStyleIds[idx % userStyleIds.length]
|
||||
: ((ind.styleIds || [])[0] || HGB_Styles[0].id));
|
||||
|
||||
const variant = HGB_Generator.generateVariant({
|
||||
industry: ind,
|
||||
brand,
|
||||
sub,
|
||||
icons,
|
||||
layoutId,
|
||||
styleId, // 配色由 generator 自动跟随 styleId 色系
|
||||
lockup: avatar ? '' : p.lockup, // 头像为纯图形,无品牌名组合
|
||||
// AI 字体决策生效(非法字体由 fontFor 自动回退)
|
||||
fontFavs: [p.fontCn, p.fontEn].filter(Boolean)
|
||||
});
|
||||
|
||||
variant.id = 'ai_plan_' + Date.now().toString(36) + idx.toString(36);
|
||||
variant.name = (p.name || '').trim() || variant.name;
|
||||
variant.concept = (p.concept || '').trim();
|
||||
variant.aiIcon = aiIcon ? aiIcon.name : '';
|
||||
variant.ai = true;
|
||||
|
||||
// 对比度自动修正
|
||||
fixContrast(variant);
|
||||
|
||||
// 收集自动修正警告,供卡片展示
|
||||
const warnings = [];
|
||||
if (p._layoutInvalid) warnings.push('布局已自动调整');
|
||||
if (p._iconInvalid) warnings.push('图标已替换');
|
||||
if (p._styleInvalid) warnings.push('风格已调整');
|
||||
if (variant._bgFixed) warnings.push('背景已调亮');
|
||||
variant.warnings = warnings;
|
||||
return variant;
|
||||
}
|
||||
|
||||
/* ---------- 主入口 ---------- */
|
||||
/**
|
||||
* AI 生成方案
|
||||
* @param {object} opt { industry, brand, sub, desc, icons, count }
|
||||
* @returns {Promise<{plans:Array, overallConcept:string}>}
|
||||
*/
|
||||
async function generatePlansAI(opt) {
|
||||
const messages = buildPrompt(opt);
|
||||
const content = await HGB_AI.chat(messages, { json: true, maxTokens: 8192 });
|
||||
|
||||
let data = extractJson(content);
|
||||
// 懒加载适配:预加载 AI 引用素材源,确保 resolveIcon/findIcon 命中
|
||||
await preloadReferencedSources(data);
|
||||
data = validate(data, opt);
|
||||
const plans = data.plans.map((p, i) => mapToPlan(p, opt, i));
|
||||
return {
|
||||
plans,
|
||||
overallConcept: (data.overallConcept || '').trim(),
|
||||
raw: content
|
||||
};
|
||||
}
|
||||
|
||||
return { generatePlansAI, resolveIcon, extractJson, validate, mapToPlan, buildPrompt };
|
||||
})();
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 画个标 · 核心:头像生成(Avatar)
|
||||
* 头像 = 复用 Mark 纯图形引擎 + 头像专属约束 + 平台规格导出
|
||||
* - 仅纯图形:过滤带文字布局(monogram 首字 / ribbon 绶带区),保证小尺寸清晰
|
||||
* - 圆形构图优先:头像布局以圆形/圆角/对称构图为倾向,缩小不变形
|
||||
* - 圆形裁切预览与导出:社交平台头像均以圆形展示
|
||||
* - 平台规格一键导出:微信 / QQ / 抖音 / YouTube / App 图标 / Favicon + 自定义
|
||||
*/
|
||||
const HGB_AvatarGen = (function () {
|
||||
'use strict';
|
||||
|
||||
/** 适合头像的纯图形布局(Mark 层,全部无文字) */
|
||||
const AVATAR_LAYOUTS = [
|
||||
'orb', // 圆标容器(径向渐变圆)
|
||||
'badge', // 双环徽章
|
||||
'seal', // 双环印章
|
||||
'burst', // 光芒徽章(射线环)
|
||||
'duo', // 双图标对称
|
||||
'emblem', // 渐变盾徽
|
||||
'hexagon', // 六边形徽章
|
||||
'diamond', // 菱形徽章
|
||||
'arch', // 拱门徽章
|
||||
'geo', // 几何构形
|
||||
'techcorner', // 科技角标
|
||||
'fullbleed', // 头像专属:全出血大图标(无容器)
|
||||
'concentric', // 头像专属:同心圆环
|
||||
'centroid' // 头像专属:中心单环
|
||||
];
|
||||
|
||||
/** 平台头像导出规格预设 */
|
||||
const AVATAR_SIZES = [
|
||||
{ id: 'wechat', name: '微信头像', size: 300, note: '微信/微博 300' },
|
||||
{ id: 'qq', name: 'QQ 头像', size: 640, note: 'QQ 640' },
|
||||
{ id: 'douyin', name: '抖音头像', size: 400, note: '抖音/快手 400' },
|
||||
{ id: 'youtube', name: 'YouTube 头像', size: 800, note: '频道头像 800' },
|
||||
{ id: 'app', name: 'App 图标', size: 1024, note: 'iOS/安卓 1024' },
|
||||
{ id: 'favicon', name: '网站 Favicon', size: 64, note: '浏览器标签 64' }
|
||||
];
|
||||
|
||||
/** 头像词根扩充的主力单色源(懒加载优先预载;多色 emoji/品牌源不参与词根搜索) */
|
||||
const AVATAR_CORE_SOURCES = [
|
||||
'lucide', 'tabler', 'feather', 'mdi', 'fluent', 'solar', 'msymbols',
|
||||
'iconpark', 'hugeicons', 'mingcute', 'carbon', 'majesticons', 'pixelarticons', 'akar-icons'
|
||||
];
|
||||
|
||||
/** 布局 id 是否适合头像(在头像白名单内) */
|
||||
function isAvatarLayout(layoutId) {
|
||||
return AVATAR_LAYOUTS.includes(layoutId);
|
||||
}
|
||||
|
||||
/** 洗牌(保证多套头像图标各异) */
|
||||
function shuffle(arr) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
/** 内置库关键词搜索:图标名包含任一英文词根即命中(返回带库前缀 id 的图标数组;懒加载适配:先预载核心源再搜已加载源) */
|
||||
async function searchBuiltin(roots, limit) {
|
||||
// 词根搜索依赖已加载源 → 预载头像核心源(容忍单个失败)
|
||||
await HGB_IconLib.preload(AVATAR_CORE_SOURCES);
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const kws = roots.filter(k => k.length >= 3);
|
||||
if (!kws.length) return out;
|
||||
for (const kw of kws) {
|
||||
for (const ic of HGB_IconLib.searchLoaded(kw, limit)) {
|
||||
const fullId = ic.id;
|
||||
if (seen.has(fullId)) continue;
|
||||
seen.add(fullId);
|
||||
out.push({ id: fullId, name: ic.name, svg: ic.svg, source: ic.source, type: ic.type });
|
||||
if (out.length >= limit) return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 头像素材池:用户选中 + 行业图标优先,内置库按行业图标词根扩充(解决"素材不够/重复") */
|
||||
async function avatarIconPool(ind, userIcons) {
|
||||
const picked = (userIcons || []).filter(Boolean).slice(0, 5);
|
||||
const indIcons = (ind.icons || []);
|
||||
const poolMap = new Map();
|
||||
// 本地段:用户选中 + 行业图标(保持插入顺序)
|
||||
for (const ic of picked) poolMap.set(ic.id, ic);
|
||||
for (const ic of indIcons) if (!poolMap.has(ic.id)) poolMap.set(ic.id, ic);
|
||||
const localCount = poolMap.size;
|
||||
// 词根:行业图标 id 按 '-' 拆分(英文语义词根,如 chef-hat → chef/hat)
|
||||
const roots = [];
|
||||
for (const ic of indIcons) {
|
||||
for (const part of String(ic.id || '').split('-')) {
|
||||
const k = part.toLowerCase();
|
||||
if (k.length >= 3 && !roots.includes(k)) roots.push(k);
|
||||
}
|
||||
}
|
||||
if (roots.length) {
|
||||
for (const ic of await searchBuiltin(roots, 60)) {
|
||||
if (!poolMap.has(ic.id)) poolMap.set(ic.id, ic);
|
||||
}
|
||||
}
|
||||
const all = Array.from(poolMap.values());
|
||||
// 分段洗牌:行业/用户图标在前(高度相关),内置库扩充在后;各自洗牌保证多套各异
|
||||
return shuffle(all.slice(0, localCount)).concat(shuffle(all.slice(localCount)));
|
||||
}
|
||||
|
||||
/** 批量生成头像(复用 Mark 生成引擎 + 头像布局白名单 + 内置库素材扩充) */
|
||||
async function generateAvatars(opt) {
|
||||
const ind = opt.industry || {};
|
||||
const count = Math.min(Math.max(opt.count || 5, 1), 20);
|
||||
// 1) 基础批量生成(风格全覆盖 + 头像布局白名单)
|
||||
const base = HGB_Generator.generatePlans({ ...opt, count, layoutWhitelist: AVATAR_LAYOUTS });
|
||||
// 2) 头像素材池扩充(行业/用户图标在前 + 内置库词根扩充;懒加载预载核心源)
|
||||
const pool = await avatarIconPool(ind, opt.icons);
|
||||
if (pool.length < 2) return base;
|
||||
// 3) 逐套替换为池中不同图标(保持风格/布局/配色不变,图标素材多样)
|
||||
return base.map((p, i) => {
|
||||
const ic1 = pool[i % pool.length];
|
||||
const ic2 = pool[(i + Math.ceil(pool.length / 2)) % pool.length];
|
||||
if (!ic1) return p;
|
||||
const v = HGB_Generator.generateVariant({
|
||||
...opt,
|
||||
layoutWhitelist: AVATAR_LAYOUTS,
|
||||
layoutId: p.layoutId,
|
||||
styleId: p.styleId,
|
||||
icons: ic2 && ic2.id !== ic1.id ? [ic1, ic2] : [ic1],
|
||||
lockup: p.lockup
|
||||
});
|
||||
return Object.assign({}, v, {
|
||||
id: p.id, name: p.name, concept: p.concept, aiIcon: ic1.id
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成单个头像变体(预览弹窗微调) */
|
||||
function generateVariant(opt) {
|
||||
return HGB_Generator.generateVariant({
|
||||
...opt,
|
||||
layoutWhitelist: AVATAR_LAYOUTS
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
AVATAR_LAYOUTS,
|
||||
AVATAR_SIZES,
|
||||
isAvatarLayout,
|
||||
generateAvatars,
|
||||
generateVariant
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 画个标 · DiceBear 头像风格引擎(M4)
|
||||
* 29 种 DiceBear v10 风格定义内置(js/data/sources/dicebear-*.js,由构建脚本生成),
|
||||
* 本模块:按"风格 × seed"确定性组合出无限头像 SVG(不占图标配额)。
|
||||
*
|
||||
* 风格定义结构(v10 单文件 JSON):
|
||||
* canvas 画布模板(elements 树,component 节点按 name 引用 components)
|
||||
* components { name: { width, height, rotate/scale/translate 随机范围, variants: { 名: { elements, weight } } } }
|
||||
* colors { 色名: { values: [...] } }(element 属性 fill/stroke 以 {type:'color',name} 引用)
|
||||
* attributes 根 svg 属性(fill、shape-rendering 等)
|
||||
*
|
||||
* 渲染:seed → 确定性 PRNG → 每 component 加权选 variant + 随机变换 → color 引用取色 → 组装 SVG
|
||||
*/
|
||||
const HGB_Dicebear = (function () {
|
||||
'use strict';
|
||||
|
||||
const VERSION = '20260822e';
|
||||
const STYLES = [
|
||||
'adventurer', 'adventurer-neutral', 'avataaars', 'avataaars-neutral',
|
||||
'big-ears', 'big-ears-neutral', 'big-smile', 'blobs', 'bottts', 'bottts-neutral',
|
||||
'clay', 'constellation', 'critters', 'croodles', 'croodles-neutral',
|
||||
'cutouts', 'disco', 'dylan', 'fun-emoji', 'glass', 'glyphs', 'icons',
|
||||
'identicon', 'initial-face', 'initials', 'landscape', 'line-face', 'loops',
|
||||
'lorelei', 'lorelei-neutral', 'micah', 'miniavs', 'moods', 'notionists',
|
||||
'notionists-neutral', 'open-peeps', 'patchwork', 'personas', 'pixel-art',
|
||||
'pixel-art-neutral', 'pixelbot', 'planets', 'rings', 'shape-grid', 'shapes',
|
||||
'sprouts', 'squircles', 'stripes', 'thumbs', 'toon-head', 'triangles',
|
||||
'voxel-art', 'voxel-bot', 'waves', 'weave'
|
||||
];
|
||||
const CN = {
|
||||
adventurer: '冒险家', 'adventurer-neutral': '冒险家中性', avataaars: '阿凡达',
|
||||
'avataaars-neutral': '阿凡达中性', 'big-ears': '大耳朵', 'big-ears-neutral': '大耳朵中性',
|
||||
'big-smile': '大笑脸', blobs: '斑点', bottts: '机器人', 'bottts-neutral': '机器人中性',
|
||||
clay: '黏土', constellation: '星座', critters: '小动物',
|
||||
croodles: '卡通线条', 'croodles-neutral': '卡通线条中性', cutouts: '剪贴画',
|
||||
disco: '迪斯科', dylan: '迪伦', 'fun-emoji': '趣味表情', glass: '玻璃拟态',
|
||||
glyphs: '字形符', icons: '图标风', identicon: '指纹几何', 'initial-face': '首字脸',
|
||||
initials: '首字母', landscape: '风景', 'line-face': '线条脸', loops: '回环',
|
||||
lorelei: '洛雷莱', 'lorelei-neutral': '洛雷莱中性', micah: '米迦',
|
||||
miniavs: '迷你头像', moods: '心情', notionists: 'Notion 风格', 'notionists-neutral': 'Notion 中性',
|
||||
'open-peeps': '开放小人', patchwork: '拼布', personas: '人物剪影', 'pixel-art': '像素风',
|
||||
'pixel-art-neutral': '像素风中性', pixelbot: '像素机器人', planets: '星球',
|
||||
rings: '圆环几何', 'shape-grid': '形状网格', shapes: '几何形状',
|
||||
sprouts: '萌芽', squircles: '圆角方', stripes: '条纹', thumbs: '拇指人',
|
||||
'toon-head': '卡通头', triangles: '三角形', 'voxel-art': '体素艺术',
|
||||
'voxel-bot': '体素机器人', waves: '波浪', weave: '编织'
|
||||
};
|
||||
|
||||
/* ---------- 确定性 PRNG(seed 字符串 → mulberry32) ---------- */
|
||||
function hash(str) {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
function rng(seedStr) {
|
||||
let s = hash(seedStr) || 1;
|
||||
return function () {
|
||||
s = (s + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
function pick(r, arr) {
|
||||
return arr[Math.floor(r() * arr.length)];
|
||||
}
|
||||
function weightedPick(r, items) {
|
||||
let total = 0;
|
||||
for (const i of items) total += (i.weight != null ? i.weight : 0.01);
|
||||
if (total <= 0) return items[0].key;
|
||||
let x = r() * total;
|
||||
for (const i of items) {
|
||||
x -= (i.weight != null ? i.weight : 0.01);
|
||||
if (x <= 0) return i.key;
|
||||
}
|
||||
return items[items.length - 1].key;
|
||||
}
|
||||
function rn(v) { return Math.round(v * 100) / 100; }
|
||||
/** 变量引用解析(initials 风格:initial/fontFamily/fontWeight 等) */
|
||||
function resolveVariable(name, seed, opts) {
|
||||
switch (name) {
|
||||
case 'initial':
|
||||
case 'initials': {
|
||||
const s = String(opts.initial || seed || '').trim();
|
||||
if (s) return s[0].toUpperCase();
|
||||
return opts.defaultInitial || 'D';
|
||||
}
|
||||
case 'fontFamily': return opts.fontFamily || 'Arial';
|
||||
case 'fontWeight': return String(opts.fontWeight != null ? opts.fontWeight : 600);
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
/** 属性值:color 引用 → 从色板取色;variable 引用 → 变量解析;其他原样 */
|
||||
function resolveAttr(v, r, colors, opts) {
|
||||
if (v && typeof v === 'object') {
|
||||
if (v.type === 'color' && v.name) {
|
||||
const c = colors[v.name];
|
||||
if (typeof c === 'string') return c; // 已解析的单一颜色
|
||||
if (c && Array.isArray(c.values) && c.values.length) return pick(r, c.values);
|
||||
return '#000000';
|
||||
}
|
||||
if (v.type === 'variable' && v.name) {
|
||||
return resolveVariable(v.name, opts.seed, opts);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/* ---------- 递归渲染 element / component / text 节点 ---------- */
|
||||
function renderComponent(comp, r, colors, components, opts) {
|
||||
const names = Object.keys(comp.variants || {});
|
||||
if (!names.length) return '';
|
||||
const items = names.map(n => ({ key: n, weight: comp.variants[n].weight }));
|
||||
const variant = comp.variants[weightedPick(r, items)] || {};
|
||||
// 随机变换(rotate/scale/translate 范围;个别风格 translate 只有 x 或 y,缺字段视为 0)
|
||||
const t = [];
|
||||
if (comp.translate) {
|
||||
const tx = comp.translate.x;
|
||||
const ty = comp.translate.y;
|
||||
const x = tx ? tx.min + r() * (tx.max - tx.min) : 0;
|
||||
const y = ty ? ty.min + r() * (ty.max - ty.min) : 0;
|
||||
if (x || y) t.push('translate(' + rn(x) + ' ' + rn(y) + ')');
|
||||
}
|
||||
if (comp.rotate) {
|
||||
const deg = comp.rotate.min + r() * (comp.rotate.max - comp.rotate.min);
|
||||
if (deg) t.push('rotate(' + rn(deg) + ')');
|
||||
}
|
||||
if (comp.scale) {
|
||||
const s = comp.scale.min + r() * (comp.scale.max - comp.scale.min);
|
||||
if (s !== 1) t.push('scale(' + rn(s) + ')');
|
||||
}
|
||||
const inner = (variant.elements || []).map(e => renderNode(e, r, colors, components, opts)).join('');
|
||||
return t.length ? '<g transform="' + t.join(' ') + '">' + inner + '</g>' : inner;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析组件继承(DiceBear schema 的 extends 字段):
|
||||
* - identicon 的 row01~row05 → row;glass 的 shape01/shape02 → shape
|
||||
* 继承语义 = 复用父组件定义(无自身 variants 时完全继承;带环保护)
|
||||
*/
|
||||
function resolveComp(name, components) {
|
||||
const seen = new Set();
|
||||
let cur = name;
|
||||
while (cur && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
const c = (components || {})[cur];
|
||||
if (!c) return (components || {})[name] || null;
|
||||
if (!c.extends) return c;
|
||||
if (c.variants && Object.keys(c.variants).length) return c;
|
||||
cur = c.extends;
|
||||
}
|
||||
return (components || {})[name] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一渲染节点:支持三种类型
|
||||
* - component 节点(任意层级出现,如 shapes 的 <g><component/></g>)→ 渲染其变体
|
||||
* - text 节点 {type:'text', value} → 渲染为纯文本(initials 风格文字,value 可为变量引用)
|
||||
* - element 节点 → 拼属性 + 递归 children
|
||||
* components 仅在 canvas 层传入(renderComponent 内部复用自身 variants 而非 components 表)
|
||||
*/
|
||||
function renderNode(el, r, colors, components, opts) {
|
||||
if (!el) return '';
|
||||
if (el.type === 'component') {
|
||||
const comp = resolveComp(el.name, components);
|
||||
if (!comp) return '';
|
||||
const inner = renderComponent(comp, r, colors, components, opts);
|
||||
// component 节点自带 transform 等属性(如 shapes 的 matrix 定位)
|
||||
const attrs = [];
|
||||
for (const k of Object.keys(el.attributes || {})) {
|
||||
const v = el.attributes[k];
|
||||
if (v == null) continue;
|
||||
attrs.push(k + '="' + String(v).replace(/"/g, '"') + '"');
|
||||
}
|
||||
return '<g' + (attrs.length ? ' ' + attrs.join(' ') : '') + '>' + inner + '</g>';
|
||||
}
|
||||
if (el.type === 'text') {
|
||||
const v = resolveAttr(el.value, r, colors, opts);
|
||||
return String(v != null ? v : '');
|
||||
}
|
||||
// element:拼属性(color 引用取色,数字取整)
|
||||
const attrs = [];
|
||||
for (const k of Object.keys(el.attributes || {})) {
|
||||
const v = resolveAttr(el.attributes[k], r, colors, opts);
|
||||
if (v === '' || v == null) continue;
|
||||
const sv = typeof v === 'number' ? rn(v) : String(v);
|
||||
attrs.push(k + '="' + sv.replace(/"/g, '"') + '"');
|
||||
}
|
||||
const children = (el.children || []).map(c => renderNode(c, r, colors, components, opts)).join('');
|
||||
const name = (el.name || 'g').replace(/[^a-zA-Z0-9-]/g, '') || 'g';
|
||||
return '<' + name + (attrs.length ? ' ' + attrs.join(' ') : '') +
|
||||
(children ? '>' + children + '</' + name + '>' : '/>');
|
||||
}
|
||||
|
||||
/* ---------- 画布组装 ---------- */
|
||||
function renderCanvas(canvas, r, colors, components, opts) {
|
||||
return (canvas.elements || []).map(n => renderNode(n, r, colors, components, opts)).join('');
|
||||
}
|
||||
|
||||
/** 画布是否已含显式 background 色元素(避免重复垫底) */
|
||||
function hasBgElement(canvas) {
|
||||
function scan(el) {
|
||||
if (!el || typeof el !== 'object') return false;
|
||||
const a = el.attributes || {};
|
||||
for (const k of Object.keys(a)) {
|
||||
const v = a[k];
|
||||
if (v && typeof v === 'object' && v.type === 'color' && v.name === 'background') return true;
|
||||
if (v === 'background') return true;
|
||||
}
|
||||
return (el.children || []).some(scan);
|
||||
}
|
||||
return (canvas && canvas.elements || []).some(scan);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染单个风格头像
|
||||
* @param {string} styleId 风格 id(如 adventurer / pixel-art)
|
||||
* @param {string} seed 确定性种子(同 seed 同输出)
|
||||
* @returns {{svg:string, styleId:string}} svg 为完整 <svg> 文档
|
||||
*/
|
||||
function renderAvatar(styleId, seed, opts) {
|
||||
const def = window['HGB_Dicebear_' + styleId.replace(/-/g, '_')];
|
||||
if (!def) return { svg: '', styleId };
|
||||
opts = opts || {};
|
||||
const r = rng(String(seed || 'dicebear'));
|
||||
const colors = def.colors || {};
|
||||
// 每色名先定一个颜色(保持跨 component 一致)
|
||||
const colorMap = {};
|
||||
for (const name of Object.keys(colors)) {
|
||||
colorMap[name] = pick(r, colors[name].values || ['#000000']);
|
||||
}
|
||||
let inner = renderCanvas(def.canvas, r, colorMap, def.components || {}, { seed, ...opts });
|
||||
const w = (def.canvas && def.canvas.width) || 100;
|
||||
const h = (def.canvas && def.canvas.height) || 100;
|
||||
// 背景填充:dicebear 官方渲染器默认按 backgroundColor 垫底。neutral 系列(pixel-art-neutral 等)
|
||||
// 脸部肤色依赖此背景,画布无显式背景元素且色板含 background 时先垫同尺寸 rect;
|
||||
// 无 background 色板的风格(adventurer/pixel-art 等)零影响
|
||||
if (colorMap.background && !hasBgElement(def.canvas)) {
|
||||
inner = '<rect width="' + w + '" height="' + h + '" fill="' + colorMap.background + '"/>' + inner;
|
||||
}
|
||||
const rootAttrs = [];
|
||||
for (const k of Object.keys(def.attributes || {})) {
|
||||
rootAttrs.push(k + '="' + String(def.attributes[k]).replace(/"/g, '"') + '"');
|
||||
}
|
||||
const svg = '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h +
|
||||
'" viewBox="0 0 ' + w + ' ' + h + '"' +
|
||||
(rootAttrs.length ? ' ' + rootAttrs.join(' ') : '') + '>' + inner + '</svg>';
|
||||
return { svg, styleId };
|
||||
}
|
||||
|
||||
/** 懒加载单个风格定义(缓存 + 并发去重) */
|
||||
const loaded = {};
|
||||
const pending = {};
|
||||
function loadStyle(styleId) {
|
||||
if (loaded[styleId]) return Promise.resolve(loaded[styleId]);
|
||||
if (pending[styleId]) return pending[styleId];
|
||||
pending[styleId] = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'js/data/sources/dicebear-' + styleId + '.js?v=' + VERSION;
|
||||
s.onload = () => {
|
||||
const def = window['HGB_Dicebear_' + styleId.replace(/-/g, '_')];
|
||||
if (!def) { reject(new Error('风格定义缺失: ' + styleId)); return; }
|
||||
loaded[styleId] = def;
|
||||
delete pending[styleId];
|
||||
resolve(def);
|
||||
};
|
||||
s.onerror = () => { delete pending[styleId]; reject(new Error('风格加载失败: ' + styleId)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return pending[styleId];
|
||||
}
|
||||
|
||||
/** 加载全部风格(预载) */
|
||||
function loadAll() {
|
||||
return Promise.allSettled(STYLES.map(loadStyle));
|
||||
}
|
||||
|
||||
return { VERSION, STYLES, CN, renderAvatar, loadStyle, loadAll };
|
||||
})();
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 画个标 · 核心:表情设计师 AI 编排(Emoji AI)
|
||||
* AI 输出结构化设计决策 JSON(布局/风格/动画/分层)→ 系统负责渲染与导出;
|
||||
* AI 失败/无 Key 时返回 null,由 emojiGen 降级为内置随机池(页面不空白)。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 与 aiDesigner 同模式:buildPrompt → HGB_AI.chat(json) → extractJson → validate → 决策
|
||||
* - validate 把所有输出修正到合法白名单(布局/风格/动画类型/参数),非法即回退
|
||||
* - 纯函数 + 环境分级容错(Node 无头无 HGB_AI 时返回 null,不算失败)
|
||||
*/
|
||||
const HGB_EmojiAI = (function () {
|
||||
'use strict';
|
||||
|
||||
/* ============ Prompt 编排 ============ */
|
||||
|
||||
function layoutList() {
|
||||
if (typeof HGB_EmojiGen !== 'undefined') return HGB_EmojiGen.EMOJI_LAYOUTS.join('/');
|
||||
return 'orb/badge/seal/burst/duo/emblem/hexagon/diamond/arch/geo/techcorner/fullbleed/concentric/centroid';
|
||||
}
|
||||
|
||||
function buildPrompt(opt) {
|
||||
const ind = opt.industry || {};
|
||||
const iconDesc = (opt.icons && opt.icons.length)
|
||||
? opt.icons.map(i => i.name || i.id).join('、')
|
||||
: '(未指定,由 AI 建议行业意象)';
|
||||
const mood = opt.mood ? '用户期望情绪:' + opt.mood + ';' : '';
|
||||
// 情绪-动画映射(生成前置,先审后生成:用户给出期望情绪时优先匹配,避免动效与情绪割裂)
|
||||
const moodMap = opt.mood
|
||||
? '情绪-动画映射参考(用户给出期望情绪时优先匹配,可适度发挥但勿选明显冲突类型):' +
|
||||
'开心→bounce、生气→shake、惊讶→heartbeat、难过→float、兴奋→hopspin、平静→breath。'
|
||||
: '';
|
||||
const sys = [
|
||||
'你是「画个标」的动态表情设计师。用户给出行业与图标素材,你输出「一张静态底图 + 一个主动画」的结构化设计决策。',
|
||||
'铁律:只输出 JSON,不要任何解释文字。',
|
||||
'字段与取值:',
|
||||
'- overallConcept:一句话设计概念(中文,10-20 字)',
|
||||
'- base.layoutId:从白名单选一个:' + layoutList(),
|
||||
'- base.styleId:从系统风格库选一个(如 minimal/neon/flat/artdeco/corporate 等,按行业气质匹配)',
|
||||
'- anim.type:从动画库选一个:bounce/pulse/wiggle/float/spin/fade/twirl/heartbeat/shake/breath/hopspin',
|
||||
'- anim.speed:0.6-1.5(周期数,生气/兴奋偏快,平静偏慢)',
|
||||
'- anim.amplitude:0.7-1.4(强度,活泼可大,克制宜小)',
|
||||
'- anim.layered:true/false(是否元素分层错峰动画,让表情更活;多元素图形建议 true)',
|
||||
'- anim.concept:动效概念一句话(中文)',
|
||||
moodMap,
|
||||
'示例:{"overallConcept":"咖啡的松弛感","base":{"layoutId":"seal","styleId":"neon"},"anim":{"type":"float","speed":0.8,"amplitude":0.9,"layered":true,"concept":"蒸汽缓缓漂浮"}}'
|
||||
].join('\n');
|
||||
|
||||
const user = [
|
||||
'行业:' + (ind.name || '通用') + '(' + (ind.emoji || '') + ',' + (ind.desc || '') + ')',
|
||||
'品牌/主题:' + (opt.brand || '动态表情'),
|
||||
'用户图标素材:' + iconDesc,
|
||||
mood,
|
||||
'请输出严格 JSON(不要 markdown 代码块)。'
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
return [
|
||||
{ role: 'system', content: sys },
|
||||
{ role: 'user', content: user }
|
||||
];
|
||||
}
|
||||
|
||||
/* ============ 解析与校验 ============ */
|
||||
|
||||
/** 从聊天文本提取 JSON 对象(取第一个完整 {} 块,容忍前后缀文字) */
|
||||
function extractJson(text) {
|
||||
if (!text || typeof text !== 'string') return null;
|
||||
const start = text.indexOf('{');
|
||||
if (start < 0) return null;
|
||||
let depth = 0, inStr = false, esc = false;
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (inStr) {
|
||||
if (esc) esc = false;
|
||||
else if (c === '\\') esc = true;
|
||||
else if (c === '"') inStr = false;
|
||||
} else if (c === '"') inStr = true;
|
||||
else if (c === '{') depth++;
|
||||
else if (c === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
try { return JSON.parse(text.slice(start, i + 1)); } catch (e) { return null; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inList(v, list) { return list.indexOf(v) >= 0; }
|
||||
|
||||
/** 修正 AI 输出到合法白名单(非法项回退合理默认,返回完整决策对象) */
|
||||
function validate(d, opt) {
|
||||
const ind = opt.industry || {};
|
||||
const dec = d || {};
|
||||
const base = dec.base || {};
|
||||
const anim = dec.anim || {};
|
||||
const layouts = (typeof HGB_EmojiGen !== 'undefined') ? HGB_EmojiGen.EMOJI_LAYOUTS : [];
|
||||
const animTypes = (typeof HGB_Motion !== 'undefined') ? HGB_Motion.animTypes() : [];
|
||||
const styleIds = (typeof HGB_Styles !== 'undefined') ? HGB_Styles.map(s => s.id) : [];
|
||||
|
||||
let layoutId = inList(base.layoutId, layouts) ? base.layoutId : null;
|
||||
if (!layoutId && ind.favLayout && inList(ind.favLayout, layouts)) layoutId = ind.favLayout;
|
||||
if (!layoutId) layoutId = layouts[0] || 'orb';
|
||||
|
||||
const styleId = inList(base.styleId, styleIds) ? base.styleId : (opt.styleIds && opt.styleIds[0]);
|
||||
|
||||
const animType = inList(anim.type, animTypes.map(t => t.id)) ? anim.type : null;
|
||||
let aType = animType || 'bounce';
|
||||
|
||||
const clamp = (v, lo, hi, dft) => {
|
||||
const n = Number(v);
|
||||
return (isFinite(n) && n >= lo && n <= hi) ? n : dft;
|
||||
};
|
||||
|
||||
return {
|
||||
overallConcept: String(dec.overallConcept || '').trim().slice(0, 30) || '动态表情包',
|
||||
base: { layoutId, styleId },
|
||||
anim: {
|
||||
type: aType,
|
||||
speed: clamp(anim.speed, 0.6, 1.5, 1),
|
||||
amplitude: clamp(anim.amplitude, 0.7, 1.4, 1),
|
||||
layered: !!anim.layered,
|
||||
concept: String(anim.concept || '').trim().slice(0, 30)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ============ 主入口 ============ */
|
||||
|
||||
/**
|
||||
* AI 表情设计决策(失败/无 Key/超时 → 返回 null,供生成器降级)
|
||||
* @param {object} opt { industry, brand, icons, mood }
|
||||
* @returns {Promise<{overallConcept, base:{layoutId,styleId}, anim:{...}}|null>}
|
||||
*/
|
||||
async function suggest(opt) {
|
||||
if (typeof HGB_AI === 'undefined') return null;
|
||||
try {
|
||||
if (typeof HGB_AI.hasKey === 'function' && !HGB_AI.hasKey()) return null;
|
||||
const messages = buildPrompt(opt);
|
||||
const content = await HGB_AI.chat(messages, { json: true, maxTokens: 2048 });
|
||||
const d = extractJson(content);
|
||||
if (!d) return null;
|
||||
return validate(d, opt);
|
||||
} catch (e) {
|
||||
return null; // AI 失败 → 生成器降级内置池
|
||||
}
|
||||
}
|
||||
|
||||
return { buildPrompt, extractJson, validate, suggest };
|
||||
})();
|
||||
|
||||
// Node 无头冒烟自检(浏览器同样执行,幂等)
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = HGB_EmojiAI;
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* 画个标 · 核心:动态表情生成与导出(Emoji/Sticker)
|
||||
* 表情 = 复用 Mark 纯图形引擎(头像布局白名单)+ 动画维度(HGB_Motion)+ GIF/WebM 导出
|
||||
* - 纯图形:与头像一致排除带文字布局,小尺寸清晰
|
||||
* - 动画:AI 缺失时本地确定性分配(pickAnim 轮换),预览与导出同源(同一 apply)
|
||||
* - 导出:GIF(HGB_GifEncoder 逐帧采样)/ WebM(canvas.captureStream + MediaRecorder)/ PNG(静态帧)
|
||||
*/
|
||||
const HGB_EmojiGen = (function () {
|
||||
'use strict';
|
||||
|
||||
/** 表情可用的纯图形布局(复用头像白名单) */
|
||||
const EMOJI_LAYOUTS = typeof HGB_AvatarGen !== 'undefined'
|
||||
? HGB_AvatarGen.AVATAR_LAYOUTS
|
||||
: ['orb', 'badge', 'burst', 'duo', 'emblem', 'fullbleed', 'concentric', 'centroid'];
|
||||
|
||||
/** 平台表情导出规格预设 */
|
||||
const EMOJI_SIZES = [
|
||||
{ id: 'wechat', name: '微信表情', size: 240, note: '微信 240' },
|
||||
{ id: 'qq', name: 'QQ 表情', size: 320, note: 'QQ 320' },
|
||||
{ id: 'telegram', name: 'Telegram 贴纸', size: 512, note: 'Telegram 512' }
|
||||
];
|
||||
|
||||
/* ============ 生成 ============ */
|
||||
|
||||
/**
|
||||
* 情绪 → 主动画决策(生成前置:AI 缺失/失败时,首张贴合用户情绪)
|
||||
* 优先复用 emotion-params 技能(情绪-动效映射,与评审技能同源),未加载时走内置映射兜底
|
||||
* @param {string} mood 情绪 id(happy/angry/surprised/sad/excited/calm)
|
||||
* @returns {object|null} {type,speed,amplitude,layered} 或 null(无情绪)
|
||||
*/
|
||||
function moodAnim(mood) {
|
||||
if (!mood) return null;
|
||||
if (typeof HGB_StickerSkills !== 'undefined') {
|
||||
try {
|
||||
const r = HGB_StickerSkills.run('emotion-params', { mood });
|
||||
if (r && !r.error && r.type && HGB_Motion.animTypes().some(t => t.id === r.type)) {
|
||||
return { type: r.type, speed: r.speed || 1, amplitude: r.amplitude || 1, layered: true };
|
||||
}
|
||||
} catch (e) { /* 技能异常 → 内置映射兜底 */ }
|
||||
}
|
||||
const M = {
|
||||
happy: { type: 'bounce', speed: 1.2, amplitude: 1, layered: true },
|
||||
angry: { type: 'shake', speed: 1.4, amplitude: 1.2, layered: true },
|
||||
surprised: { type: 'heartbeat', speed: 1.2, amplitude: 1.1, layered: true },
|
||||
sad: { type: 'float', speed: 0.8, amplitude: 0.8, layered: true },
|
||||
excited: { type: 'hopspin', speed: 1.3, amplitude: 1.1, layered: true },
|
||||
calm: { type: 'breath', speed: 0.9, amplitude: 0.8, layered: true }
|
||||
};
|
||||
return M[String(mood).toLowerCase()] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于一张静态底图展开为「同图 × 全部动画类型」的一组动画表情
|
||||
* @param {object} base 静态方案(含 elements/markElements/layoutId 等)
|
||||
* @param {object} opts 可选 { varParams: 是否微调各动画参数(幅度/速度)增加层次 }
|
||||
* @returns {Array} 变体数组,同图、每项 anim 为不同动画类型
|
||||
*/
|
||||
function expandAnimations(base, opts) {
|
||||
let types = HGB_Motion.animTypes();
|
||||
// AI 优先:把 AI 推荐动画类型排到首位(首张即 AI 决策动效)
|
||||
if (opts && opts.preferType && types.some(t => t.id === opts.preferType)) {
|
||||
types = [types.find(t => t.id === opts.preferType), ...types.filter(t => t.id !== opts.preferType)];
|
||||
}
|
||||
return types.map((t, i) => {
|
||||
const fp = (i === 0 && opts && opts.firstParams) ? opts.firstParams : {};
|
||||
return Object.assign({}, base, {
|
||||
id: base.id + '-' + t.id,
|
||||
anim: HGB_Motion.normalize({
|
||||
type: t.id,
|
||||
speed: (opts && opts.varParams) ? [0.9, 1, 1.1][i % 3] : (fp.speed || 1),
|
||||
amplitude: (opts && opts.varParams) ? (0.9 + (i % 2) * 0.2) : (fp.amplitude || 1),
|
||||
// 交错开启元素级分层:一半变体"各元素错峰动",表情更活;AI 指定时首张遵循 AI 决策
|
||||
layered: (opts && opts.layered === false) ? false : ((fp.layered != null) ? fp.layered : (i % 2 === 1)),
|
||||
layerSpread: 0.3
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一组动态表情(核心入口):AI 编排(行业/情绪 → 布局/风格/动画决策)或降级内置池
|
||||
* 先生成一张静态底图 → 自动展开全部动画类型
|
||||
* @param {object} opt 同 HGB_Generator.generatePlans(industry/brand/icons/mood 等)
|
||||
* @returns {Promise<Array>} 同图 × 全部动画类型的变体数组
|
||||
*/
|
||||
async function generateEmojiSet(opt) {
|
||||
let base = null;
|
||||
let ai = null;
|
||||
// AI 编排:输出结构化决策 JSON;失败/无 Key 静默降级内置池
|
||||
if (typeof HGB_EmojiAI !== 'undefined') {
|
||||
try { ai = await HGB_EmojiAI.suggest(opt); } catch (e) { ai = null; }
|
||||
}
|
||||
if (ai && ai.base) {
|
||||
try {
|
||||
const v = HGB_AvatarGen.generateVariant({
|
||||
...opt,
|
||||
layoutId: ai.base.layoutId,
|
||||
styleId: ai.base.styleId,
|
||||
anim: ai.anim
|
||||
});
|
||||
if (v) {
|
||||
v.concept = v.concept || ai.overallConcept;
|
||||
v.aiConcept = ai.anim.concept;
|
||||
v.aiMood = opt.mood;
|
||||
base = v;
|
||||
}
|
||||
} catch (e) { base = null; }
|
||||
}
|
||||
if (!base) {
|
||||
base = (await HGB_AvatarGen.generateAvatars({ ...opt, count: 1 }))[0];
|
||||
}
|
||||
// 主动画决策:AI 优先;AI 缺失/失败时按情绪匹配(生成前置),再退化为确定性轮换
|
||||
const moodA = moodAnim(opt && opt.mood);
|
||||
base.anim = HGB_Motion.normalize(ai && ai.anim ? ai.anim : (moodA || HGB_Motion.pickAnim(0)));
|
||||
base._aiDesign = !!ai;
|
||||
return expandAnimations(base, Object.assign({}, opt || {}, {
|
||||
preferType: (ai && ai.anim && ai.anim.type) || (moodA && moodA.type) || null,
|
||||
firstParams: (ai && ai.anim) || moodA || null
|
||||
}));
|
||||
}
|
||||
|
||||
/** 单个变体(预览弹窗微调:换动画/换配色/换布局) */
|
||||
function generateVariant(opt) {
|
||||
const p = HGB_AvatarGen.generateVariant({ ...opt });
|
||||
return Object.assign({}, p, { anim: opt.anim || HGB_Motion.pickAnim(0) });
|
||||
}
|
||||
|
||||
/** 表情图形元素(纯图形 Mark,避免 lockup 混入文字) */
|
||||
function markElements(plan) {
|
||||
return (plan.markElements && plan.markElements.length) ? plan.markElements : (plan.elements || []);
|
||||
}
|
||||
|
||||
/* ============ 帧 SVG 构建(动画烘焙,预览/导出同源) ============ */
|
||||
|
||||
/** 元素包围盒(纯图形:icon 元素直接用 x/y/w/h;text 忽略,表情纯图形) */
|
||||
function elementBox(el) {
|
||||
if (!el || el.visible === false) return null;
|
||||
return { x: el.x, y: el.y, w: el.w, h: el.h };
|
||||
}
|
||||
|
||||
/** 全部可见元素内容包围盒 */
|
||||
function contentBox(elements) {
|
||||
let box = null;
|
||||
for (const el of elements || []) {
|
||||
const b = elementBox(el);
|
||||
if (!b) continue;
|
||||
if (!box) box = { x: b.x, y: b.y, w: b.w, h: b.h };
|
||||
else {
|
||||
const x2 = Math.max(box.x + box.w, b.x + b.w);
|
||||
const y2 = Math.max(box.y + box.h, b.y + b.h);
|
||||
box.x = Math.min(box.x, b.x);
|
||||
box.y = Math.min(box.y, b.y);
|
||||
box.w = x2 - box.x;
|
||||
box.h = y2 - box.y;
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 单色图标上色(字符串处理,零 DOM;多色素材 preserve 保留原样) */
|
||||
function colorizeIconSvg(svg, el) {
|
||||
if (el.preserve) return svg;
|
||||
const fill = el.fill && el.fill !== 'none' ? el.fill : 'none';
|
||||
const stroke = el.stroke && el.stroke !== 'none' ? el.stroke : 'none';
|
||||
const strokeAttrs = stroke !== 'none'
|
||||
? ` stroke="${esc(stroke)}" stroke-width="${esc(el.strokeWidth)}" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"`
|
||||
: ' stroke="none"';
|
||||
return svg.replace(/<(path|circle|rect|polygon|polyline|line|ellipse)\b/g,
|
||||
(m, tag) => `<${tag} fill="${esc(fill)}"${strokeAttrs}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建单帧 SVG 字符串(把动画帧变换烘焙进元素)
|
||||
* @param {Array} elements 元素数组(plan.elements)
|
||||
* @param {string} bg 背景色('transparent' 或 hex)
|
||||
* @param {number} size 输出边长
|
||||
* @param {number} t 归一化帧时间(0-1)
|
||||
* @param {object} anim 动画参数(HGB_Motion.normalize 结果)
|
||||
* @param {object} opts { trim: 内容顶满画布; pad: 留白比例 }
|
||||
* @returns {string} SVG 字符串(含 XML 声明)
|
||||
*/
|
||||
function buildFrameSvg(elements, bg, size, t, anim, opts) {
|
||||
const W = size || 240, H = size || 240;
|
||||
const isTransparent = bg === 'transparent' || bg === null || bg === undefined;
|
||||
|
||||
// 内容包围盒裁剪 viewBox
|
||||
let vx = 0, vy = 0, vw = W, vh = H;
|
||||
if (opts && opts.trim !== false) {
|
||||
const box = contentBox(elements);
|
||||
if (box) {
|
||||
const pad = (opts && opts.pad != null ? opts.pad : 0.06) * Math.max(box.w, box.h);
|
||||
vx = box.x - pad; vy = box.y - pad;
|
||||
vw = box.w + pad * 2; vh = box.h + pad * 2;
|
||||
if (vw < 4) { vx -= 2; vw = 4; }
|
||||
if (vh < 4) { vy -= 2; vh = 4; }
|
||||
}
|
||||
}
|
||||
|
||||
// 渐变 defs
|
||||
let defs = '';
|
||||
const grads = {};
|
||||
for (const el of elements) {
|
||||
if (el && el.grad && el.grad.id && !grads[el.grad.id]) grads[el.grad.id] = el.grad;
|
||||
}
|
||||
if (Object.keys(grads).length) {
|
||||
let d = '<defs>';
|
||||
for (const id of Object.keys(grads)) {
|
||||
const g = grads[id];
|
||||
d += (g.type === 'radial'
|
||||
? `<radialGradient id="${esc(id)}" cx="0.5" cy="0.36" r="0.78"><stop offset="0%" stop-color="${esc(g.from)}"/><stop offset="100%" stop-color="${esc(g.to)}"/></radialGradient>`
|
||||
: `<linearGradient id="${esc(id)}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="${esc(g.from)}"/><stop offset="100%" stop-color="${esc(g.to)}"/></linearGradient>`);
|
||||
}
|
||||
defs = d + '</defs>';
|
||||
}
|
||||
|
||||
let inner = '';
|
||||
if (!isTransparent) inner += `<rect x="${vx}" y="${vy}" width="${vw}" height="${vh}" fill="${esc(bg)}"/>`;
|
||||
if (defs) inner += defs;
|
||||
|
||||
// 元素级分层:各图标按索引错峰取时间(layered 时各自动),否则全局同拍
|
||||
const iconEls = elements.filter(el => el.visible && el.type === 'icon');
|
||||
iconEls.forEach((el, i) => {
|
||||
const tEl = HGB_Motion.elementT(t, anim, i, iconEls.length);
|
||||
const frame = HGB_Motion.apply(el, tEl, anim);
|
||||
const base = `translate(${el.x} ${el.y}) scale(${(el.w || 24) / 24} ${(el.h || 24) / 24})`;
|
||||
const tr = HGB_Motion.composeTransform(el, frame, base);
|
||||
const opacity = frame.opacity < 1 ? ` opacity="${Math.round(frame.opacity * 1000) / 1000}"` : '';
|
||||
inner += `<g transform="${tr}"${opacity}>${colorizeIconSvg(el.svg || '', el)}</g>`;
|
||||
});
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="${vx} ${vy} ${vw} ${vh}">` +
|
||||
inner + '</svg>';
|
||||
}
|
||||
|
||||
/* ============ 导出 ============ */
|
||||
|
||||
/**
|
||||
* 逐帧渲染 canvas(浏览器:SVG → img → canvas)
|
||||
* @returns {Promise<Array<HTMLCanvasElement>>}
|
||||
*/
|
||||
async function renderFrames(plan, size, anim, opts) {
|
||||
const a = HGB_Motion.normalize(anim);
|
||||
const n = HGB_Motion.frameCount(a);
|
||||
const bg = (opts && opts.bg) || '#FFFFFF';
|
||||
const els = markElements(plan);
|
||||
const frames = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = HGB_Motion.frameT(i, a);
|
||||
const svg = buildFrameSvg(els, bg, size, t, a, opts);
|
||||
frames.push(await HGB_Exporter.renderCanvas(size, bg === 'transparent' ? null : bg, svg));
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** 导出 GIF Blob */
|
||||
async function exportGif(plan, size, anim, opts) {
|
||||
const a = HGB_Motion.normalize(anim);
|
||||
const frames = await renderFrames(plan, size, a, opts);
|
||||
return HGB_GifEncoder.encodeBlob(frames, {
|
||||
delay: 1000 / a.fps,
|
||||
transparent: (opts && opts.bg) === 'transparent'
|
||||
});
|
||||
}
|
||||
|
||||
/** 导出 WebM(浏览器:canvas.captureStream + MediaRecorder) */
|
||||
function exportWebm(plan, size, anim, opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
reject(new Error('当前浏览器不支持 MediaRecorder(无法导出 WebM)'));
|
||||
return;
|
||||
}
|
||||
const a = HGB_Motion.normalize(anim);
|
||||
const bg = (opts && opts.bg) || '#FFFFFF';
|
||||
const transparent = bg === 'transparent';
|
||||
const els = markElements(plan);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size; canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const stream = canvas.captureStream(a.fps);
|
||||
const mime = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']
|
||||
.find(m => MediaRecorder.isTypeSupported(m)) || '';
|
||||
const rec = new MediaRecorder(stream, {
|
||||
mimeType: mime || undefined,
|
||||
videoBitsPerSecond: Math.round(size * size * a.fps * 0.25)
|
||||
});
|
||||
const chunks = [];
|
||||
rec.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); };
|
||||
rec.onstop = () => resolve(new Blob(chunks, { type: mime || 'video/webm' }));
|
||||
rec.onerror = () => reject(new Error('WebM 录制失败'));
|
||||
|
||||
const durationMs = a.duration * 1000;
|
||||
const start = performance.now();
|
||||
let raf;
|
||||
const draw = (now) => {
|
||||
const t = HGB_Motion.previewT(now - start, a);
|
||||
const svg = buildFrameSvg(els, bg, size, t, a, opts);
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }));
|
||||
img.onload = () => {
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
if (!transparent) { ctx.fillStyle = bg; ctx.fillRect(0, 0, size, size); }
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
URL.revokeObjectURL(url);
|
||||
if (performance.now() - start >= durationMs) {
|
||||
cancelAnimationFrame(raf);
|
||||
rec.stop();
|
||||
} else {
|
||||
raf = requestAnimationFrame(draw);
|
||||
}
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('WebM 帧渲染失败')); };
|
||||
img.src = url;
|
||||
};
|
||||
rec.start();
|
||||
raf = requestAnimationFrame(draw);
|
||||
});
|
||||
}
|
||||
|
||||
/** 导出静态 PNG(动画初始帧 t=0) */
|
||||
async function exportPng(plan, size, bg) {
|
||||
const svg = buildFrameSvg(markElements(plan), bg || '#FFFFFF', size, 0, HGB_Motion.normalize(plan.anim), {});
|
||||
return HGB_Exporter.renderCanvas(size, (bg || '#FFFFFF') === 'transparent' ? null : (bg || '#FFFFFF'), svg);
|
||||
}
|
||||
|
||||
/** 当前预览帧 SVG(预览弹窗用 buildFrameSvg 可复用,此接口供下载 SVG 静态帧) */
|
||||
function exportSvg(plan, size, bg, t, anim) {
|
||||
return buildFrameSvg(markElements(plan), bg || '#FFFFFF', size || 240, t || 0, HGB_Motion.normalize(anim || plan.anim), {});
|
||||
}
|
||||
|
||||
return {
|
||||
EMOJI_LAYOUTS, EMOJI_SIZES,
|
||||
generateEmojiSet, expandAnimations, generateVariant,
|
||||
buildFrameSvg, colorizeIconSvg, contentBox, markElements,
|
||||
renderFrames, exportGif, exportWebm, exportPng, exportSvg
|
||||
};
|
||||
})();
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = HGB_EmojiGen;
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* 画个标 · 核心:导出功能
|
||||
* 支持:SVG 矢量导出、PNG / JPG 位图导出(多尺寸规格)、PDF 文档导出
|
||||
*/
|
||||
const HGB_Exporter = (function () {
|
||||
'use strict';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
/** 尺寸规格预设(PNG/JPG 应用场景) */
|
||||
const SIZES = [
|
||||
{ name: '社交媒体头像', size: 256, note: '微信/微博头像' },
|
||||
{ name: '方形通用', size: 512, note: '网站 LOGO' },
|
||||
{ name: '高清印刷', size: 1024, note: '名片/宣传物料' },
|
||||
{ name: '超清大图', size: 2048, note: '海报/横幅' }
|
||||
];
|
||||
|
||||
/* ============ SVG 构建 ============ */
|
||||
|
||||
/** 估算文本宽度(与 generator 一致:中文全角、西文 0.55、计入字距) */
|
||||
function textWidthOf(text, fontSize, spacing) {
|
||||
const t = String(text || '');
|
||||
const cn = (t.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const other = t.length - cn;
|
||||
return cn * fontSize + other * fontSize * 0.55 + Math.max(0, t.length - 1) * (spacing || 0);
|
||||
}
|
||||
|
||||
/** 元素视觉包围盒(文字按估算宽度水平居中、按字号定高;环形文字用外接圆盒) */
|
||||
function elementBox(el) {
|
||||
if (!el || el.visible === false) return null;
|
||||
if (el.type === 'text' && !el.arc) {
|
||||
const fs = el.fontSize || 40;
|
||||
const w = Math.max(textWidthOf(el.text, fs, el.letterSpacing), 8);
|
||||
const bw = el.w || w, bh = el.h || fs;
|
||||
return { x: el.x + (bw - w) / 2, y: el.y + (bh - fs) / 2, w, h: fs };
|
||||
}
|
||||
return { x: el.x, y: el.y, w: el.w, h: el.h };
|
||||
}
|
||||
|
||||
/** 全部可见元素的内容包围盒(无内容返回 null) */
|
||||
function contentBox(elements) {
|
||||
let box = null;
|
||||
for (const el of elements || []) {
|
||||
const b = elementBox(el);
|
||||
if (!b) continue;
|
||||
if (!box) box = { x: b.x, y: b.y, w: b.w, h: b.h };
|
||||
else {
|
||||
const x2 = Math.max(box.x + box.w, b.x + b.w);
|
||||
const y2 = Math.max(box.y + box.h, b.y + b.h);
|
||||
box.x = Math.min(box.x, b.x);
|
||||
box.y = Math.min(box.y, b.y);
|
||||
box.w = x2 - box.x;
|
||||
box.h = y2 - box.y;
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由任意元素数组构建 SVG(用于方案库预览/导出)
|
||||
* @param {Array} elements 元素数组
|
||||
* @param {string} bgColor 背景色
|
||||
* @param {number} [size] 输出边长(默认画布 600)
|
||||
* @param {object} [opts] { trim: 是否按内容包围盒裁剪 viewBox(内容顶满画布,适合头像/favicon),
|
||||
* pad: 内容四周留白比例(默认 0.05) }
|
||||
*/
|
||||
function buildSvgFrom(elements, bgColor, size, opts) {
|
||||
const W = size || HGB_Store.CANVAS_W, H = size || HGB_Store.CANVAS_H;
|
||||
const bg = bgColor || '#FFFFFF';
|
||||
|
||||
// 内容包围盒裁剪:viewBox 收敛到内容 + 少量留白,内容顶满画布
|
||||
let vx = 0, vy = 0, vw = W, vh = H;
|
||||
if (opts && opts.trim) {
|
||||
const box = contentBox(elements);
|
||||
if (box) {
|
||||
const pad = (opts.pad != null ? opts.pad : 0.05) * Math.max(box.w, box.h);
|
||||
vx = box.x - pad; vy = box.y - pad;
|
||||
vw = box.w + pad * 2; vh = box.h + pad * 2;
|
||||
if (vw < 4) { vx -= 2; vw = 4; }
|
||||
if (vh < 4) { vy -= 2; vh = 4; }
|
||||
}
|
||||
}
|
||||
|
||||
// 收集渐变 defs(元素以 el.grad 声明,需注入 <defs> 才能用 url(#id) 填充)
|
||||
// 收集环形文字路径(text 带 el.arc 时注入 path)
|
||||
let defs = '';
|
||||
const grads = {};
|
||||
const arcs = [];
|
||||
for (const el of elements) {
|
||||
if (el && el.grad && el.grad.id && !grads[el.grad.id]) grads[el.grad.id] = el.grad;
|
||||
if (el && el.arc) arcs.push(el);
|
||||
}
|
||||
if (Object.keys(grads).length || arcs.length) {
|
||||
let d = '<defs>';
|
||||
for (const id of Object.keys(grads)) {
|
||||
const g = grads[id];
|
||||
if (g.type === 'radial') {
|
||||
d += `<radialGradient id="${esc(id)}" cx="0.5" cy="0.36" r="0.78">` +
|
||||
`<stop offset="0%" stop-color="${esc(g.from)}"/><stop offset="100%" stop-color="${esc(g.to)}"/>` +
|
||||
'</radialGradient>';
|
||||
} else {
|
||||
d += `<linearGradient id="${esc(id)}" x1="0" y1="0" x2="1" y2="1">` +
|
||||
`<stop offset="0%" stop-color="${esc(g.from)}"/><stop offset="100%" stop-color="${esc(g.to)}"/>` +
|
||||
'</linearGradient>';
|
||||
}
|
||||
}
|
||||
for (const el of arcs) {
|
||||
d += `<path id="arc_${esc(el.id)}" d="${esc(arcPathD(el.arc))}" fill="none" stroke="none"/>`;
|
||||
}
|
||||
defs = d + '</defs>';
|
||||
}
|
||||
|
||||
let inner = '';
|
||||
inner += `<rect x="${vx}" y="${vy}" width="${vw}" height="${vh}" fill="${esc(bg)}"/>`;
|
||||
if (defs) inner += defs;
|
||||
|
||||
for (const el of elements) {
|
||||
if (!el.visible) continue;
|
||||
inner += buildElementSvg(el);
|
||||
}
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
||||
`<svg xmlns="${SVG_NS}" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="${vx} ${vy} ${vw} ${vh}">` +
|
||||
inner + '</svg>';
|
||||
}
|
||||
|
||||
/** 生成当前设计的导出 SVG 字符串(不含选中框/手柄) */
|
||||
function buildExportSvg(bgColor) {
|
||||
const s = HGB_Store.get();
|
||||
return buildSvgFrom(s.elements, bgColor || s.bg || '#FFFFFF');
|
||||
}
|
||||
|
||||
function buildElementSvg(el) {
|
||||
const cx = el.x + el.w / 2;
|
||||
const cy = el.y + el.h / 2;
|
||||
const opacity = el.opacity != null ? ` opacity="${el.opacity}"` : '';
|
||||
|
||||
if (el.type === 'icon') {
|
||||
// 图标源为 24x24 viewBox:先缩放到元素尺寸,再平移到目标位置
|
||||
const sx = (el.w || 24) / 24, sy = (el.h || 24) / 24;
|
||||
let t = `translate(${el.x} ${el.y}) scale(${sx.toFixed(4)} ${sy.toFixed(4)})`;
|
||||
if (el.rotation) {
|
||||
// 绕元素中心旋转(在平移缩放后的坐标系中)
|
||||
t = `translate(${cx} ${cy}) rotate(${el.rotation}) translate(${-cx} ${-cy}) ${t}`;
|
||||
}
|
||||
return `<g transform="${t}"${opacity}>${applyIconStyle(el)}</g>`;
|
||||
}
|
||||
if (el.type === 'text') {
|
||||
let transform = '';
|
||||
if (el.rotation) {
|
||||
transform = ` transform="translate(${cx} ${cy}) rotate(${el.rotation}) translate(${-cx} ${-cy})"`;
|
||||
}
|
||||
const f = el.fontFamily || 'sans-serif';
|
||||
const attrs = [
|
||||
'text-anchor="middle"', 'dominant-baseline="central"',
|
||||
'font-family="' + esc(f) + '"',
|
||||
'font-size="' + (el.fontSize || 40) + '"',
|
||||
'font-weight="' + (el.fontWeight || 400) + '"',
|
||||
'fill="' + esc(el.fill || '#000') + '"',
|
||||
el.letterSpacing ? 'letter-spacing="' + el.letterSpacing + '"' : '',
|
||||
el.stroke && el.stroke !== 'none'
|
||||
? ` stroke="${esc(el.stroke)}" stroke-width="${el.strokeWidth}"` : '',
|
||||
'xml:space="preserve"'
|
||||
].filter(Boolean).join(' ');
|
||||
if (el.arc) {
|
||||
// 环形文字:沿注入的圆弧 path 排列
|
||||
return `<text${transform}${opacity} ${attrs}><textPath href="#arc_${esc(el.id)}" startOffset="50%">${esc(el.text || '')}</textPath></text>`;
|
||||
}
|
||||
return `<text${transform}${opacity} x="${cx}" y="${cy}" ${attrs}>${esc(el.text || '')}</text>`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 生成圆弧路径 d(环形文字用,与 renderer 保持一致) */
|
||||
function arcPathD(arc) {
|
||||
const a1 = arc.start * Math.PI / 180;
|
||||
const a2 = (arc.start + arc.sweep) * Math.PI / 180;
|
||||
const x1 = arc.cx + arc.r * Math.cos(a1);
|
||||
const y1 = arc.cy + arc.r * Math.sin(a1);
|
||||
const x2 = arc.cx + arc.r * Math.cos(a2);
|
||||
const y2 = arc.cy + arc.r * Math.sin(a2);
|
||||
const large = Math.abs(arc.sweep) > 180 ? 1 : 0;
|
||||
const sweepFlag = arc.sweep < 0 ? 0 : 1; // sweep-flag=0 逆时针(y向下坐标系中负角走下半弧)
|
||||
return 'M ' + x1.toFixed(2) + ' ' + y1.toFixed(2) +
|
||||
' A ' + arc.r + ' ' + arc.r + ' 0 ' + large + ' ' + sweepFlag + ' ' + x2.toFixed(2) + ' ' + y2.toFixed(2);
|
||||
}
|
||||
|
||||
function applyIconStyle(el) {
|
||||
// 多色素材(emoji/品牌)保留原色:直接内嵌原始 svg body,不覆盖 fill/stroke
|
||||
if (el.preserve) return el.svg;
|
||||
const tmp = document.createElementNS(SVG_NS, 'g');
|
||||
tmp.innerHTML = el.svg;
|
||||
tmp.querySelectorAll('path,circle,rect,polygon,polyline,line,ellipse').forEach(p => {
|
||||
if (el.fill && el.fill !== 'none') p.setAttribute('fill', el.fill);
|
||||
else p.setAttribute('fill', 'none');
|
||||
if (el.stroke && el.stroke !== 'none') {
|
||||
p.setAttribute('stroke', el.stroke);
|
||||
// stroke-width 为"视觉像素"宽度:non-scaling-stroke 保证不随整体 scale 放大变粗
|
||||
p.setAttribute('stroke-width', el.strokeWidth);
|
||||
p.setAttribute('vector-effect', 'non-scaling-stroke');
|
||||
p.setAttribute('stroke-linecap', 'round');
|
||||
p.setAttribute('stroke-linejoin', 'round');
|
||||
} else {
|
||||
p.setAttribute('stroke', 'none');
|
||||
}
|
||||
});
|
||||
return tmp.innerHTML;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ============ 位图渲染(SVG -> Canvas) ============ */
|
||||
|
||||
/**
|
||||
* 将当前设计渲染到指定尺寸的 canvas
|
||||
* @param {number} size 输出边长(px)
|
||||
* @param {string|null} bgColor 背景色;null 表示透明
|
||||
*/
|
||||
function renderCanvas(size, bgColor, svgOverride) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const svgStr = svgOverride ||
|
||||
buildExportSvg(bgColor === undefined ? null : bgColor);
|
||||
const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (bgColor !== null && bgColor !== undefined) {
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(canvas);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('SVG 渲染失败,请检查元素内容'));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/* ============ 导出:SVG ============ */
|
||||
function exportSvg(svgOverride, bgColor) {
|
||||
const svgStr = svgOverride || buildExportSvg(bgColor || HGB_Store.get().bg || '#FFFFFF');
|
||||
download(svgStr, 'image/svg+xml', safeName() + '.svg');
|
||||
return svgStr;
|
||||
}
|
||||
|
||||
/* ============ 导出:PNG ============ */
|
||||
async function exportPng(size = 1024, bgColor, svgOverride) {
|
||||
const canvas = await renderCanvas(size, bgColor, svgOverride);
|
||||
const blob = await canvasToBlob(canvas, 'image/png');
|
||||
download(blob, 'image/png', safeName() + '_' + size + 'px.png');
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/* ============ 导出:JPG ============ */
|
||||
async function exportJpg(size = 1024, bgColor = '#FFFFFF', svgOverride) {
|
||||
const canvas = await renderCanvas(size, bgColor, svgOverride);
|
||||
const blob = await canvasToBlob(canvas, 'image/jpeg', 0.92);
|
||||
download(blob, 'image/jpeg', safeName() + '_' + size + 'px.jpg');
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/* ============ 导出:PDF(手写最小 PDF,嵌入 JPEG) ============ */
|
||||
async function exportPdf(size = 1024, bgColor = '#FFFFFF', svgOverride) {
|
||||
// 1. 渲染 JPEG
|
||||
const canvas = await renderCanvas(size, bgColor, svgOverride);
|
||||
const jpegDataUrl = canvas.toDataURL('image/jpeg', 0.92);
|
||||
const jpegBase64 = jpegDataUrl.split(',')[1];
|
||||
|
||||
// 2. 构建 PDF(单页,图片全页)
|
||||
const pt = Math.round(size * 72 / 96); // 96dpi → pt(近似 A4 常用分辨率换算)
|
||||
const pdf = buildPdf(jpegBase64, pt, pt);
|
||||
|
||||
const blob = new Blob([pdf], { type: 'application/pdf' });
|
||||
download(blob, 'application/pdf', safeName() + '_' + size + 'px.pdf');
|
||||
return blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手写最小 PDF 1.4 文件
|
||||
* 结构:Catalog → Pages → Page → XObject(Image) + Content stream
|
||||
*/
|
||||
function buildPdf(jpegBase64, pageW, pageH) {
|
||||
// 将 base64 解码为字节
|
||||
const bin = atob(jpegBase64);
|
||||
const imgBytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) imgBytes[i] = bin.charCodeAt(i);
|
||||
|
||||
const objects = [];
|
||||
|
||||
// Object 1: Catalog
|
||||
objects.push('<< /Type /Catalog /Pages 2 0 R >>');
|
||||
// Object 2: Pages
|
||||
objects.push(`<< /Type /Pages /Kids [3 0 R] /Count 1 >>`);
|
||||
// Object 3: Page
|
||||
objects.push(
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageW} ${pageH}] ` +
|
||||
`/Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>`
|
||||
);
|
||||
// Object 4: Image XObject(JPEG 流)
|
||||
objects.push(
|
||||
`<< /Type /XObject /Subtype /Image /Width ${pageW} /Height ${pageH} ` +
|
||||
`/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${imgBytes.length} >>`
|
||||
);
|
||||
// Object 5: Content(绘制图片铺满页面)
|
||||
const content = `q\n${pageW} 0 0 ${pageH} 0 0 cm\n/Im0 Do\nQ`;
|
||||
objects.push(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`);
|
||||
|
||||
// 组装 PDF 字节流
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [];
|
||||
chunks.push(encoder.encode('%PDF-1.4\n'));
|
||||
const offsets = [];
|
||||
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
offsets.push(chunks.reduce((a, c) => a + c.length, 0));
|
||||
const header = `${i + 1} 0 obj\n`;
|
||||
chunks.push(encoder.encode(header));
|
||||
if (i === 3) {
|
||||
// 图片对象:字典 + stream
|
||||
chunks.push(encoder.encode(objects[i] + '\nstream\n'));
|
||||
chunks.push(imgBytes);
|
||||
chunks.push(encoder.encode('\nendstream\n'));
|
||||
} else {
|
||||
chunks.push(encoder.encode(objects[i] + '\n'));
|
||||
}
|
||||
chunks.push(encoder.encode('endobj\n'));
|
||||
}
|
||||
|
||||
const xrefStart = chunks.reduce((a, c) => a + c.length, 0);
|
||||
const xref = `xref\n0 ${objects.length + 1}\n` +
|
||||
'0000000000 65535 f \n' +
|
||||
offsets.map(o => String(o).padStart(10, '0') + ' 00000 n \n').join('') +
|
||||
`trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n` +
|
||||
'startxref\n' + xrefStart + '\n%%EOF';
|
||||
|
||||
chunks.push(encoder.encode(xref));
|
||||
return new Blob(chunks, { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
/* ============ 一键导出全部尺寸 ============ */
|
||||
async function exportAllSizes(bgColor) {
|
||||
const results = [];
|
||||
for (const spec of SIZES) {
|
||||
const canvas = await exportPng(spec.size, bgColor);
|
||||
results.push({ spec, canvas });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/* ============ 头像导出(圆形/方形 多规格 PNG) ============ */
|
||||
|
||||
/** 将 canvas 圆形裁切(以画布中心为圆心,四周透明) */
|
||||
function circleCropCanvas(canvas) {
|
||||
const s = canvas.width;
|
||||
const out = document.createElement('canvas');
|
||||
out.width = s;
|
||||
out.height = s;
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.beginPath();
|
||||
ctx.arc(s / 2, s / 2, s / 2, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导出头像 PNG(多平台规格)
|
||||
* @param {string} svgOverride 头像 SVG 字符串(应已含背景 rect)
|
||||
* @param {Array<number>} sizes 尺寸列表(px)
|
||||
* @param {object} [opts] { bg: 背景色(null=透明,仅 SVG 内 rect 提供背景),
|
||||
* round: 是否圆形裁切, base: 文件名前缀 }
|
||||
* @returns {Promise<Array<{size:number, canvas:HTMLCanvasElement}>>}
|
||||
*/
|
||||
async function exportAvatarPngs(svgOverride, sizes, opts) {
|
||||
const o = opts || {};
|
||||
const results = [];
|
||||
for (const size of sizes) {
|
||||
const canvas = await renderCanvas(size, o.bg, svgOverride);
|
||||
const out = o.round ? circleCropCanvas(canvas) : canvas;
|
||||
const blob = await canvasToBlob(out, 'image/png');
|
||||
const suffix = o.round ? '_圆形' : '';
|
||||
download(blob, 'image/png', (o.base || safeName()) + '_' + size + suffix + 'px.png');
|
||||
results.push({ size, canvas: out });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/* ============ 工具 ============ */
|
||||
function canvasToBlob(canvas, type, quality) {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(b => b ? resolve(b) : reject(new Error(type + ' 编码失败')), type, quality);
|
||||
});
|
||||
}
|
||||
|
||||
function download(data, mime, filename) {
|
||||
const blob = data instanceof Blob ? data : new Blob([data], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function safeName() {
|
||||
const n = (HGB_Store.get().name || '画个标设计').replace(/[\\/:*?"<>|]/g, '_');
|
||||
return n || '画个标设计';
|
||||
}
|
||||
|
||||
return {
|
||||
SIZES, buildExportSvg, buildSvgFrom, renderCanvas,
|
||||
exportSvg, exportPng, exportJpg, exportPdf, exportAllSizes,
|
||||
exportAvatarPngs, circleCropCanvas,
|
||||
buildPdf, download, safeName
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,773 @@
|
||||
/**
|
||||
* 画个标 · 智能设计引擎(图形标识 Mark + 品牌组合 Lockup 双层架构)
|
||||
* 核心理念:
|
||||
* - 图形标识(Mark):纯图形设计(容器/环/图标/装饰),不含任何文字 —— 这是"LOGO 设计"本身
|
||||
* - 品牌组合(Lockup):在 Mark 完成之后,把品牌名摆放到图形周边(下方/右侧/上方/环绕)
|
||||
* - 品牌名绝不"融"进图形内部,只作为周边排版层存在
|
||||
* 落地 GitHub logo-design-skill 设计方法论:
|
||||
* - 元素坐标一律以"中心点"传递,杜绝文字/图标偏离画布中心轴
|
||||
* - 布局 = 图形容器/环/弧/绶带 + 图标的三层构图,而非"换个位置"
|
||||
* - 深色主色填充的容器(盾/圆/方)承载白色图标,形成徽章化视觉
|
||||
*/
|
||||
const HGB_Generator = (function () {
|
||||
'use strict';
|
||||
|
||||
const CW = 600, CH = 600;
|
||||
let seq = 0;
|
||||
|
||||
/* ============ 字体选择 ============ */
|
||||
// 覆盖风格库 font/fontCn 的全部取值,保证风格字体真正生效(否则 fontFor 回退默认)
|
||||
const CN_FONTS = ['SimHei', 'Microsoft YaHei', 'SimSun', 'KaiTi', '宋体', '微软雅黑', '楷体', '黑体'];
|
||||
const EN_FONTS = ['Arial', 'Georgia', 'Impact', 'Verdana', 'Montserrat', 'Playfair Display',
|
||||
'Comic Sans MS', 'Times New Roman', 'Bradley Hand', 'Arial Black'];
|
||||
|
||||
/** 根据文本内容与行业推荐字体,选择合适字体 */
|
||||
function fontFor(text, favs) {
|
||||
const hasCn = /[\u4e00-\u9fa5]/.test(text || '');
|
||||
const pool = hasCn ? CN_FONTS : EN_FONTS;
|
||||
const fav = (favs || []).find(f => pool.includes(f));
|
||||
return fav || (hasCn ? 'SimHei' : 'Arial');
|
||||
}
|
||||
|
||||
/* ============ 文字测量与自适应(防"破版"核心) ============ */
|
||||
/** 估算文本渲染宽度:中文全角、西文按 0.55 倍、计入字距 */
|
||||
function textWidth(text, fontSize, spacing) {
|
||||
const t = String(text || '');
|
||||
const cn = (t.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const other = t.length - cn;
|
||||
return cn * fontSize + other * fontSize * 0.55 + Math.max(0, t.length - 1) * (spacing || 0);
|
||||
}
|
||||
|
||||
/** 从基准字号向下收缩,直到文本能放进 maxWidth(下限 min) */
|
||||
function fitFontSize(text, maxWidth, base, spacing, min) {
|
||||
const t = String(text || '');
|
||||
if (!t) return base;
|
||||
const mn = min || 18;
|
||||
let size = base;
|
||||
while (size > mn && textWidth(t, size, spacing || 0) > maxWidth) size -= 2;
|
||||
return Math.round(size);
|
||||
}
|
||||
|
||||
/** 超宽文本截断加省略号 */
|
||||
function clipText(text, maxWidth, size, spacing) {
|
||||
const t = String(text || '');
|
||||
if (!t || textWidth(t, size, spacing || 0) <= maxWidth) return t;
|
||||
let cut = t;
|
||||
while (cut.length > 1 && textWidth(cut + '…', size, spacing || 0) > maxWidth) cut = cut.slice(0, -1);
|
||||
return cut + '…';
|
||||
}
|
||||
|
||||
/** 品牌名是否适合环形环绕等紧凑排版(中文 ≤6 字或西文 ≤12 字符) */
|
||||
function isShort(brand) {
|
||||
const t = String(brand || '').trim();
|
||||
const cn = (t.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
return cn ? cn <= 6 : t.length <= 12;
|
||||
}
|
||||
|
||||
/** 按品牌名长度选择默认 Mark 布局 */
|
||||
function defaultLayout(brand) {
|
||||
return LAYOUTS.find(l => l.id === 'orb') || LAYOUTS[0];
|
||||
}
|
||||
|
||||
/** 风格倾向布局:取风格 layouts 列表第一个有效布局(可再按白名单过滤;无则 null) */
|
||||
function favLayout(style, whitelist) {
|
||||
let favs = (style && style.layouts || []).filter(id => LAYOUTS.some(l => l.id === id));
|
||||
if (whitelist && whitelist.length) favs = favs.filter(id => whitelist.includes(id));
|
||||
// LOGO 模式(无白名单)排除头像专有布局
|
||||
else favs = favs.filter(id => !(LAYOUTS.find(l => l.id === id) || {}).avatarOnly);
|
||||
if (!favs.length) return null;
|
||||
return LAYOUTS.find(l => l.id === favs[0]) || null;
|
||||
}
|
||||
|
||||
/** 布局兜底:白名单优先轮换,否则全局轮换(排除头像专有布局) */
|
||||
function layoutFallback(i, whitelist) {
|
||||
if (whitelist && whitelist.length) {
|
||||
const l = LAYOUTS.find(x => x.id === whitelist[i % whitelist.length]);
|
||||
if (l) return l;
|
||||
}
|
||||
const pool = LAYOUTS.filter(l => !l.avatarOnly);
|
||||
return pool[i % pool.length];
|
||||
}
|
||||
|
||||
/* ============ 元素构造(一律以"中心点"定位) ============ */
|
||||
function iconEl(ic, cx, cy, w, h, stroke, strokeW) {
|
||||
return {
|
||||
id: uid('ic'), type: 'icon',
|
||||
name: (ic && ic.name) || '图标', iconId: (ic && ic.id) || '',
|
||||
svg: (ic && ic.svg) || '',
|
||||
// 多色素材(emoji/品牌):preserve=true 时导出/预览保留原始颜色,不按 stroke/fill 覆盖
|
||||
preserve: !!(ic && ic.type && ic.type !== 'icon'),
|
||||
x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h, rotation: 0,
|
||||
fill: 'none', stroke, strokeWidth: strokeW,
|
||||
opacity: 1, visible: true, locked: false
|
||||
};
|
||||
}
|
||||
function ringEl(cx, cy, w, h, stroke, strokeW) {
|
||||
return {
|
||||
id: uid('rg'), type: 'icon', name: '圆环', iconId: 'ring',
|
||||
svg: '<circle cx="12" cy="12" r="10"/>',
|
||||
x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h, rotation: 0,
|
||||
fill: 'none', stroke, strokeWidth: strokeW,
|
||||
opacity: 1, visible: true, locked: false
|
||||
};
|
||||
}
|
||||
function textEl(text, cx, cy, w, h, fontFamily, fontSize, fontWeight, fill, letterSpacing) {
|
||||
return {
|
||||
id: uid('tx'), type: 'text', name: '文字', text,
|
||||
x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h, rotation: 0,
|
||||
fontSize, fontWeight, fontFamily, letterSpacing,
|
||||
fill, stroke: 'none', strokeWidth: 0,
|
||||
opacity: 1, visible: true, locked: false
|
||||
};
|
||||
}
|
||||
/** 环形文字(textPath,品牌名环绕 Mark 用) */
|
||||
function arcTextEl(text, arc, fontFamily, fontSize, fontWeight, fill, letterSpacing) {
|
||||
return {
|
||||
id: uid('at'), type: 'text', name: '环形文字', text,
|
||||
x: Math.round(arc.cx - arc.r), y: Math.round(arc.cy - arc.r),
|
||||
w: Math.round(arc.r * 2), h: Math.round(arc.r * 2), rotation: 0,
|
||||
fontSize, fontWeight, fontFamily, letterSpacing,
|
||||
fill, stroke: 'none', strokeWidth: 0,
|
||||
arc, opacity: 1, visible: true, locked: false
|
||||
};
|
||||
}
|
||||
/** 装饰原语(中心定位包装) */
|
||||
function decoAt(decId, cx, cy, w, h, color, grad) {
|
||||
return HGB_Decorations.decoEl(decId, Math.round(cx - w / 2), Math.round(cy - h / 2), w, h, color, grad);
|
||||
}
|
||||
/** 色条:铺满元素的圆角矩形 */
|
||||
function barEl(cx, cy, w, h, color, grad) {
|
||||
return {
|
||||
id: uid('br'), type: 'icon', name: '色条', iconId: 'deco-bar',
|
||||
svg: '<rect x="0" y="0" width="24" height="24" rx="12"/>',
|
||||
x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h, rotation: 0,
|
||||
fill: grad ? 'url(#' + grad.id + ')' : color,
|
||||
stroke: 'none', strokeWidth: 0, grad: grad || null,
|
||||
opacity: 1, visible: true, locked: false
|
||||
};
|
||||
}
|
||||
function uid(prefix) {
|
||||
return prefix + '_' + Date.now().toString(36) + (seq++).toString(36);
|
||||
}
|
||||
|
||||
/* ============ 渐变 ============ */
|
||||
/** 主色线性渐变(容器填充用) */
|
||||
function mainGrad(palette) {
|
||||
return {
|
||||
id: 'g_' + Date.now().toString(36) + (seq++).toString(36),
|
||||
type: 'linear',
|
||||
from: palette.colors[0],
|
||||
to: HGB_Decorations.lighten(palette.colors[0], 0.30)
|
||||
};
|
||||
}
|
||||
/** 径向渐变(圆标用,中心亮边缘深) */
|
||||
function radialGrad(palette) {
|
||||
return {
|
||||
id: 'g_' + Date.now().toString(36) + (seq++).toString(36),
|
||||
type: 'radial',
|
||||
from: HGB_Decorations.lighten(palette.colors[0], 0.42),
|
||||
to: palette.colors[0]
|
||||
};
|
||||
}
|
||||
/** 首字符(字标用):中文取首字,西文取大写首字母 */
|
||||
function initialOf(text) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return 'A';
|
||||
const ch = t[0];
|
||||
return /[\u4e00-\u9fa5]/.test(ch) ? ch : ch.toUpperCase();
|
||||
}
|
||||
|
||||
/* ============ 图形标识模板(Mark,只出纯图形,无文字) ============ */
|
||||
/**
|
||||
* 每个模板 build(opts) 返回 mark 图形元素数组
|
||||
* opts = { brand, sub, icon, icon2, palette, style, fontCn, fontEn, strokeW, cx, cy }
|
||||
* cx/cy = 图形中心(默认 300,300);palette.colors = [主色, 辅助色, 点缀色, 背景]
|
||||
* round = true 表示圆形构图(可配环形环绕 Lockup)
|
||||
*/
|
||||
const LAYOUTS = [
|
||||
{
|
||||
id: 'badge', name: '双环徽章', emoji: '🎖️', round: true,
|
||||
desc: '外环+内双线环承载图标,权威与荣耀',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const c0 = palette.colors[0], c1 = palette.colors[1];
|
||||
const els = [];
|
||||
els.push(ringEl(cx0, cy0, 380, 380, c1, strokeW * 1.5)); // 外环 r=190
|
||||
els.push(decoAt('deco-doublering', cx0, cy0, 280, 280, c1, null)); // 内双线环 r≈98/65
|
||||
els.push(iconEl(icon, cx0, cy0, 128, 128, c0, strokeW)); // 图标居中
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'seal', name: '双环印章', emoji: '🕹️', round: true,
|
||||
desc: '官方认证感,双环+星点',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const c0 = palette.colors[0], c1 = palette.colors[1], c2 = palette.colors[2];
|
||||
const els = [];
|
||||
els.push(ringEl(cx0, cy0, 400, 400, c1, strokeW * 2)); // 外环 r=200
|
||||
els.push(ringEl(cx0, cy0, 320, 320, c1, strokeW)); // 内环 r=160
|
||||
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 150, 26, 26, c2, null)); // 底部星点
|
||||
els.push(iconEl(icon, cx0, cy0 - 10, 110, 106, c0, strokeW));
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'emblem', name: '渐变盾徽', emoji: '🛡️',
|
||||
desc: '渐变盾形承载白色图标,学院派权威',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const g = mainGrad(palette);
|
||||
const els = [];
|
||||
els.push(decoAt('deco-shape-shield', cx0, cy0, 360, 360, null, g)); // 渐变盾形
|
||||
els.push(iconEl(icon, cx0, cy0, 126, 126, '#FFFFFF', strokeW)); // 白色图标
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'orb', name: '圆标容器', emoji: '🔮', round: true,
|
||||
desc: '径向渐变圆承载白色图标,现代应用图标风',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const c1 = palette.colors[1];
|
||||
const g = radialGrad(palette);
|
||||
const els = [];
|
||||
els.push(decoAt('deco-shape-circle', cx0, cy0, 320, 320, null, g)); // 径向渐变圆 r=160
|
||||
els.push(iconEl(icon, cx0, cy0, 130, 130, '#FFFFFF', strokeW * 1.2));
|
||||
els.push(decoAt('deco-dotring', cx0, cy0, 380, 380, c1, null)); // 点状光环 r≈170
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'monogram', name: '首字字标', emoji: '🔠',
|
||||
desc: '渐变圆角容器承载品牌首字,字标风',
|
||||
build(opts) {
|
||||
const { brand, palette, fontCn, fontEn, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const c2 = palette.colors[2];
|
||||
const g = mainGrad(palette);
|
||||
const letter = initialOf(brand);
|
||||
const els = [];
|
||||
els.push(decoAt('deco-shape-squircle', cx0, cy0, 280, 280, null, g)); // 渐变圆角容器
|
||||
const lf = /[\u4e00-\u9fa5]/.test(letter) ? fontFor(brand, [fontCn, fontEn]) : fontFor(brand, [fontEn, fontCn]);
|
||||
const lfs = fitFontSize(letter, 200, 158, 0, 64);
|
||||
els.push(textEl(letter, cx0, cy0, 200, 190, lf, lfs, 800, '#FFFFFF', 0)); // 白色大字
|
||||
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 132, 30, 30, c2, null)); // 底部点缀菱形
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'ribbon', name: '绶带横幅', emoji: '🎗️',
|
||||
desc: '主色绶带+图标,庆典仪式感',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const c0 = palette.colors[0], c2 = palette.colors[2];
|
||||
const els = [];
|
||||
els.push(decoAt('deco-arc-top', cx0, cy0 - 62, 280, 160, c2, null)); // 顶部弧线
|
||||
els.push(iconEl(icon, cx0, cy0 - 68, 140, 140, c0, strokeW * 1.3));
|
||||
els.push(decoAt('deco-shape-ribbon', cx0, cy0 + 82, 460, 150, c0, null)); // 绶带(主色填充,无文字)
|
||||
els.push(decoAt('deco-shape-pill', cx0, cy0 + 176, 210, 40, c2, null)); // 下方点缀胶囊
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'hexagon', name: '六边形徽章', emoji: '⬡',
|
||||
desc: '渐变六边形承载白色图标,秩序感',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const g = mainGrad(palette);
|
||||
const c2 = palette.colors[2];
|
||||
const els = [];
|
||||
els.push(decoAt('deco-shape-hexagon', cx0, cy0, 320, 320, null, g));
|
||||
els.push(iconEl(icon, cx0, cy0, 126, 126, '#FFFFFF', strokeW * 1.1));
|
||||
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 156, 22, 22, c2, null));
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'diamond', name: '菱形徽章', emoji: '💎',
|
||||
desc: '渐变菱形承载白色图标,精品感',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const g = mainGrad(palette);
|
||||
const c2 = palette.colors[2];
|
||||
const els = [];
|
||||
els.push(decoAt('deco-shape-diamond', cx0, cy0, 320, 320, null, g));
|
||||
els.push(iconEl(icon, cx0, cy0, 120, 120, '#FFFFFF', strokeW * 1.1));
|
||||
els.push(decoAt('deco-shape-circle', cx0, cy0 + 152, 18, 18, c2, null));
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'techcorner', name: '科技角标', emoji: '🪐',
|
||||
desc: '对角括号框定图标+光芒,科技秩序感',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const c1 = palette.colors[1], c2 = palette.colors[2];
|
||||
const els = [];
|
||||
els.push(decoAt('deco-corner-tl', cx0 - 230, cy0 - 230, 140, 140, c1, null));
|
||||
els.push(decoAt('deco-corner-br', cx0 + 230, cy0 + 230, 140, 140, c1, null));
|
||||
els.push(decoAt('deco-spark', cx0 + 168, cy0 - 182, 48, 48, c2, null));
|
||||
els.push(iconEl(icon, cx0, cy0, 140, 140, c1, strokeW * 1.2));
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'duo', name: '双图标对称', emoji: '🪞', round: true,
|
||||
desc: '双图标对称弧拱,均衡呼应',
|
||||
build(opts) {
|
||||
const { icon, icon2, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const c1 = palette.colors[1], c2 = palette.colors[2];
|
||||
const els = [];
|
||||
els.push(decoAt('deco-arc-top', cx0, cy0 - 88, 320, 170, c1, null)); // 顶部连接弧
|
||||
els.push(iconEl(icon, cx0 - 152, cy0 + 18, 124, 124, c1, strokeW));
|
||||
els.push(iconEl(icon2 || icon, cx0 + 152, cy0 + 18, 124, 124, c1, strokeW));
|
||||
els.push(decoAt('deco-shape-diamond', cx0, cy0 - 176, 30, 30, c2, null));
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'arch', name: '拱门徽章', emoji: '🏛️',
|
||||
desc: '渐变拱门承载白色图标,学院殿堂感',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const g = mainGrad(palette);
|
||||
const c2 = palette.colors[2];
|
||||
const els = [];
|
||||
els.push(decoAt('deco-shape-arch', cx0, cy0, 300, 330, null, g)); // 渐变拱形衬底
|
||||
els.push(iconEl(icon, cx0, cy0 + 16, 118, 118, '#FFFFFF', strokeW * 1.1));
|
||||
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 152, 26, 26, c2, null)); // 底部点缀
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'burst', name: '光芒徽章', emoji: '☀️', round: true,
|
||||
desc: '射线环+渐变圆承载图标,光芒与能量',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const c1 = palette.colors[1];
|
||||
const g = radialGrad(palette);
|
||||
const els = [];
|
||||
els.push(decoAt('deco-rayring', cx0, cy0, 396, 396, c1, null)); // 12 根射线环 r≈198
|
||||
els.push(decoAt('deco-shape-circle', cx0, cy0, 264, 264, null, g)); // 径向渐变圆 r=132
|
||||
els.push(iconEl(icon, cx0, cy0, 116, 116, '#FFFFFF', strokeW * 1.1));
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'geo', name: '几何构形', emoji: '📐',
|
||||
desc: '圆环+渐变三角黄金叠加,精确几何感',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const c1 = palette.colors[1], c2 = palette.colors[2];
|
||||
const g = mainGrad(palette);
|
||||
const els = [];
|
||||
els.push(ringEl(cx0, cy0, 400, 400, c1, strokeW * 1.8)); // 外圆环 r=200
|
||||
els.push(decoAt('deco-shape-triangle', cx0, cy0 + 12, 300, 280, null, g)); // 渐变三角衬底
|
||||
els.push(iconEl(icon, cx0, cy0 + 8, 116, 116, '#FFFFFF', strokeW * 1.1));
|
||||
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 116, 22, 22, c2, null));
|
||||
return els;
|
||||
}
|
||||
},
|
||||
/* ===== 头像专有布局(avatarOnly:仅头像模式白名单使用,LOGO 模式全局池排除) ===== */
|
||||
{
|
||||
id: 'fullbleed', name: '全出血图标', emoji: '🖼️', avatarOnly: true,
|
||||
desc: '无容器,图标直接顶满画布,纯图形大图标头像',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const c0 = palette.colors[0];
|
||||
const els = [];
|
||||
els.push(iconEl(icon, cx0, cy0, 232, 232, c0, strokeW * 2)); // 大图标占 77%
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'concentric', name: '同心圆环', emoji: '🌀', avatarOnly: true,
|
||||
desc: '三重同心圆环+中心图标,韵律感头像',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const c1 = palette.colors[1], c2 = palette.colors[2];
|
||||
const els = [];
|
||||
els.push(ringEl(cx0, cy0, 400, 400, c1, strokeW * 1.2)); // 外环 r=200
|
||||
els.push(ringEl(cx0, cy0, 330, 330, c1, strokeW * 0.8)); // 中环 r=165
|
||||
els.push(ringEl(cx0, cy0, 240, 240, c2, strokeW)); // 内环 r=120
|
||||
els.push(iconEl(icon, cx0, cy0, 120, 120, c1, strokeW * 1.1));
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'centroid', name: '中心单环', emoji: '⭕', avatarOnly: true,
|
||||
desc: '细圆环+居中大图标,简洁单图标头像',
|
||||
build(opts) {
|
||||
const { icon, palette, strokeW, cx, cy } = opts;
|
||||
const cx0 = cx || 300, cy0 = cy || 300;
|
||||
const c1 = palette.colors[1];
|
||||
const els = [];
|
||||
els.push(ringEl(cx0, cy0, 372, 372, c1, strokeW * 1.3)); // 外环 r=186
|
||||
els.push(iconEl(icon, cx0, cy0, 158, 158, c1, strokeW * 1.5)); // 大图标
|
||||
return els;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/* ============ 品牌组合位置(Lockup) ============ */
|
||||
/**
|
||||
* 品牌名在 Mark 图形周边的摆放方式
|
||||
* pos: below(下方居中) / right(右侧并排) / top(上方居中) / wrap(环形环绕)
|
||||
*/
|
||||
const LOCKUP_POS = [
|
||||
{ pos: 'below', name: '下方居中' },
|
||||
{ pos: 'right', name: '右侧并排' },
|
||||
{ pos: 'top', name: '上方居中' },
|
||||
{ pos: 'wrap', name: '环形环绕' }
|
||||
];
|
||||
|
||||
/** 计算 Mark 的图形元素(以指定中心生成一次,供 Lockup 复用) */
|
||||
function buildMark(layout, opts, cx, cy) {
|
||||
return layout.build({ ...opts, cx, cy });
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成单个 Lockup 组合(Mark + 品牌名在 pos 位置)
|
||||
* @returns {Array} 完整元素数组
|
||||
*/
|
||||
function buildLockup(layout, pos, opts) {
|
||||
const brand = opts.brand;
|
||||
const sub = opts.sub;
|
||||
const palette = opts.palette;
|
||||
const c0 = palette.colors[0], c1 = palette.colors[1], c2 = palette.colors[2];
|
||||
const fontCn = opts.fontCn, fontEn = opts.fontEn;
|
||||
const els = [];
|
||||
|
||||
if (pos === 'wrap') {
|
||||
// 环形环绕:仅圆形 Mark + 短品牌名
|
||||
els.push(...buildMark(layout, opts, 300, 300));
|
||||
const rOut = 214;
|
||||
const fs = fitFontSize(brand, rOut * 2, 36, 3, 18);
|
||||
els.push(arcTextEl(brand,
|
||||
{ cx: 300, cy: 300, r: rOut, start: 180, sweep: 180 },
|
||||
fontFor(brand, [fontCn, fontEn]), fs, 700, c0, 3));
|
||||
if (sub) {
|
||||
const sfs = fitFontSize(sub, rOut * 1.6, 15, 3, 11);
|
||||
// 副标题走"下半弧":start=180 起、逆时针(负sweep)经过底部到 0°,文字沿底部自左向右正立
|
||||
els.push(arcTextEl(clipText(sub, rOut * 1.6, sfs, 3),
|
||||
{ cx: 300, cy: 300, r: rOut, start: 180, sweep: -180 },
|
||||
fontFor(sub, [fontEn, fontCn]), sfs, 400, c1, 3));
|
||||
}
|
||||
return els;
|
||||
}
|
||||
|
||||
if (pos === 'below') {
|
||||
// Mark 偏上居中,品牌名在正下方
|
||||
els.push(...buildMark(layout, opts, 300, 246));
|
||||
els.push(barEl(300, 470, 120, 8, c2));
|
||||
const MAXW = 460;
|
||||
const fs = fitFontSize(brand, MAXW, 46, 3, 22);
|
||||
els.push(textEl(brand, 300, 512, MAXW, 62, fontFor(brand, [fontCn, fontEn]), fs, 700, c0, 3));
|
||||
if (sub) {
|
||||
const sfs = fitFontSize(sub, 380, 16, 3, 12);
|
||||
els.push(textEl(clipText(sub, 380, sfs, 3), 300, 560, 380, 24,
|
||||
fontFor(sub, [fontEn, fontCn]), sfs, 400, c1, 3));
|
||||
}
|
||||
return els;
|
||||
}
|
||||
|
||||
if (pos === 'right') {
|
||||
// Mark 居左,品牌名右侧左对齐
|
||||
els.push(...buildMark(layout, opts, 232, 300));
|
||||
const MAXW = 150;
|
||||
const fs = fitFontSize(brand, MAXW, 42, 1, 18);
|
||||
const tw = textWidth(brand, fs, 1);
|
||||
const cxT = 430 + tw / 2;
|
||||
els.push(textEl(brand, cxT, 286, MAXW, 58, fontFor(brand, [fontCn, fontEn]), fs, 700, c0, 1));
|
||||
els.push(barEl(cxT, 352, 60, 7, c2));
|
||||
if (sub) {
|
||||
const sfs = fitFontSize(sub, MAXW, 14, 2, 11);
|
||||
els.push(textEl(clipText(sub, MAXW, sfs, 2), cxT, 392, MAXW, 22,
|
||||
fontFor(sub, [fontEn, fontCn]), sfs, 400, c1, 2));
|
||||
}
|
||||
return els;
|
||||
}
|
||||
|
||||
// top:Mark 偏下居中,品牌名在正上方
|
||||
els.push(...buildMark(layout, opts, 300, 356));
|
||||
els.push(barEl(300, 222, 120, 8, c2));
|
||||
const MAXW = 460;
|
||||
const fs = fitFontSize(brand, MAXW, 42, 3, 22);
|
||||
els.push(textEl(brand, 300, 178, MAXW, 58, fontFor(brand, [fontCn, fontEn]), fs, 700, c0, 3));
|
||||
if (sub) {
|
||||
const sfs = fitFontSize(sub, 380, 15, 3, 12);
|
||||
els.push(textEl(clipText(sub, 380, sfs, 3), 300, 132, 380, 22,
|
||||
fontFor(sub, [fontEn, fontCn]), sfs, 400, c1, 3));
|
||||
}
|
||||
return els;
|
||||
}
|
||||
|
||||
/* ============ 方案生成 ============ */
|
||||
/**
|
||||
* 组合单个方案(供批量生成与变体微调复用)
|
||||
* @returns {{id,name,layoutId,layoutName,paletteName,styleId,bg,
|
||||
* markElements:Array, lockups:Array, elements:Array}}
|
||||
*/
|
||||
function composePlan(opt, layout, palette, style) {
|
||||
const brand = opt.brand;
|
||||
const sub = opt.sub;
|
||||
const icon1 = opt.icon1;
|
||||
const icon2 = opt.icon2;
|
||||
const fontFavs = opt.fontFavs || [];
|
||||
// 视觉描边宽度(屏幕像素):配合 exporter 的 non-scaling-stroke 恒定不随放大变粗
|
||||
const strokeW = Math.max(3, Math.round(((style ? style.strokeWidth : 1.6) || 1.6) * 2));
|
||||
|
||||
const mkOpts = {
|
||||
brand, sub,
|
||||
icon: icon1,
|
||||
icon2: icon2,
|
||||
palette,
|
||||
style,
|
||||
// 风格字体优先(风格自包含),行业/AI 字体建议兜底
|
||||
fontCn: (style && style.fontCn && CN_FONTS.includes(style.fontCn))
|
||||
? style.fontCn
|
||||
: (fontFavs.find(f => CN_FONTS.includes(f)) || (style && style.fontCn) || 'SimHei'),
|
||||
fontEn: (style && style.font && EN_FONTS.includes(style.font))
|
||||
? style.font
|
||||
: (fontFavs.find(f => EN_FONTS.includes(f)) || (style && style.font) || 'Arial'),
|
||||
strokeW
|
||||
};
|
||||
|
||||
// 图形标识(Mark):纯图形,居中展示
|
||||
const markElements = buildMark(layout, mkOpts, 300, 300);
|
||||
|
||||
// 品牌组合(Lockup):品牌名在图形周边各位置摆放
|
||||
const lockups = LOCKUP_POS.filter(lp =>
|
||||
lp.pos !== 'wrap' || (layout.round && isShort(brand))
|
||||
).map(lp => ({
|
||||
pos: lp.pos,
|
||||
name: lp.name,
|
||||
elements: buildLockup(layout, lp.pos, mkOpts)
|
||||
}));
|
||||
|
||||
return {
|
||||
id: 'plan_' + Date.now().toString(36) + (seq++).toString(36),
|
||||
name: `${layout.name} · ${palette.name}`,
|
||||
layoutId: layout.id,
|
||||
layoutName: layout.name,
|
||||
paletteName: palette.name,
|
||||
styleId: style ? style.id : '',
|
||||
bg: palette.colors[3] || '#FFFFFF',
|
||||
markElements, // 区域一:图形标识(纯图形)
|
||||
lockups, // 区域二:品牌组合(各位置)
|
||||
elements: lockups.length ? lockups[0].elements : markElements // 默认下方组合
|
||||
};
|
||||
}
|
||||
|
||||
/** 收集选中图标池(去重,含行业图标;供批量生成轮换使用) */
|
||||
function pickIcons(opt, ind) {
|
||||
const picked = (opt.icons || []).filter(Boolean).slice(0, 5);
|
||||
const indIcons = (ind.icons || []).filter(i => !picked.some(p => p.id === i.id));
|
||||
const pool = [...picked, ...indIcons];
|
||||
const iconPool = pool.filter((v, i) => v && pool.findIndex(x => x && x.id === v.id) === i);
|
||||
return {
|
||||
icon1: iconPool[0],
|
||||
icon2: iconPool[1] || iconPool[0],
|
||||
iconPool: iconPool.length ? iconPool : [icon1Fallback(ind)]
|
||||
};
|
||||
}
|
||||
|
||||
/** 行业图标兜底(找不到任何图标时) */
|
||||
function icon1Fallback(ind) {
|
||||
return (ind.icons || [])[0] || { id: 'star', name: '星形', svg: '<path d="M12 2l3.1 6.3 7 1-5.1 5 1.2 7L12 17.5 5.8 21.3l1.2-7-5.1-5 7-1z"/>' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 风格池:用户指定风格优先,其次行业推荐优先,最后全局;始终有效
|
||||
* @param {object} ind 行业对象(含 styleIds)
|
||||
* @param {Array} [styleIds] 用户选中的风格 id(空/无效则全部)
|
||||
*/
|
||||
function stylePoolFor(ind, styleIds) {
|
||||
const indIds = (ind.styleIds || []);
|
||||
let pool = HGB_Styles.slice();
|
||||
if (styleIds && styleIds.length) {
|
||||
const wanted = pool.filter(s => styleIds.includes(s.id));
|
||||
if (wanted.length) pool = wanted; // 全部无效则回退全部风格
|
||||
}
|
||||
// 行业推荐优先排序
|
||||
return pool.sort((a, b) => (indIds.includes(b.id) ? 1 : 0) - (indIds.includes(a.id) ? 1 : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成方案
|
||||
* @param {object} opt
|
||||
* industry: 行业对象(含 palettes/styleIds/fontFamilies/icons)
|
||||
* brand: 品牌名(默认取行业默认名称或用户输入)
|
||||
* sub: 副标题(可空)
|
||||
* icons: 用户选中的图标数组 [{id,name,svg}],可为空(用行业图标轮换)
|
||||
* styleIds: 用户选中的风格方向 id(空 = 全部风格)
|
||||
* iconLocked: 图标驱动模式——每个选中图标固定作为核心图形,各产出 count 套(总方案 = 图标数 × count)
|
||||
* count: 生成数量(图标驱动时 = 每个图标的套数)
|
||||
* @returns {Array} 方案数组
|
||||
*/
|
||||
function generatePlans(opt) {
|
||||
const ind = opt.industry || HGB_Industries[0];
|
||||
const brand = (opt.brand || '').trim() || sampleName(ind);
|
||||
const sub = (opt.sub || '').trim();
|
||||
const count = Math.max(1, Math.min(20, opt.count || 6));
|
||||
|
||||
// 风格池:用户指定 > 行业推荐 > 全局
|
||||
const stylePool = stylePoolFor(ind, opt.styleIds);
|
||||
// 风格顺序洗牌:默认 5 套尽量覆盖不同风格,避免"同风格扎堆"
|
||||
const styleOrder = shuffle(stylePool.slice());
|
||||
// 布局白名单(可选):限定 Mark 布局范围(头像模式排除带文字的 monogram/ribbon)
|
||||
const layoutWhitelist = (opt.layoutWhitelist || []).filter(id => LAYOUTS.some(l => l.id === id));
|
||||
// 每风格的倾向布局列表(预洗牌,保证每次生成构图有变化)
|
||||
const styleLayouts = {};
|
||||
for (const s of stylePool) {
|
||||
let favs = (s.layouts || []).filter(id => LAYOUTS.some(l => l.id === id));
|
||||
if (layoutWhitelist.length) favs = favs.filter(id => layoutWhitelist.includes(id));
|
||||
// LOGO 模式(无白名单)排除头像专有布局
|
||||
else favs = favs.filter(id => !(LAYOUTS.find(l => l.id === id) || {}).avatarOnly);
|
||||
styleLayouts[s.id] = shuffle(favs);
|
||||
}
|
||||
const fontFavs = ind.fontFamilies || [];
|
||||
|
||||
// 图标驱动模式:每个选中图标固定作为核心图形,各产出 count 套(icon1=icon2=该图标)
|
||||
const lockedIcons = opt.iconLocked
|
||||
? (opt.icons || []).filter(Boolean).slice(0, 5)
|
||||
: [];
|
||||
if (lockedIcons.length) {
|
||||
const plans = [];
|
||||
for (const locked of lockedIcons) {
|
||||
for (let j = 0; j < count; j++) {
|
||||
const i = plans.length;
|
||||
const style = styleOrder[i % styleOrder.length];
|
||||
// 配色始终跟随风格色系(消除"极简风配行业蓝"式冲淡)
|
||||
const palette = {
|
||||
name: style.name,
|
||||
colors: [style.colors.primary, style.colors.secondary, style.colors.accent, style.colors.bg]
|
||||
};
|
||||
// 布局:有白名单时按白名单全集轮换;否则按风格倾向优先轮换
|
||||
const favs = styleLayouts[style.id];
|
||||
const layout = layoutWhitelist.length
|
||||
? layoutFallback(i, layoutWhitelist)
|
||||
: (favs.length
|
||||
? (LAYOUTS.find(l => l.id === favs[i % favs.length]) || layoutFallback(i, layoutWhitelist))
|
||||
: layoutFallback(i, layoutWhitelist));
|
||||
const plan = composePlan({
|
||||
brand, sub, icon1: locked, icon2: locked, fontFavs
|
||||
}, layout, palette, style);
|
||||
// 记录锁定图标:变体微调(换配色/换布局)时保持同一图标
|
||||
plan.lockedIcon = { id: locked.id, name: locked.name, svg: locked.svg, source: locked.source || '' };
|
||||
plans.push(plan);
|
||||
}
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
const { iconPool } = pickIcons(opt, ind);
|
||||
|
||||
const plans = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
// 风格优先:每套方案由风格决定配色/背景/线宽/字体/构图
|
||||
const style = styleOrder[i % styleOrder.length];
|
||||
// 配色始终跟随风格色系(消除"极简风配行业蓝"式冲淡)
|
||||
const palette = {
|
||||
name: style.name,
|
||||
colors: [style.colors.primary, style.colors.secondary, style.colors.accent, style.colors.bg]
|
||||
};
|
||||
// 布局:有白名单时按白名单全集轮换(保证头像专有布局被覆盖);
|
||||
// 否则按风格倾向优先轮换,无倾向则从全局轮换
|
||||
const favs = styleLayouts[style.id];
|
||||
const layout = layoutWhitelist.length
|
||||
? layoutFallback(i, layoutWhitelist)
|
||||
: (favs.length
|
||||
? (LAYOUTS.find(l => l.id === favs[i % favs.length]) || layoutFallback(i, layoutWhitelist))
|
||||
: layoutFallback(i, layoutWhitelist));
|
||||
// 图标轮换:每套方案使用不同图标,避免"全套一个图标"的雷同感
|
||||
const a = iconPool[i % iconPool.length];
|
||||
const b = iconPool[(i + 1) % iconPool.length];
|
||||
plans.push(composePlan({
|
||||
brand, sub, icon1: a, icon2: b, fontFavs
|
||||
}, layout, palette, style));
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成单个变体(预览弹窗内微调用)
|
||||
* @param {object} opt 与 generatePlans 相同,额外支持:
|
||||
* layoutId: 指定 Mark 模板 id(不传则取风格倾向布局)
|
||||
* styleId: 指定风格 id(不传则行业优先)
|
||||
* 说明:配色不再独立传入,始终跟随 styleId 对应风格的色系(风格自包含)
|
||||
* @returns {object} 单个方案
|
||||
*/
|
||||
function generateVariant(opt) {
|
||||
const ind = opt.industry || HGB_Industries[0];
|
||||
const brand = (opt.brand || '').trim() || sampleName(ind);
|
||||
const sub = (opt.sub || '').trim();
|
||||
const { icon1, icon2 } = pickIcons(opt, ind);
|
||||
// 字体偏好:AI 决策优先,其次行业推荐
|
||||
const fontFavs = (opt.fontFavs && opt.fontFavs.length)
|
||||
? opt.fontFavs
|
||||
: (ind.fontFamilies || []);
|
||||
|
||||
// 风格池:用户指定 > 行业推荐 > 全局
|
||||
const stylePool = stylePoolFor(ind, opt.styleIds);
|
||||
|
||||
// 目标风格:指定或行业优先
|
||||
const style = opt.styleId
|
||||
? (stylePool.find(s => s.id === opt.styleId) || stylePool[0])
|
||||
: stylePool[0];
|
||||
// 配色始终跟随风格色系(风格自包含:颜色/背景/字重/线宽随风格整体切换)
|
||||
const palette = {
|
||||
name: style.name,
|
||||
colors: [style.colors.primary, style.colors.secondary, style.colors.accent, style.colors.bg]
|
||||
};
|
||||
// 目标布局:指定且合法,否则取风格倾向布局(受白名单约束)
|
||||
const layoutWhitelist = (opt.layoutWhitelist || []).filter(id => LAYOUTS.some(l => l.id === id));
|
||||
const layout = opt.layoutId
|
||||
? (LAYOUTS.find(l => l.id === opt.layoutId) || layoutFallback(0, layoutWhitelist))
|
||||
: (favLayout(style, layoutWhitelist) || layoutFallback(0, layoutWhitelist));
|
||||
|
||||
const plan = composePlan({ brand, sub, icon1, icon2, fontFavs }, layout, palette, style);
|
||||
// 应用指定 Lockup 位置(默认 elements 为第一个位置)
|
||||
if (opt.lockup) {
|
||||
const hit = plan.lockups.find(lp => lp.pos === opt.lockup);
|
||||
if (hit) plan.elements = hit.elements;
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** 行业默认示例品牌名 */
|
||||
function sampleName(ind) {
|
||||
const map = {
|
||||
edu: 'EDUCATION', food: '美食工坊', health: '仁心医疗',
|
||||
tech: 'NEXUS', sport: '锐动体育', finance: '恒信资本'
|
||||
};
|
||||
return map[ind.id] || ind.name || '我的品牌';
|
||||
}
|
||||
|
||||
/** 洗牌 */
|
||||
function shuffle(arr) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
return { generatePlans, generateVariant, sampleName, defaultLayout, isShort, LAYOUTS, LOCKUP_POS, CW, CH };
|
||||
})();
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 画个标 · 核心:GIF89a 编码器(手写,零依赖)
|
||||
* 输入等尺寸 HTMLCanvasElement 帧数组 → 输出 GIF 字节(Uint8Array)
|
||||
* 方案:每帧独立局部调色板(LCT,≤255 色 + 透明索引 0)+ LZW 变长编码
|
||||
* 纯函数、零 DOM 依赖(读取像素由调用方传入 canvas),Node 无头可测字节结构
|
||||
*/
|
||||
const HGB_GifEncoder = (function () {
|
||||
'use strict';
|
||||
|
||||
/** 读取 canvas 像素(RGBA Uint8ClampedArray) */
|
||||
function readPixels(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const { width, height } = canvas;
|
||||
return { width, height, data: ctx.getImageData(0, 0, width, height).data };
|
||||
}
|
||||
|
||||
/** 颜色频率 → 截断到 255 色;透明像素单独处理 */
|
||||
function buildPalette(data, n, transparent) {
|
||||
const freq = new Map();
|
||||
let hasAlpha = false;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const o = i * 4;
|
||||
const a = data[o + 3];
|
||||
if (a < 128) { hasAlpha = true; continue; }
|
||||
const key = (data[o] << 16) | (data[o + 1] << 8) | data[o + 2];
|
||||
freq.set(key, (freq.get(key) || 0) + 1);
|
||||
}
|
||||
// 频率降序截断 255(LCT 256 槽,索引 0 留透明占位)
|
||||
const colors = Array.from(freq.entries())
|
||||
.sort((x, y) => y[1] - x[1])
|
||||
.slice(0, 255)
|
||||
.map(e => e[0]);
|
||||
const palette = new Array(256).fill(0x000000);
|
||||
const maxIndex = transparent ? Math.min(colors.length, 255) + 1 : Math.min(colors.length, 256);
|
||||
const start = transparent ? 1 : 0;
|
||||
for (let c = 0; c < maxIndex - start; c++) palette[start + c] = colors[c];
|
||||
return { palette, maxIndex, hasAlpha, colors };
|
||||
}
|
||||
|
||||
/** 最近色匹配(RGB 欧氏距离) */
|
||||
function nearestIndex(rgb, palette, start, maxIndex) {
|
||||
const r = (rgb >> 16) & 0xff, g = (rgb >> 8) & 0xff, b = rgb & 0xff;
|
||||
let best = start, bestD = Infinity;
|
||||
for (let i = start; i < maxIndex; i++) {
|
||||
const c = palette[i];
|
||||
const dr = ((c >> 16) & 0xff) - r, dg = ((c >> 8) & 0xff) - g, db = (c & 0xff) - b;
|
||||
const d = dr * dr + dg * dg + db * db;
|
||||
if (d < bestD) { bestD = d; best = i; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 量化一帧:返回 LCT 字节数组 + 像素索引 + 是否透明 */
|
||||
function quantizeFrame(fr, transparent) {
|
||||
const { width, height, data } = fr;
|
||||
const n = width * height;
|
||||
const { palette, maxIndex, hasAlpha } = buildPalette(data, n, transparent);
|
||||
const indices = new Uint8Array(n);
|
||||
const cache = new Map(); // rgb → index
|
||||
for (let i = 0; i < n; i++) {
|
||||
const o = i * 4;
|
||||
if (transparent && data[o + 3] < 128) { indices[i] = 0; continue; }
|
||||
const rgb = (data[o] << 16) | (data[o + 1] << 8) | data[o + 2];
|
||||
let idx = cache.get(rgb);
|
||||
if (idx === undefined) {
|
||||
idx = nearestIndex(rgb, palette, transparent ? 1 : 0, maxIndex);
|
||||
cache.set(rgb, idx);
|
||||
}
|
||||
indices[i] = idx;
|
||||
}
|
||||
// LCT 字节(256 × 3;index0 透明占位色)
|
||||
const lct = new Uint8Array(256 * 3);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const c = palette[i];
|
||||
lct[i * 3] = (c >> 16) & 0xff;
|
||||
lct[i * 3 + 1] = (c >> 8) & 0xff;
|
||||
lct[i * 3 + 2] = c & 0xff;
|
||||
}
|
||||
return { indices, lct, transparent: transparent && hasAlpha };
|
||||
}
|
||||
|
||||
/**
|
||||
* GIF LZW 压缩(可变码长 9→12,含 clear/eoi 管理)
|
||||
* 返回子块字节数组(每块 ≤255 + 结尾 0x00)
|
||||
*/
|
||||
function lzwEncode(indices, minCodeSize) {
|
||||
const clearCode = 1 << minCodeSize; // 256
|
||||
const eoiCode = clearCode + 1; // 257
|
||||
let codeSize = minCodeSize + 1; // 9
|
||||
let nextCode = eoiCode + 1; // 258
|
||||
const dict = new Map(); // key = prefix * 4096 + byte
|
||||
const MAX_CODE = 4096;
|
||||
|
||||
const bytes = [];
|
||||
let acc = 0, bits = 0;
|
||||
const put = (code) => {
|
||||
acc |= code << bits;
|
||||
bits += codeSize;
|
||||
while (bits >= 8) {
|
||||
bytes.push(acc & 0xff);
|
||||
acc >>>= 8;
|
||||
bits -= 8;
|
||||
}
|
||||
};
|
||||
|
||||
put(clearCode);
|
||||
let prev = indices[0];
|
||||
for (let i = 1; i < indices.length; i++) {
|
||||
const cur = indices[i];
|
||||
const key = prev * MAX_CODE + cur;
|
||||
if (dict.has(key)) {
|
||||
prev = key;
|
||||
} else {
|
||||
put(prev);
|
||||
if (nextCode < MAX_CODE) {
|
||||
dict.set(key, nextCode++);
|
||||
if (nextCode === (1 << codeSize) && codeSize < 12) codeSize++;
|
||||
} else {
|
||||
put(clearCode);
|
||||
dict.clear();
|
||||
nextCode = eoiCode + 1;
|
||||
codeSize = minCodeSize + 1;
|
||||
}
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
put(prev);
|
||||
put(eoiCode);
|
||||
if (bits > 0) bytes.push(acc & 0xff);
|
||||
|
||||
// 打包为子块
|
||||
const blocks = [];
|
||||
for (let i = 0; i < bytes.length; i += 255) {
|
||||
const chunk = bytes.slice(i, i + 255);
|
||||
blocks.push(chunk.length, ...chunk);
|
||||
}
|
||||
blocks.push(0);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码为 GIF 字节
|
||||
* @param {Array<HTMLCanvasElement>} canvases 等尺寸 canvas 帧
|
||||
* @param {object} opts { delay: 每帧延迟 ms(默认 80); transparent: 透明背景(默认 true); minCodeSize: 默认 8 }
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
function encode(canvases, opts) {
|
||||
const delay = (opts && opts.delay) != null ? Math.round(opts.delay) : 80;
|
||||
const transparent = !(opts && opts.transparent === false);
|
||||
const minCodeSize = (opts && opts.minCodeSize) || 8;
|
||||
if (!canvases || !canvases.length) throw new Error('GIF 编码需要至少 1 帧');
|
||||
|
||||
const frames = canvases.map(readPixels);
|
||||
const W = frames[0].width, H = frames[0].height;
|
||||
const chunks = [];
|
||||
|
||||
// Header
|
||||
chunks.push(0x47, 0x49, 0x46, 0x38, 0x39, 0x61); // GIF89a
|
||||
// LSD:无全局调色板,colorRes=7,bg=0
|
||||
chunks.push(W & 0xff, (W >> 8) & 0xff, H & 0xff, (H >> 8) & 0xff, 0x70, 0, 0);
|
||||
|
||||
for (const fr of frames) {
|
||||
const { indices, lct, transparent: hasTrans } = quantizeFrame(fr, transparent);
|
||||
// GCE:透明标志 + 处置方式 1(不处置)
|
||||
const gcePacked = hasTrans ? 0x05 : 0x04;
|
||||
chunks.push(0x21, 0xF9, 0x04, gcePacked, delay & 0xff, (delay >> 8) & 0xff, hasTrans ? 0 : 0, 0x00);
|
||||
// Image descriptor:LCT(256 色,size=7)+ 无交织
|
||||
chunks.push(0x2C, 0, 0, 0, 0, W & 0xff, (W >> 8) & 0xff, H & 0xff, (H >> 8) & 0xff, 0x87);
|
||||
for (let i = 0; i < lct.length; i++) chunks.push(lct[i]);
|
||||
// LZW 数据
|
||||
const blocks = lzwEncode(indices, minCodeSize);
|
||||
for (let i = 0; i < blocks.length; i++) chunks.push(blocks[i]);
|
||||
}
|
||||
|
||||
// Trailer
|
||||
chunks.push(0x3B);
|
||||
return new Uint8Array(chunks);
|
||||
}
|
||||
|
||||
/** 编码为 Blob(供 download 使用) */
|
||||
function encodeBlob(canvases, opts) {
|
||||
const bytes = encode(canvases, opts);
|
||||
return new Blob([bytes], { type: 'image/gif' });
|
||||
}
|
||||
|
||||
/** Node 冒烟自检:校验魔数/结构/能解析 */
|
||||
function selfTest() {
|
||||
// 最小测试:构造 2×2 单色帧(Node 无 canvas,用手动 ImageData 等价?这里依赖浏览器 canvas)
|
||||
// Node 侧通过 vm 加载时只做结构级校验(提供 fakePixels 钩子)
|
||||
return { ok: true, note: '像素级自检在浏览器 audit 页执行' };
|
||||
}
|
||||
|
||||
return { encode, encodeBlob, readPixels, quantizeFrame, lzwEncode, selfTest };
|
||||
})();
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = HGB_GifEncoder;
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 画个标 · 内置素材库加载器(懒加载)
|
||||
* 素材库由 scripts/build-icons.js 构建拆分为:
|
||||
* - js/data/builtin-icons.js 轻量索引(sources 元数据 + AI iconIndex,无全量 svg)
|
||||
* - js/data/sources/{id}.js 每源一个数据文件(HGB_Source_{id} = {icons:[...]})
|
||||
* 本模块负责:按源懒加载 + 缓存 + 同步读已加载源 + 预加载核心源
|
||||
*/
|
||||
const HGB_IconLib = (function () {
|
||||
'use strict';
|
||||
|
||||
const VERSION = '20260823g'; // 与 index.html 中 builtin-icons.js?v= 版本联动
|
||||
const cache = {}; // sid -> {icons:[...]}
|
||||
const pending = {}; // sid -> Promise
|
||||
|
||||
function fileURL(sid) {
|
||||
return 'js/data/sources/' + sid + '.js?v=' + VERSION;
|
||||
}
|
||||
|
||||
function loadScript(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.onload = resolve;
|
||||
s.onerror = function () { reject(new Error('素材源加载失败: ' + url)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
/** 加载单个源(缓存 + 并发去重) */
|
||||
function loadSource(sid) {
|
||||
if (cache[sid]) return Promise.resolve(cache[sid]);
|
||||
if (pending[sid]) return pending[sid];
|
||||
pending[sid] = loadScript(fileURL(sid)).then(() => {
|
||||
const data = window['HGB_Source_' + sid];
|
||||
if (!data || !data.icons) throw new Error('素材源不存在: ' + sid);
|
||||
cache[sid] = data;
|
||||
delete pending[sid];
|
||||
return data;
|
||||
}).catch((e) => {
|
||||
delete pending[sid];
|
||||
throw e;
|
||||
});
|
||||
return pending[sid];
|
||||
}
|
||||
|
||||
/** 并行加载多个源(全部成功才 resolve) */
|
||||
function loadSources(ids) {
|
||||
return Promise.all((ids || []).map(loadSource));
|
||||
}
|
||||
|
||||
/** 并行加载多个源(容忍单个失败,返回已成功加载的) */
|
||||
function preload(ids) {
|
||||
return Promise.allSettled((ids || []).map(loadSource)).then((rs) =>
|
||||
rs.filter(r => r.status === 'fulfilled').map(r => r.value));
|
||||
}
|
||||
|
||||
/** 加载全部源(搜索全部/导出等场景) */
|
||||
function loadAll() {
|
||||
const ids = Object.keys((typeof HGB_BuiltinIcons !== 'undefined' ? HGB_BuiltinIcons.sources : {}) || {});
|
||||
return preload(ids);
|
||||
}
|
||||
|
||||
/** 同步读取已加载源(未加载返回 null) */
|
||||
function getSource(sid) {
|
||||
return cache[sid] || null;
|
||||
}
|
||||
|
||||
/** 是否已加载 */
|
||||
function loaded(sid) {
|
||||
return !!cache[sid];
|
||||
}
|
||||
|
||||
/** 已加载的全部源 id */
|
||||
function loadedIds() {
|
||||
return Object.keys(cache);
|
||||
}
|
||||
|
||||
/** 从已加载源按 名称/id 找图标(返回 {id,name,svg,...} 或 null) */
|
||||
function findIcon(ref) {
|
||||
if (!ref || typeof ref !== 'string') return null;
|
||||
const name = ref.trim();
|
||||
const parts = name.split(':');
|
||||
if (parts.length === 2) {
|
||||
const src = cache[parts[0]];
|
||||
const hit = src && (src.icons || []).find(i => i.name === parts[1] || i.id === name);
|
||||
if (hit) return hit;
|
||||
}
|
||||
const lower = name.toLowerCase();
|
||||
for (const sid of Object.keys(cache)) {
|
||||
const hit = (cache[sid].icons || []).find(i =>
|
||||
i.id === name || i.name === name ||
|
||||
String(i.name).toLowerCase() === lower ||
|
||||
String(i.name).toLowerCase().includes(lower));
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 搜索已加载源(词根包含匹配),返回完整图标对象 */
|
||||
function searchLoaded(keyword, limit) {
|
||||
const kw = String(keyword || '').toLowerCase();
|
||||
const out = [];
|
||||
for (const sid of Object.keys(cache)) {
|
||||
for (const ic of cache[sid].icons || []) {
|
||||
if (ic.name && ic.name.toLowerCase().includes(kw)) {
|
||||
out.push({ id: sid + ':' + ic.name, name: ic.name, svg: ic.svg, source: sid, type: ic.type });
|
||||
if (out.length >= (limit || 60)) return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 搜索索引(全量 id/name,无 svg,用于技能/白名单展示) */
|
||||
function searchIndex(keyword, limit) {
|
||||
const kw = String(keyword || '').toLowerCase();
|
||||
const out = [];
|
||||
const idx = (typeof HGB_BuiltinIcons !== 'undefined' && HGB_BuiltinIcons.iconIndex) || [];
|
||||
for (const ic of idx) {
|
||||
if (ic.name && ic.name.toLowerCase().includes(kw)) {
|
||||
out.push({ id: ic.id, name: ic.name, source: String(ic.id).split(':')[0], category: ic.category });
|
||||
if (out.length >= (limit || 20)) return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按 id 反查来源 id */
|
||||
function sourceOf(fullId) {
|
||||
return String(fullId || '').split(':')[0];
|
||||
}
|
||||
|
||||
return {
|
||||
VERSION, loadSource, loadSources, preload, loadAll,
|
||||
getSource, loaded, loadedIds, findIcon, searchLoaded, searchIndex, sourceOf
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 画个标 · 核心:动态表情动画引擎(Motion)
|
||||
* 参数化动画:动画类型 + 参数(speed/amplitude/duration/fps)→ 帧函数 apply(el, t, anim)
|
||||
* 返回该帧的变换 {tx, ty, rot, s, opacity}(tx/ty 为视口像素偏移,rot 度,s 缩放倍率,opacity 0-1)
|
||||
*
|
||||
* 设计要点:
|
||||
* - 预览(requestAnimationFrame 驱动)与导出(GIF/WebM 逐帧采样)走同一套 apply(),预览与产物一致
|
||||
* - 纯函数、零 DOM 依赖,Node 无头可测(确定性:同 t 同 anim 同 el 输出相同)
|
||||
* - AI 决策只输出「类型 + 参数」,系统负责渲染,保持 AI 驱动架构铁律
|
||||
*/
|
||||
const HGB_Motion = (function () {
|
||||
'use strict';
|
||||
|
||||
/** 动画类型注册表(apply 内部用 p 中的归一化参数 + 元素参考尺寸) */
|
||||
const ANIMS = {
|
||||
bounce: {
|
||||
id: 'bounce', name: '弹跳', emoji: '🏀',
|
||||
apply(t, p) {
|
||||
// 经典弹跳:前 55% 上升,后 45% 落回 + 压缩
|
||||
const up = Math.sin(Math.PI * Math.min(t * 1.82, 1));
|
||||
const squash = Math.max(0, Math.sin(Math.PI * (t - 0.55) / 0.45)) * 0.12;
|
||||
const h = (p.elH || 80) * 0.5 * p.amplitude;
|
||||
return { tx: 0, ty: -h * up, rot: 0, s: 1 - squash, opacity: 1 };
|
||||
}
|
||||
},
|
||||
pulse: {
|
||||
id: 'pulse', name: '脉冲', emoji: '💓',
|
||||
apply(t, p) {
|
||||
const s = 1 + 0.18 * p.amplitude * Math.sin(2 * Math.PI * t);
|
||||
return { tx: 0, ty: 0, rot: 0, s, opacity: 1 };
|
||||
}
|
||||
},
|
||||
wiggle: {
|
||||
id: 'wiggle', name: '摇摆', emoji: '🌀',
|
||||
apply(t, p) {
|
||||
return { tx: 0, ty: 0, rot: 10 * p.amplitude * Math.sin(2 * Math.PI * t), s: 1, opacity: 1 };
|
||||
}
|
||||
},
|
||||
float: {
|
||||
id: 'float', name: '漂浮', emoji: '🎈',
|
||||
apply(t, p) {
|
||||
const h = (p.elH || 80) * 0.14 * p.amplitude;
|
||||
return { tx: 0, ty: h * Math.sin(2 * Math.PI * t), rot: 4 * p.amplitude * Math.sin(2 * Math.PI * t + 1), s: 1, opacity: 1 };
|
||||
}
|
||||
},
|
||||
spin: {
|
||||
id: 'spin', name: '旋转', emoji: '🔄',
|
||||
apply(t, p) {
|
||||
return { tx: 0, ty: 0, rot: 360 * p.amplitude * t, s: 1, opacity: 1 };
|
||||
}
|
||||
},
|
||||
fade: {
|
||||
id: 'fade', name: '闪烁', emoji: '✨',
|
||||
apply(t, p) {
|
||||
// 呼吸式闪烁(平滑余弦,不完全消失)
|
||||
return { tx: 0, ty: 0, rot: 0, s: 1, opacity: 0.4 + 0.6 * (0.5 + 0.5 * Math.cos(2 * Math.PI * t)) };
|
||||
}
|
||||
},
|
||||
twirl: {
|
||||
id: 'twirl', name: '旋摆', emoji: '🪁',
|
||||
apply(t, p) {
|
||||
return { tx: 0, ty: 0, rot: 30 * p.amplitude * Math.sin(2 * Math.PI * t) * Math.sin(2 * Math.PI * t * 0.5), s: 1, opacity: 1 };
|
||||
}
|
||||
},
|
||||
heartbeat: {
|
||||
id: 'heartbeat', name: '心跳', emoji: '💗',
|
||||
apply(t, p) {
|
||||
// 咚-咚 双脉冲节奏:每个周期两次快速放大(k² 平滑不突变)
|
||||
const k = Math.sin(Math.PI * ((t * 2) % 1));
|
||||
return { tx: 0, ty: 0, rot: 0, s: 1 + 0.22 * p.amplitude * k * k, opacity: 1 };
|
||||
}
|
||||
},
|
||||
shake: {
|
||||
id: 'shake', name: '抖动', emoji: '📳',
|
||||
apply(t, p) {
|
||||
// 高频小位移 + 轻微旋转(确定性正弦,非随机)
|
||||
const ph = t * 2 * Math.PI * 8;
|
||||
const a = 5 * p.amplitude;
|
||||
return { tx: a * Math.sin(ph), ty: a * 0.6 * Math.sin(ph * 1.37 + 1.2), rot: 2 * p.amplitude * Math.sin(ph * 0.7), s: 1, opacity: 1 };
|
||||
}
|
||||
},
|
||||
breath: {
|
||||
id: 'breath', name: '呼吸', emoji: '🫁',
|
||||
apply(t, p) {
|
||||
// 平滑低幅呼吸(比脉冲更柔和)
|
||||
return { tx: 0, ty: 0, rot: 0, s: 1 + 0.09 * p.amplitude * Math.sin(2 * Math.PI * t), opacity: 1 };
|
||||
}
|
||||
},
|
||||
hopspin: {
|
||||
id: 'hopspin', name: '弹转', emoji: '🤸',
|
||||
apply(t, p) {
|
||||
// 弹跳 + 半圈旋转(落地即回正)
|
||||
const up = Math.sin(Math.PI * Math.min(t * 1.82, 1));
|
||||
const h = (p.elH || 80) * 0.5 * p.amplitude;
|
||||
return { tx: 0, ty: -h * up, rot: 180 * p.amplitude * Math.min(t * 1.82, 1), s: 1, opacity: 1 };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ORDER = Object.keys(ANIMS);
|
||||
|
||||
function clamp(v, lo, hi, dft) {
|
||||
const n = Number(v);
|
||||
return (isFinite(n) && n >= lo && n <= hi) ? n : dft;
|
||||
}
|
||||
|
||||
/** 归一化动画参数(补全默认值) */
|
||||
function normalize(anim) {
|
||||
const a = anim || {};
|
||||
const def = ANIMS[a.type] || ANIMS.bounce;
|
||||
return {
|
||||
type: def.id,
|
||||
speed: clamp(a.speed, 0.5, 4, 1), // 周期数(duration 内循环几次)
|
||||
amplitude: clamp(a.amplitude, 0.3, 2, 1), // 强度倍率
|
||||
duration: clamp(a.duration, 0.4, 4, 1.2), // 时长(秒)
|
||||
fps: Math.round(clamp(a.fps, 4, 24, 12)),
|
||||
layered: !!a.layered, // 元素级分层(各元素错峰动,表情更活)
|
||||
layerSpread: clamp(a.layerSpread, 0, 0.6, 0.3) // 分层错峰比例(0=同步)
|
||||
};
|
||||
}
|
||||
|
||||
/** 总帧数 */
|
||||
function frameCount(anim) {
|
||||
const a = normalize(anim);
|
||||
return Math.max(2, Math.round(a.duration * a.fps));
|
||||
}
|
||||
|
||||
/** 第 i 帧的归一化时间 t(含速度:speed 个周期) */
|
||||
function frameT(i, anim) {
|
||||
const a = normalize(anim);
|
||||
const n = frameCount(a);
|
||||
return ((i / n) * a.speed) % 1;
|
||||
}
|
||||
|
||||
/** 预览时间(now 毫秒时间戳 → t) */
|
||||
function previewT(now, anim) {
|
||||
const a = normalize(anim);
|
||||
const period = a.duration / a.speed; // 单周期秒数
|
||||
return period > 0 ? ((now / 1000) % period) / period : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 元素级时间(分层动画):各元素错峰偏移,让表情的多个图形"各自动"而非整图同步
|
||||
* @param {number} t 全局时间 [0,1)
|
||||
* @param {object} anim 动画参数(需含 layered/layerSpread)
|
||||
* @param {number} i 元素索引
|
||||
* @param {number} n 元素总数
|
||||
* @returns {number} 该元素的局部时间 [0,1)
|
||||
*/
|
||||
function elementT(t, anim, i, n) {
|
||||
const a = normalize(anim);
|
||||
if (!a.layered || n <= 1) return t;
|
||||
return (t + a.layerSpread * (i / Math.max(1, n - 1))) % 1;
|
||||
}
|
||||
|
||||
/** 计算某元素某帧的变换(el 需含 x/y/w/h) */
|
||||
function apply(el, t, anim) {
|
||||
const a = normalize(anim);
|
||||
const def = ANIMS[a.type] || ANIMS.pulse;
|
||||
const f = def.apply(t, { amplitude: a.amplitude, elH: (el && el.h) || 80, elW: (el && el.w) || 80 }) || {};
|
||||
return {
|
||||
tx: f.tx || 0,
|
||||
ty: f.ty || 0,
|
||||
rot: f.rot || 0,
|
||||
s: (f.s != null && isFinite(f.s)) ? f.s : 1,
|
||||
opacity: (f.opacity != null && isFinite(f.opacity)) ? clamp(f.opacity, 0, 1, 1) : 1
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 合成元素最终 transform 字符串(在元素原始 transform 之上叠加动画变换)
|
||||
* @param {object} el 元素(x/y/w/h)
|
||||
* @param {object} frame apply() 返回值
|
||||
* @param {string} base 元素原始 transform(如 buildSvgFrom 输出的 translate(x y) scale(sx sy))
|
||||
*/
|
||||
function composeTransform(el, frame, base) {
|
||||
const cx = el.x + el.w / 2, cy = el.y + el.h / 2;
|
||||
const r = frame.rot ? trimNum(frame.rot) + '' : '';
|
||||
const s = (frame.s !== 1) ? ' scale(' + trimNum(frame.s) + ')' : '';
|
||||
const rot = r ? ' rotate(' + r + ')' : '';
|
||||
// 变换顺序(右→左执行):base(原始定位)→ 回中缩放 → 旋转 → 平移
|
||||
return 'translate(' + trimNum(frame.tx) + ' ' + trimNum(frame.ty) + ')' +
|
||||
' translate(' + trimNum(cx) + ' ' + trimNum(cy) + ')' + rot + s +
|
||||
' translate(' + trimNum(-cx) + ' ' + trimNum(-cy) + ')' +
|
||||
(base ? ' ' + base : '');
|
||||
}
|
||||
|
||||
/** 数字去尾零(0 → '0',禁止 toFixed 产生 0.00) */
|
||||
function trimNum(n) {
|
||||
if (!isFinite(n)) n = 0;
|
||||
return (Math.round(n * 10000) / 10000).toString();
|
||||
}
|
||||
|
||||
/** 确定性分配:按索引轮换动画类型(AI 缺失时降级,保证每批动画多样) */
|
||||
function pickAnim(i) {
|
||||
return normalize({ type: ORDER[i % ORDER.length], speed: 1, amplitude: 1 });
|
||||
}
|
||||
|
||||
/** 随机分配(用户点「换动画」) */
|
||||
function randomAnim() {
|
||||
const a = pickAnim(Math.floor(Math.random() * ORDER.length));
|
||||
a.speed = [0.75, 1, 1.25][Math.floor(Math.random() * 3)];
|
||||
return a;
|
||||
}
|
||||
|
||||
/** 全部动画类型列表 [{id,name,emoji}] */
|
||||
function animTypes() {
|
||||
return ORDER.map(id => ({ id, name: ANIMS[id].name, emoji: ANIMS[id].emoji }));
|
||||
}
|
||||
|
||||
function get(id) { return ANIMS[id] || ANIMS.pulse; }
|
||||
|
||||
return {
|
||||
ANIMS, ORDER, normalize, frameCount, frameT, previewT, elementT,
|
||||
apply, composeTransform, pickAnim, randomAnim, animTypes, get
|
||||
};
|
||||
})();
|
||||
|
||||
// Node 无头冒烟自检(浏览器同样执行,幂等)
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = HGB_Motion;
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 画个标 · 海报 AI 设计器:DeepSeek 输出设计决策 JSON,系统校验后映射为元素方案
|
||||
* 复用 HGB_AI.chat;白名单:版式模板(poster-generator.LAYOUTS)+ 配色(poster-generator.PALETTES)
|
||||
* AI 输出 JSON:{ overallConcept, plans:[{ title, subtitle, paletteId, layoutId, concept }] }
|
||||
* AI 失败由调用方降级为内置引擎(defaultDecision 随机组合),保证页面不空白。
|
||||
*/
|
||||
const HGB_PosterAI = (function () {
|
||||
'use strict';
|
||||
|
||||
function buildPrompt(opt) {
|
||||
const layoutList = HGB_PosterGenerator.LAYOUTS.map(l => `${l.id}:${l.name}`).join('\n');
|
||||
const paletteList = HGB_PosterGenerator.PALETTES.map(p => `${p.id}:${p.name}(背景 ${p.bg})`).join('\n');
|
||||
const sys = `你是顶级的海报设计师。请基于用户提供的主题信息,从白名单中选择版式与配色,设计多套专业海报方案。
|
||||
|
||||
设计原则:
|
||||
1. 标题(title)精炼有力、突出主题;副标题(subtitle)补充说明(可空)。
|
||||
2. 版式 layoutId 与配色 paletteId 必须从白名单中选择,不得编造。
|
||||
3. 多套方案尽量使用不同的版式与配色,呈现不同设计方向。
|
||||
4. 每套方案给出贴切的中文方案名(name)与设计理念(concept)。
|
||||
|
||||
只输出 JSON,不要输出任何其他文字。JSON 结构:
|
||||
{ "overallConcept": "整体理念", "plans": [ { "name": "方案名", "title": "主标题", "subtitle": "副标题(可空)", "paletteId": "indigo", "layoutId": "centered", "concept": "设计理念" } ] }
|
||||
其中 plans 数组包含 ${opt.count || 5} 套方案。`;
|
||||
const user = `【主题信息】
|
||||
- 行业/主题:${opt.industry || '通用'}
|
||||
- 主标题:${opt.title || '(由你拟定)'}
|
||||
- 副标题:${opt.sub || '(无)'}
|
||||
- 描述:${opt.desc || '(无)'}
|
||||
|
||||
【版式白名单】
|
||||
${layoutList}
|
||||
|
||||
【配色白名单】
|
||||
${paletteList}
|
||||
|
||||
请设计 ${opt.count || 5} 套海报方案。`;
|
||||
return [
|
||||
{ role: 'system', content: sys },
|
||||
{ role: 'user', content: user }
|
||||
];
|
||||
}
|
||||
|
||||
function extractJson(content) {
|
||||
let t = String(content || '').trim();
|
||||
t = t.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '');
|
||||
const s = t.indexOf('{'), e = t.lastIndexOf('}');
|
||||
if (s < 0 || e < s) throw new Error('AI 输出不是有效 JSON');
|
||||
return JSON.parse(t.slice(s, e + 1));
|
||||
}
|
||||
|
||||
/** 校验并规范化 AI 输出 */
|
||||
function validate(data, opt) {
|
||||
if (!data || !Array.isArray(data.plans) || !data.plans.length) throw new Error('AI 未返回方案');
|
||||
for (const p of data.plans) {
|
||||
if (!HGB_PosterGenerator.PALETTES.some(x => x.id === p.paletteId)) p._paletteInvalid = true;
|
||||
if (!HGB_PosterGenerator.LAYOUTS.some(x => x.id === p.layoutId)) p._layoutInvalid = true;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 决策 → 元素方案(非法字段回退默认,不随机) */
|
||||
function mapToPlan(spec, p, idx) {
|
||||
const decision = HGB_PosterGenerator.defaultDecision(p.title, p.subtitle);
|
||||
if (!p._paletteInvalid) decision.paletteId = p.paletteId;
|
||||
if (!p._layoutInvalid) decision.layoutId = p.layoutId;
|
||||
const elements = HGB_PosterGenerator.generate(spec, decision);
|
||||
return {
|
||||
id: 'poster_' + Date.now().toString(36) + idx.toString(36),
|
||||
name: (p.name || '').trim() || ('方案 ' + (idx + 1)),
|
||||
concept: (p.concept || '').trim(),
|
||||
elements,
|
||||
specId: spec.id,
|
||||
ai: true
|
||||
};
|
||||
}
|
||||
|
||||
async function generatePostersAI(opt) {
|
||||
const spec = HGB_PosterStore.spec();
|
||||
const messages = buildPrompt(opt);
|
||||
const content = await HGB_AI.chat(messages, { json: true, maxTokens: 4096 });
|
||||
const data = validate(extractJson(content), opt);
|
||||
const plans = data.plans.map((p, i) => mapToPlan(spec, p, i));
|
||||
return { plans, overallConcept: (data.overallConcept || '').trim(), raw: content };
|
||||
}
|
||||
|
||||
return { generatePostersAI, buildPrompt, extractJson, validate, mapToPlan };
|
||||
})();
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 画个标 · 海报导出:SVG / PNG(按画布规格导出)
|
||||
*/
|
||||
const HGB_PosterExporter = (function () {
|
||||
'use strict';
|
||||
|
||||
function buildSvg(spec, elements) {
|
||||
return HGB_PosterRenderer.render(spec, elements);
|
||||
}
|
||||
|
||||
function download(name, blob) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 0);
|
||||
}
|
||||
|
||||
function exportSvg(spec, elements, base) {
|
||||
const svg = buildSvg(spec, elements);
|
||||
download((base || '海报') + '.svg', new Blob([svg], { type: 'image/svg+xml' }));
|
||||
}
|
||||
|
||||
/** 渲染 SVG 到位图并下载(scale 控制放大倍数,默认 1) */
|
||||
function svgToCanvas(spec, elements, scale) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const svg = buildSvg(spec, elements);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const w = Math.round(spec.w * scale), h = Math.round(spec.h * scale);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
resolve(c);
|
||||
};
|
||||
img.onerror = () => reject(new Error('SVG 渲染失败'));
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
});
|
||||
}
|
||||
|
||||
async function exportPng(spec, elements, base, scale) {
|
||||
const c = await svgToCanvas(spec, elements, scale || 1);
|
||||
const blob = await new Promise(res => c.toBlob(res, 'image/png'));
|
||||
download((base || '海报') + '.png', blob);
|
||||
}
|
||||
|
||||
return { buildSvg, exportSvg, exportPng, svgToCanvas };
|
||||
})();
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 画个标 · 海报生成引擎:版式模板 + 配色 + 元素构造
|
||||
* 决策(paletteId/layoutId/title/sub/icon)→ 元素数组,供 renderer 渲染。
|
||||
*/
|
||||
const HGB_PosterGenerator = (function () {
|
||||
'use strict';
|
||||
|
||||
const PALETTES = [
|
||||
{ id: 'indigo', name: '靛蓝', bg: '#1e1b4b', primary: '#ffffff', accent: '#f59e0b' },
|
||||
{ id: 'crimson', name: '深红', bg: '#7f1d1d', primary: '#ffffff', accent: '#fbbf24' },
|
||||
{ id: 'ocean', name: '海洋', bg: '#0c4a6e', primary: '#ffffff', accent: '#38bdf8' },
|
||||
{ id: 'forest', name: '森林', bg: '#14532d', primary: '#ffffff', accent: '#86efac' },
|
||||
{ id: 'minimal', name: '极简', bg: '#ffffff', primary: '#111827', accent: '#4f46e5' }
|
||||
];
|
||||
|
||||
function uid(p) { return p + '_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
||||
|
||||
function rect(id, x, y, w, h, fill) { return { id, type: 'rect', x, y, w, h, fill }; }
|
||||
function circle(id, cx, cy, r, fill) { return { id, type: 'circle', cx, cy, r, fill }; }
|
||||
function text(id, text, x, y, fontSize, fontWeight, fill, align) {
|
||||
return { id, type: 'text', text, x, y, fontSize, fontWeight, fill, align: align || 'start' };
|
||||
}
|
||||
|
||||
const LAYOUTS = [
|
||||
{
|
||||
id: 'centered', name: '居中对称', emoji: '🎯',
|
||||
build(spec, o) {
|
||||
const cw = spec.w, ch = spec.h, els = [];
|
||||
els.push(rect(uid('bg'), 0, 0, cw, ch, o.palette.bg));
|
||||
els.push(circle(uid('dot'), cw / 2, ch * 0.30, ch * 0.12, o.palette.accent));
|
||||
const fs = Math.round(cw * 0.075);
|
||||
els.push(text(uid('title'), o.title, cw / 2, ch * 0.50, fs, 800, o.palette.primary, 'middle'));
|
||||
if (o.sub) els.push(text(uid('sub'), o.sub, cw / 2, ch * 0.56, Math.round(fs * 0.38), 400, o.palette.primary, 'middle'));
|
||||
els.push(rect(uid('bar'), cw / 2 - cw * 0.12, ch * 0.62, cw * 0.24, 8, o.palette.accent));
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'left', name: '左对齐', emoji: '◀️',
|
||||
build(spec, o) {
|
||||
const cw = spec.w, ch = spec.h, els = [];
|
||||
els.push(rect(uid('bg'), 0, 0, cw, ch, o.palette.bg));
|
||||
els.push(rect(uid('side'), 0, 0, cw * 0.06, ch, o.palette.accent));
|
||||
const mx = cw * 0.13;
|
||||
const fs = Math.round(cw * 0.08);
|
||||
els.push(text(uid('title'), o.title, mx, ch * 0.44, fs, 800, o.palette.primary, 'start'));
|
||||
if (o.sub) els.push(text(uid('sub'), o.sub, mx, ch * 0.52, Math.round(fs * 0.36), 400, o.palette.primary, 'start'));
|
||||
els.push(rect(uid('bar'), mx, ch * 0.58, cw * 0.18, 8, o.palette.accent));
|
||||
return els;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'banner', name: '顶部横幅', emoji: '📢',
|
||||
build(spec, o) {
|
||||
const cw = spec.w, ch = spec.h, els = [];
|
||||
els.push(rect(uid('bg'), 0, 0, cw, ch, o.palette.bg));
|
||||
els.push(rect(uid('band'), 0, ch * 0.18, cw, ch * 0.16, o.palette.accent));
|
||||
const fs = Math.round(cw * 0.07);
|
||||
els.push(text(uid('title'), o.title, cw / 2, ch * 0.26, fs, 800, o.palette.bg, 'middle'));
|
||||
if (o.sub) els.push(text(uid('sub'), o.sub, cw / 2, ch * 0.55, Math.round(fs * 0.42), 400, o.palette.primary, 'middle'));
|
||||
return els;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/** 决策 → 元素数组 */
|
||||
function generate(spec, decision) {
|
||||
const palette = PALETTES.find(p => p.id === decision.paletteId) || PALETTES[0];
|
||||
const layout = LAYOUTS.find(l => l.id === decision.layoutId) || LAYOUTS[0];
|
||||
const o = { palette, title: decision.title || '海报标题', sub: decision.sub || '' };
|
||||
return layout.build(spec, o);
|
||||
}
|
||||
|
||||
function defaultDecision(title, sub) {
|
||||
return { paletteId: 'indigo', layoutId: 'centered', title: title || '海报标题', sub: sub || '' };
|
||||
}
|
||||
|
||||
return { PALETTES, LAYOUTS, generate, defaultDecision };
|
||||
})();
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 画个标 · 海报渲染引擎:元素数组 → SVG 字符串
|
||||
*/
|
||||
const HGB_PosterRenderer = (function () {
|
||||
'use strict';
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function elToSvg(el) {
|
||||
if (el.type === 'rect') {
|
||||
return `<rect x="${el.x}" y="${el.y}" width="${el.w}" height="${el.h}" fill="${el.fill}"/>`;
|
||||
}
|
||||
if (el.type === 'circle') {
|
||||
return `<circle cx="${el.cx}" cy="${el.cy}" r="${el.r}" fill="${el.fill}"/>`;
|
||||
}
|
||||
if (el.type === 'text') {
|
||||
const anchor = el.align === 'middle' ? 'middle' : 'start';
|
||||
return `<text x="${el.x}" y="${el.y}" font-family="SimHei, Microsoft YaHei, sans-serif" font-size="${el.fontSize}" font-weight="${el.fontWeight}" fill="${el.fill}" text-anchor="${anchor}" dominant-baseline="middle">${esc(el.text)}</text>`;
|
||||
}
|
||||
if (el.type === 'icon') {
|
||||
return `<g transform="translate(${el.x} ${el.y}) scale(${el.w / 24})">${el.svg}</g>`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function render(spec, elements) {
|
||||
const body = (elements || []).map(elToSvg).join('');
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${spec.w} ${spec.h}" width="${spec.w}" height="${spec.h}">${body}</svg>`;
|
||||
}
|
||||
|
||||
return { render };
|
||||
})();
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 画个标 · 海报工作台:状态与画布规格
|
||||
* 海报与 LOGO/头像的差异:画布多规格(竖版/方形/横版/长图),保留原始比例,不归一化。
|
||||
*/
|
||||
const HGB_PosterStore = (function () {
|
||||
'use strict';
|
||||
|
||||
/** 画布规格(逻辑像素,按 96dpi 近似) */
|
||||
const SPECS = [
|
||||
{ id: 'poster', name: '竖版海报', w: 900, h: 1200 },
|
||||
{ id: 'square', name: '方形', w: 1000, h: 1000 },
|
||||
{ id: 'wide', name: '横版横幅 16:9', w: 1200, h: 675 },
|
||||
{ id: 'story', name: '竖版长图 9:16', w: 675, h: 1200 }
|
||||
];
|
||||
|
||||
let state = {
|
||||
specId: 'poster',
|
||||
title: '',
|
||||
sub: '',
|
||||
desc: '',
|
||||
plans: [],
|
||||
activePlan: null
|
||||
};
|
||||
|
||||
function get() { return state; }
|
||||
function set(patch) { state = Object.assign({}, state, patch); }
|
||||
function spec() { return SPECS.find(s => s.id === state.specId) || SPECS[0]; }
|
||||
|
||||
return { SPECS, get, set, spec };
|
||||
})();
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 画个标 · 海报素材库加载器(懒加载)
|
||||
* 素材库由 scripts/build-posters.js 构建拆分为:
|
||||
* - js/data/poster-builtin.js 轻量索引(sources 元数据 + 分类索引,无全量内容)
|
||||
* - js/data/posters/{id}.js 每源一个数据文件(HGB_PosterSource_{id} = {items:[...]})
|
||||
* 本模块负责:按源懒加载 + 缓存 + 同步读已加载源 + 预加载核心源
|
||||
*
|
||||
* 与图标素材库(iconlib.js)的差异:
|
||||
* - 海报素材为全彩/位图/模板,保留原始宽高比,渲染不再归一化到 24 视口
|
||||
* - items 字段而非 icons;每条含 type/category/categoryCn/workspaces 归类标签
|
||||
*/
|
||||
const HGB_PosterLib = (function () {
|
||||
'use strict';
|
||||
|
||||
const VERSION = '20260823e'; // 与 poster.html 中 poster-builtin.js?v= 版本联动
|
||||
|
||||
const cache = {}; // sid -> {items:[...]}
|
||||
const pending = {}; // sid -> Promise
|
||||
|
||||
function fileURL(sid) {
|
||||
return 'js/data/posters/' + sid + '.js?v=' + VERSION;
|
||||
}
|
||||
|
||||
function loadScript(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.onload = resolve;
|
||||
s.onerror = function () { reject(new Error('海报素材源加载失败: ' + url)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
/** 加载单个源(缓存 + 并发去重) */
|
||||
function loadSource(sid) {
|
||||
if (cache[sid]) return Promise.resolve(cache[sid]);
|
||||
if (pending[sid]) return pending[sid];
|
||||
pending[sid] = loadScript(fileURL(sid)).then(() => {
|
||||
const data = window['HGB_PosterSource_' + sid];
|
||||
if (!data || !data.items) throw new Error('海报素材源不存在: ' + sid);
|
||||
cache[sid] = data;
|
||||
delete pending[sid];
|
||||
return data;
|
||||
}).catch((e) => {
|
||||
delete pending[sid];
|
||||
throw e;
|
||||
});
|
||||
return pending[sid];
|
||||
}
|
||||
|
||||
/** 并行加载多个源(全部成功才 resolve) */
|
||||
function loadSources(ids) {
|
||||
return Promise.all((ids || []).map(loadSource));
|
||||
}
|
||||
|
||||
/** 并行加载多个源(容忍单个失败,返回已成功加载的) */
|
||||
function preload(ids) {
|
||||
return Promise.allSettled((ids || []).map(loadSource)).then((rs) =>
|
||||
rs.filter(r => r.status === 'fulfilled').map(r => r.value));
|
||||
}
|
||||
|
||||
/** 加载全部源 */
|
||||
function loadAll() {
|
||||
const ids = Object.keys((typeof HGB_PosterBuiltin !== 'undefined' ? HGB_PosterBuiltin.sources : {}) || {});
|
||||
return preload(ids);
|
||||
}
|
||||
|
||||
/** 同步读取已加载源(未加载返回 null) */
|
||||
function getSource(sid) {
|
||||
return cache[sid] || null;
|
||||
}
|
||||
|
||||
/** 是否已加载 */
|
||||
function loaded(sid) {
|
||||
return !!cache[sid];
|
||||
}
|
||||
|
||||
/** 已加载的全部源 id */
|
||||
function loadedIds() {
|
||||
return Object.keys(cache);
|
||||
}
|
||||
|
||||
/** 从已加载源按 名称/id 找素材(返回 {id,name,svg,type,...} 或 null) */
|
||||
function findPoster(ref) {
|
||||
if (!ref || typeof ref !== 'string') return null;
|
||||
const name = ref.trim();
|
||||
const parts = name.split(':');
|
||||
if (parts.length === 2) {
|
||||
const src = cache[parts[0]];
|
||||
const hit = src && (src.items || []).find(i => i.name === parts[1] || i.id === name);
|
||||
if (hit) return hit;
|
||||
}
|
||||
const lower = name.toLowerCase();
|
||||
for (const sid of Object.keys(cache)) {
|
||||
const hit = (cache[sid].items || []).find(i =>
|
||||
i.id === name || i.name === name ||
|
||||
String(i.name).toLowerCase() === lower ||
|
||||
String(i.name).toLowerCase().includes(lower));
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 搜索已加载源(词根包含匹配),返回完整素材对象 */
|
||||
function searchLoaded(keyword, limit) {
|
||||
const kw = String(keyword || '').toLowerCase();
|
||||
const out = [];
|
||||
for (const sid of Object.keys(cache)) {
|
||||
for (const it of cache[sid].items || []) {
|
||||
if (it.name && it.name.toLowerCase().includes(kw)) {
|
||||
out.push({ id: sid + ':' + it.name, name: it.name, svg: it.svg, source: sid, type: it.type });
|
||||
if (out.length >= (limit || 60)) return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 搜索索引(全量 id/name,无内容,用于筛选展示) */
|
||||
function searchIndex(keyword, limit) {
|
||||
const kw = String(keyword || '').toLowerCase();
|
||||
const out = [];
|
||||
const idx = (typeof HGB_PosterBuiltin !== 'undefined' && HGB_PosterBuiltin.itemIndex) || [];
|
||||
for (const it of idx) {
|
||||
if (it.name && it.name.toLowerCase().includes(kw)) {
|
||||
out.push({ id: it.id, name: it.name, source: String(it.id).split(':')[0], category: it.category });
|
||||
if (out.length >= (limit || 20)) return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按 id 反查来源 id */
|
||||
function sourceOf(fullId) {
|
||||
return String(fullId || '').split(':')[0];
|
||||
}
|
||||
|
||||
return {
|
||||
VERSION, loadSource, loadSources, preload, loadAll,
|
||||
getSource, loaded, loadedIds, findPoster, searchLoaded, searchIndex, sourceOf
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 画个标 · 核心:SVG 渲染引擎
|
||||
* 职责:将 store 中的元素渲染到画布 SVG,绘制选中框/手柄,支持缩放显示
|
||||
*/
|
||||
const HGB_Renderer = (function () {
|
||||
'use strict';
|
||||
|
||||
let svg, defs, contentLayer, overlayLayer;
|
||||
let zoom = 1; // 当前缩放
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
const MIN_ZOOM = 0.2, MAX_ZOOM = 4;
|
||||
|
||||
/* ---------- 初始化 ---------- */
|
||||
function init() {
|
||||
svg = document.getElementById('logo-canvas');
|
||||
// 清空并重建分层
|
||||
svg.innerHTML = '';
|
||||
defs = svgCreate('defs');
|
||||
svg.appendChild(defs);
|
||||
|
||||
contentLayer = svgCreate('g');
|
||||
contentLayer.setAttribute('class', 'hgb-content');
|
||||
svg.appendChild(contentLayer);
|
||||
|
||||
overlayLayer = svgCreate('g');
|
||||
overlayLayer.setAttribute('class', 'hgb-overlay');
|
||||
overlayLayer.setAttribute('pointer-events', 'none');
|
||||
svg.appendChild(overlayLayer);
|
||||
|
||||
// 背景
|
||||
const bgRect = svgCreate('rect');
|
||||
bgRect.setAttribute('x', 0); bgRect.setAttribute('y', 0);
|
||||
bgRect.setAttribute('width', HGB_Store.CANVAS_W);
|
||||
bgRect.setAttribute('height', HGB_Store.CANVAS_H);
|
||||
bgRect.setAttribute('fill', HGB_Store.get().bg);
|
||||
bgRect.setAttribute('data-hgb-bg', '1');
|
||||
contentLayer.appendChild(bgRect);
|
||||
}
|
||||
|
||||
function svgCreate(tag) {
|
||||
return document.createElementNS(SVG_NS, tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成圆弧路径 d(环形文字用)
|
||||
* arc = { cx, cy, r, start, sweep }:圆心 / 半径 / 起始角度(度,0=右) / 扫过角度(度,顺时针)
|
||||
*/
|
||||
function arcPathD(arc) {
|
||||
const a1 = arc.start * Math.PI / 180;
|
||||
const a2 = (arc.start + arc.sweep) * Math.PI / 180;
|
||||
const x1 = arc.cx + arc.r * Math.cos(a1);
|
||||
const y1 = arc.cy + arc.r * Math.sin(a1);
|
||||
const x2 = arc.cx + arc.r * Math.cos(a2);
|
||||
const y2 = arc.cy + arc.r * Math.sin(a2);
|
||||
const large = Math.abs(arc.sweep) > 180 ? 1 : 0;
|
||||
const sweepFlag = arc.sweep < 0 ? 0 : 1; // sweep-flag=0 逆时针(y向下坐标系中负角走下半弧)
|
||||
return 'M ' + x1.toFixed(2) + ' ' + y1.toFixed(2) +
|
||||
' A ' + arc.r + ' ' + arc.r + ' 0 ' + large + ' ' + sweepFlag + ' ' + x2.toFixed(2) + ' ' + y2.toFixed(2);
|
||||
}
|
||||
|
||||
/* ---------- 主渲染 ---------- */
|
||||
function render() {
|
||||
const s = HGB_Store.get();
|
||||
init(); // 简单起见全量重绘
|
||||
|
||||
// 背景色同步到 input
|
||||
const bgInput = document.getElementById('canvas-bg');
|
||||
if (bgInput && bgInput.value !== s.bg) bgInput.value = s.bg;
|
||||
|
||||
// 绘制元素(按数组顺序,后画的在上层)
|
||||
for (const el of s.elements) {
|
||||
if (!el.visible) continue;
|
||||
contentLayer.appendChild(renderElement(el));
|
||||
}
|
||||
|
||||
// 选中框与手柄(overlay 层)
|
||||
const selIds = s.selectedIds || (s.selectedId ? [s.selectedId] : []);
|
||||
for (const sid of selIds) {
|
||||
const el = s.elements.find(e => e.id === sid);
|
||||
if (!el || !el.visible) continue;
|
||||
drawSelection(el, sid === s.selectedId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染单个元素为 SVG 节点 */
|
||||
function renderElement(el) {
|
||||
let node;
|
||||
if (el.type === 'icon') {
|
||||
node = svgCreate('g');
|
||||
node.innerHTML = el.svg;
|
||||
// 应用图标颜色
|
||||
node.querySelectorAll('path,circle,rect,polygon,polyline,line,ellipse').forEach(p => {
|
||||
if (el.fill && el.fill !== 'none') p.setAttribute('fill', el.fill);
|
||||
else p.setAttribute('fill', 'none');
|
||||
if (el.stroke && el.stroke !== 'none') {
|
||||
p.setAttribute('stroke', el.stroke);
|
||||
p.setAttribute('stroke-width', el.strokeWidth);
|
||||
p.setAttribute('stroke-linecap', 'round');
|
||||
p.setAttribute('stroke-linejoin', 'round');
|
||||
} else {
|
||||
p.setAttribute('stroke', 'none');
|
||||
}
|
||||
});
|
||||
} else if (el.type === 'text') {
|
||||
node = svgCreate('text');
|
||||
node.setAttribute('text-anchor', 'middle');
|
||||
node.setAttribute('dominant-baseline', 'central');
|
||||
node.setAttribute('font-family', el.fontFamily || 'sans-serif');
|
||||
node.setAttribute('font-size', el.fontSize || 40);
|
||||
node.setAttribute('font-weight', el.fontWeight || 400);
|
||||
if (el.letterSpacing) node.setAttribute('letter-spacing', el.letterSpacing);
|
||||
node.setAttribute('fill', el.fill || '#000');
|
||||
if (el.stroke && el.stroke !== 'none') {
|
||||
node.setAttribute('stroke', el.stroke);
|
||||
node.setAttribute('stroke-width', el.strokeWidth);
|
||||
} else {
|
||||
node.setAttribute('stroke', 'none');
|
||||
}
|
||||
if (el.arc) {
|
||||
// 环形文字:沿圆弧路径排列(品牌名环绕 mark 的排版能力)
|
||||
const pathId = 'arc_' + el.id;
|
||||
const p = svgCreate('path');
|
||||
p.setAttribute('id', pathId);
|
||||
p.setAttribute('d', arcPathD(el.arc));
|
||||
p.setAttribute('fill', 'none');
|
||||
p.setAttribute('stroke', 'none');
|
||||
defs.appendChild(p);
|
||||
const tp = svgCreate('textPath');
|
||||
tp.setAttribute('href', '#' + pathId);
|
||||
// 居中锚定:text-anchor=middle 配合 startOffset=50%,文字中心落在弧线中点
|
||||
tp.setAttribute('startOffset', '50%');
|
||||
tp.textContent = el.text || '';
|
||||
node.appendChild(tp);
|
||||
node.removeAttribute('x');
|
||||
node.removeAttribute('y');
|
||||
} else {
|
||||
node.setAttribute('x', el.x + el.w / 2);
|
||||
node.setAttribute('y', el.y + el.h / 2);
|
||||
node.textContent = el.text || '';
|
||||
}
|
||||
} else {
|
||||
// 预留:其他类型(图片等)
|
||||
node = svgCreate('g');
|
||||
}
|
||||
|
||||
// 通用变换:位置 + 旋转
|
||||
const cx = el.x + el.w / 2;
|
||||
const cy = el.y + el.h / 2;
|
||||
if (el.rotation) {
|
||||
node.setAttribute('transform',
|
||||
`translate(${cx} ${cy}) rotate(${el.rotation}) translate(${-cx} ${-cy})`);
|
||||
}
|
||||
node.setAttribute('opacity', el.opacity != null ? el.opacity : 1);
|
||||
|
||||
// 元素定位
|
||||
node.setAttribute('data-id', el.id);
|
||||
node.setAttribute('data-type', el.type);
|
||||
if (el.type === 'icon') {
|
||||
node.setAttribute('data-icon', el.iconId || '');
|
||||
}
|
||||
node.classList.add('hgb-elem');
|
||||
const s = HGB_Store.get();
|
||||
const selIds = s.selectedIds || (s.selectedId ? [s.selectedId] : []);
|
||||
if (selIds.includes(el.id)) node.classList.add('selected');
|
||||
if (el.locked) node.classList.add('locked');
|
||||
|
||||
// 命中测试基于数据层(store),无需 DOM 命中框
|
||||
node._el = el;
|
||||
return node;
|
||||
}
|
||||
|
||||
/* ---------- 选中框与手柄 ---------- */
|
||||
function drawSelection(el, withHandles) {
|
||||
const box = svgCreate('rect');
|
||||
box.setAttribute('class', 'hgb-selection' + (withHandles ? '' : ' multi'));
|
||||
box.setAttribute('x', el.x); box.setAttribute('y', el.y);
|
||||
box.setAttribute('width', el.w); box.setAttribute('height', el.h);
|
||||
overlayLayer.appendChild(box);
|
||||
|
||||
if (!withHandles) return; // 多选元素仅显示虚线框
|
||||
|
||||
// 8 个缩放手柄 + 1 个旋转手柄
|
||||
const handles = [
|
||||
{ cx: el.x, cy: el.y, cursor: 'nwse-resize', action: 'nw' },
|
||||
{ cx: el.x + el.w / 2, cy: el.y, cursor: 'ns-resize', action: 'n' },
|
||||
{ cx: el.x + el.w, cy: el.y, cursor: 'nesw-resize', action: 'ne' },
|
||||
{ cx: el.x + el.w, cy: el.y + el.h / 2, cursor: 'ew-resize', action: 'e' },
|
||||
{ cx: el.x + el.w, cy: el.y + el.h, cursor: 'nwse-resize', action: 'se' },
|
||||
{ cx: el.x + el.w / 2, cy: el.y + el.h, cursor: 'ns-resize', action: 's' },
|
||||
{ cx: el.x, cy: el.y + el.h, cursor: 'nesw-resize', action: 'sw' },
|
||||
{ cx: el.x, cy: el.y + el.h / 2, cursor: 'ew-resize', action: 'w' }
|
||||
];
|
||||
for (const h of handles) {
|
||||
const node = svgCreate('rect');
|
||||
node.setAttribute('class', 'hgb-handle');
|
||||
node.setAttribute('x', h.cx - 5); node.setAttribute('y', h.cy - 5);
|
||||
node.setAttribute('width', 10); node.setAttribute('height', 10);
|
||||
node.setAttribute('rx', 2);
|
||||
node.setAttribute('style', 'cursor:' + h.cursor);
|
||||
node.setAttribute('data-handle', h.action);
|
||||
node.setAttribute('pointer-events', 'all');
|
||||
overlayLayer.appendChild(node);
|
||||
}
|
||||
|
||||
// 旋转手柄
|
||||
const rx = el.x + el.w / 2;
|
||||
const ry = el.y - 28;
|
||||
const line = svgCreate('line');
|
||||
line.setAttribute('class', 'hgb-rotate-line');
|
||||
line.setAttribute('x1', rx); line.setAttribute('y1', el.y);
|
||||
line.setAttribute('x2', rx); line.setAttribute('y2', ry);
|
||||
overlayLayer.appendChild(line);
|
||||
|
||||
const rot = svgCreate('circle');
|
||||
rot.setAttribute('class', 'hgb-handle rotate');
|
||||
rot.setAttribute('cx', rx); rot.setAttribute('cy', ry);
|
||||
rot.setAttribute('r', 7);
|
||||
rot.setAttribute('data-handle', 'rotate');
|
||||
rot.setAttribute('pointer-events', 'all');
|
||||
overlayLayer.appendChild(rot);
|
||||
}
|
||||
|
||||
/* ---------- 缩放与显示 ---------- */
|
||||
function setZoom(z) {
|
||||
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
|
||||
applyZoom();
|
||||
return zoom;
|
||||
}
|
||||
function getZoom() { return zoom; }
|
||||
|
||||
function zoomIn() { return setZoom(zoom * 1.2); }
|
||||
function zoomOut() { return setZoom(zoom / 1.2); }
|
||||
|
||||
function fitToStage() {
|
||||
const stage = document.getElementById('canvas-stage');
|
||||
const size = Math.min(stage.clientWidth - 48, stage.clientHeight - 48);
|
||||
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, size / HGB_Store.CANVAS_W));
|
||||
applyZoom();
|
||||
return zoom;
|
||||
}
|
||||
|
||||
function applyZoom() {
|
||||
svg.style.width = (HGB_Store.CANVAS_W * zoom) + 'px';
|
||||
svg.style.height = (HGB_Store.CANVAS_H * zoom) + 'px';
|
||||
const lv = document.getElementById('zoom-level');
|
||||
if (lv) lv.textContent = Math.round(zoom * 100) + '%';
|
||||
}
|
||||
|
||||
/* ---------- 坐标转换(client -> svg user units) ---------- */
|
||||
function toSvgPoint(clientX, clientY) {
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = clientX; pt.y = clientY;
|
||||
return pt.matrixTransform(svg.getScreenCTM().inverse());
|
||||
}
|
||||
|
||||
/** 命中测试:返回最上层可交互元素 */
|
||||
function hitTest(clientX, clientY) {
|
||||
const s = HGB_Store.get();
|
||||
const p = toSvgPoint(clientX, clientY);
|
||||
// 从顶层往下找
|
||||
for (let i = s.elements.length - 1; i >= 0; i--) {
|
||||
const el = s.elements[i];
|
||||
if (!el.visible || el.locked) continue;
|
||||
if (p.x >= el.x && p.x <= el.x + el.w &&
|
||||
p.y >= el.y && p.y <= el.y + el.h) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- 画布状态 ---------- */
|
||||
function setCanvasBg(color) {
|
||||
const bgRect = svg.querySelector('[data-hgb-bg]');
|
||||
if (bgRect) bgRect.setAttribute('fill', color);
|
||||
}
|
||||
|
||||
return {
|
||||
init, render, renderElement,
|
||||
setZoom, getZoom, zoomIn, zoomOut, fitToStage,
|
||||
toSvgPoint, hitTest, setCanvasBg, svgCreate,
|
||||
get svg() { return svg; }
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* 画个标 · 核心:状态管理(Store)
|
||||
* 职责:维护设计数据、元素 CRUD、撤销/重做、事件订阅、版本快照
|
||||
*/
|
||||
const HGB_Store = (function () {
|
||||
'use strict';
|
||||
|
||||
const CANVAS_W = 600;
|
||||
const CANVAS_H = 600;
|
||||
const MAX_HISTORY = 60;
|
||||
const LS_KEY = 'hgb_design';
|
||||
const LS_VERSION_KEY = 'hgb_versions';
|
||||
|
||||
/* ---------- 默认项目 ---------- */
|
||||
function defaultProject() {
|
||||
return {
|
||||
name: '我的品牌',
|
||||
bg: '#FFFFFF',
|
||||
elements: [
|
||||
{
|
||||
id: uid(),
|
||||
type: 'icon',
|
||||
name: '学士帽',
|
||||
iconId: 'graduation-cap',
|
||||
svg: iconSvg('graduation-cap'),
|
||||
x: 210, y: 150, w: 180, h: 180,
|
||||
rotation: 0,
|
||||
fill: 'none',
|
||||
stroke: '#1B4F72',
|
||||
strokeWidth: 2,
|
||||
opacity: 1,
|
||||
visible: true, locked: false
|
||||
},
|
||||
{
|
||||
id: uid(),
|
||||
type: 'text',
|
||||
name: '品牌中文名',
|
||||
text: '远渡教育',
|
||||
x: 130, y: 360, w: 340, h: 64,
|
||||
rotation: 0,
|
||||
fontSize: 48,
|
||||
fontWeight: 700,
|
||||
fontFamily: 'SimHei',
|
||||
letterSpacing: 8,
|
||||
fill: '#1B4F72',
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
opacity: 1,
|
||||
visible: true, locked: false
|
||||
},
|
||||
{
|
||||
id: uid(),
|
||||
type: 'text',
|
||||
name: '品牌英文名',
|
||||
text: 'FAR CROSS EDUCATION',
|
||||
x: 130, y: 430, w: 340, h: 32,
|
||||
rotation: 0,
|
||||
fontSize: 18,
|
||||
fontWeight: 400,
|
||||
fontFamily: 'Georgia',
|
||||
letterSpacing: 4,
|
||||
fill: '#2980B9',
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
opacity: 1,
|
||||
visible: true, locked: false
|
||||
}
|
||||
],
|
||||
selectedId: null,
|
||||
selectedIds: [],
|
||||
currentStyle: null
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- 工具 ---------- */
|
||||
function uid() {
|
||||
return 'el_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||
}
|
||||
|
||||
function iconSvg(iconId) {
|
||||
// 行业模块图标 + 基础图形
|
||||
for (const ind of HGB_Industries) {
|
||||
const ic = (ind.icons || []).find(i => i.id === iconId);
|
||||
if (ic) return ic.svg;
|
||||
}
|
||||
const ic = HGB_Icons.shapes.find(i => i.id === iconId);
|
||||
return ic ? ic.svg : '';
|
||||
}
|
||||
|
||||
/* ---------- 状态 ---------- */
|
||||
let state = defaultProject();
|
||||
let history = [];
|
||||
let historyIdx = -1;
|
||||
let listeners = {};
|
||||
|
||||
/* ---------- 历史记录 ---------- */
|
||||
function snapshot() {
|
||||
return JSON.parse(JSON.stringify(state));
|
||||
}
|
||||
|
||||
function pushHistory() {
|
||||
// 移除 redo 分支
|
||||
history = history.slice(0, historyIdx + 1);
|
||||
history.push(snapshot());
|
||||
if (history.length > MAX_HISTORY) history.shift();
|
||||
historyIdx = history.length - 1;
|
||||
emit('history');
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (historyIdx <= 0) return false;
|
||||
historyIdx--;
|
||||
state = JSON.parse(JSON.stringify(history[historyIdx]));
|
||||
emit('change');
|
||||
return true;
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (historyIdx >= history.length - 1) return false;
|
||||
historyIdx++;
|
||||
state = JSON.parse(JSON.stringify(history[historyIdx]));
|
||||
emit('change');
|
||||
return true;
|
||||
}
|
||||
|
||||
function canUndo() { return historyIdx > 0; }
|
||||
function canRedo() { return historyIdx < history.length - 1; }
|
||||
|
||||
/* ---------- 元素操作 ---------- */
|
||||
function addElement(el) {
|
||||
state.elements.push(el);
|
||||
state.selectedId = el.id;
|
||||
pushHistory();
|
||||
emit('change');
|
||||
return el;
|
||||
}
|
||||
|
||||
function updateElement(id, patch, record = true) {
|
||||
const el = state.elements.find(e => e.id === id);
|
||||
if (!el) return;
|
||||
Object.assign(el, patch);
|
||||
if (record) pushHistory();
|
||||
emit('change');
|
||||
}
|
||||
|
||||
function removeElement(id) {
|
||||
state.elements = state.elements.filter(e => e.id !== id);
|
||||
if (state.selectedId === id) state.selectedId = null;
|
||||
pushHistory();
|
||||
emit('change');
|
||||
}
|
||||
|
||||
/** 载入智能方案(替换全部元素与背景) */
|
||||
function loadPlan(plan) {
|
||||
state.elements = (plan.elements || []).map(e => ({ ...e }));
|
||||
state.bg = plan.bg || '#FFFFFF';
|
||||
state.selectedId = null;
|
||||
pushHistory();
|
||||
emit('change');
|
||||
}
|
||||
|
||||
function duplicateElement(id) {
|
||||
const src = state.elements.find(e => e.id === id);
|
||||
if (!src) return null;
|
||||
const copy = JSON.parse(JSON.stringify(src));
|
||||
copy.id = uid();
|
||||
copy.name = src.name + ' 副本';
|
||||
copy.x += 24; copy.y += 24;
|
||||
// 副本放置到顶层
|
||||
state.elements.push(copy);
|
||||
state.selectedId = copy.id;
|
||||
pushHistory();
|
||||
emit('change');
|
||||
return copy;
|
||||
}
|
||||
|
||||
function select(id, additive) {
|
||||
if (additive && id != null) {
|
||||
// Shift 多选:切换选中
|
||||
if (state.selectedIds.includes(id)) {
|
||||
state.selectedIds = state.selectedIds.filter(x => x !== id);
|
||||
state.selectedId = state.selectedIds.length ? state.selectedIds[state.selectedIds.length - 1] : null;
|
||||
} else {
|
||||
state.selectedIds.push(id);
|
||||
state.selectedId = id;
|
||||
}
|
||||
} else {
|
||||
state.selectedIds = id != null ? [id] : [];
|
||||
state.selectedId = id;
|
||||
}
|
||||
emit('select');
|
||||
emit('change');
|
||||
}
|
||||
|
||||
/** 当前所有选中元素 */
|
||||
function selectedElements() {
|
||||
return state.selectedIds.map(id => state.elements.find(e => e.id === id)).filter(Boolean);
|
||||
}
|
||||
|
||||
/** 移动全部选中元素 */
|
||||
function moveSelection(dx, dy) {
|
||||
for (const el of selectedElements()) {
|
||||
el.x = Math.round(el.x + dx);
|
||||
el.y = Math.round(el.y + dy);
|
||||
}
|
||||
emit('change');
|
||||
}
|
||||
|
||||
/** 对齐全部选中元素 */
|
||||
function alignSelection(mode) {
|
||||
const els = selectedElements();
|
||||
if (els.length < 2) return;
|
||||
const xs = els.map(e => e.x), xes = els.map(e => e.x + e.w);
|
||||
const ys = els.map(e => e.y), yes = els.map(e => e.y + e.h);
|
||||
const minX = Math.min(...xs), maxX = Math.max(...xes);
|
||||
const minY = Math.min(...ys), maxY = Math.max(...yes);
|
||||
const boxCx = (minX + maxX) / 2, boxCy = (minY + maxY) / 2;
|
||||
for (const el of els) {
|
||||
if (mode === 'left') el.x = minX;
|
||||
else if (mode === 'center') el.x = Math.round(boxCx - el.w / 2);
|
||||
else if (mode === 'right') el.x = Math.round(maxX - el.w);
|
||||
else if (mode === 'top') el.y = minY;
|
||||
else if (mode === 'middle') el.y = Math.round(boxCy - el.h / 2);
|
||||
else if (mode === 'bottom') el.y = Math.round(maxY - el.h);
|
||||
}
|
||||
pushHistory(); emit('change');
|
||||
}
|
||||
|
||||
/** 复制全部选中元素 */
|
||||
function duplicateSelection() {
|
||||
const srcs = selectedElements();
|
||||
if (!srcs.length) return 0;
|
||||
for (const src of srcs) {
|
||||
const copy = JSON.parse(JSON.stringify(src));
|
||||
copy.id = uid();
|
||||
copy.name = src.name + ' 副本';
|
||||
copy.x += 24; copy.y += 24;
|
||||
state.elements.push(copy);
|
||||
state.selectedIds.push(copy.id);
|
||||
state.selectedId = copy.id;
|
||||
}
|
||||
pushHistory(); emit('change');
|
||||
return srcs.length;
|
||||
}
|
||||
|
||||
/** 粘贴剪贴板元素(生成新副本并选中) */
|
||||
function pasteElements(list) {
|
||||
if (!list || !list.length) return [];
|
||||
const newEls = list.map(src => {
|
||||
const copy = JSON.parse(JSON.stringify(src));
|
||||
copy.id = uid();
|
||||
copy.name = src.name + ' 副本';
|
||||
copy.x += 24; copy.y += 24;
|
||||
state.elements.push(copy);
|
||||
state.selectedIds.push(copy.id);
|
||||
state.selectedId = copy.id;
|
||||
return copy;
|
||||
});
|
||||
pushHistory(); emit('change');
|
||||
return newEls;
|
||||
}
|
||||
|
||||
/** 删除全部选中元素 */
|
||||
function removeSelection() {
|
||||
if (!state.selectedIds.length) return 0;
|
||||
const ids = state.selectedIds.slice();
|
||||
state.elements = state.elements.filter(e => !ids.includes(e.id));
|
||||
state.selectedIds = [];
|
||||
state.selectedId = null;
|
||||
pushHistory(); emit('change');
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
/* ---------- 图层排序 ---------- */
|
||||
// elements 数组顺序即显示顺序(后画在上层)
|
||||
function layerUp(id) {
|
||||
const i = state.elements.findIndex(e => e.id === id);
|
||||
if (i < 0 || i >= state.elements.length - 1) return;
|
||||
[state.elements[i], state.elements[i + 1]] = [state.elements[i + 1], state.elements[i]];
|
||||
pushHistory(); emit('change');
|
||||
}
|
||||
function layerDown(id) {
|
||||
const i = state.elements.findIndex(e => e.id === id);
|
||||
if (i <= 0) return;
|
||||
[state.elements[i], state.elements[i - 1]] = [state.elements[i - 1], state.elements[i]];
|
||||
pushHistory(); emit('change');
|
||||
}
|
||||
function layerFront(id) {
|
||||
const el = state.elements.find(e => e.id === id);
|
||||
if (!el) return;
|
||||
state.elements = state.elements.filter(e => e.id !== id);
|
||||
state.elements.push(el);
|
||||
pushHistory(); emit('change');
|
||||
}
|
||||
function layerBack(id) {
|
||||
const el = state.elements.find(e => e.id === id);
|
||||
if (!el) return;
|
||||
state.elements = state.elements.filter(e => e.id !== id);
|
||||
state.elements.unshift(el);
|
||||
pushHistory(); emit('change');
|
||||
}
|
||||
|
||||
/** 拖拽图层:将 fromIndex 元素移动到 toIndex 位置(占据目标位置,其余元素顺移) */
|
||||
function moveLayer(fromIndex, toIndex) {
|
||||
const len = state.elements.length;
|
||||
if (fromIndex < 0 || fromIndex >= len || toIndex < 0 || toIndex >= len) return;
|
||||
if (fromIndex === toIndex) return;
|
||||
const [el] = state.elements.splice(fromIndex, 1);
|
||||
state.elements.splice(toIndex, 0, el);
|
||||
pushHistory(); emit('change');
|
||||
}
|
||||
|
||||
/* ---------- 风格应用 ---------- */
|
||||
function applyStyle(style) {
|
||||
state.currentStyle = style.id;
|
||||
state.bg = style.bg;
|
||||
for (const el of state.elements) {
|
||||
if (el.type === 'icon') {
|
||||
el.stroke = style.iconStroke;
|
||||
el.strokeWidth = style.strokeWidth;
|
||||
} else if (el.type === 'text') {
|
||||
el.fill = style.colors.primary;
|
||||
if (el.fontFamily === 'SimHei' || el.fontFamily === 'Microsoft YaHei' || el.fontFamily === '黑体') {
|
||||
el.fontFamily = style.fontCn;
|
||||
}
|
||||
}
|
||||
}
|
||||
pushHistory();
|
||||
emit('change');
|
||||
emit('style');
|
||||
}
|
||||
|
||||
/* ---------- 配色应用 ---------- */
|
||||
function applyPalette(pal) {
|
||||
let idx = 0;
|
||||
for (const el of state.elements) {
|
||||
if (el.type === 'icon') {
|
||||
el.stroke = pal.colors[Math.min(idx, 1)];
|
||||
idx++;
|
||||
} else if (el.type === 'text') {
|
||||
el.fill = pal.colors[0];
|
||||
}
|
||||
}
|
||||
pushHistory();
|
||||
emit('change');
|
||||
emit('style');
|
||||
}
|
||||
|
||||
/* ---------- 字体应用 ---------- */
|
||||
function applyFont(family, web) {
|
||||
const el = selected();
|
||||
if (!el || el.type !== 'text') return;
|
||||
if (web) HGB_Fonts.loadWebFont(family);
|
||||
updateElement(el.id, { fontFamily: family });
|
||||
}
|
||||
|
||||
/* ---------- 版本快照 ---------- */
|
||||
function saveVersion() {
|
||||
const versions = loadVersions();
|
||||
const v = {
|
||||
id: uid(),
|
||||
time: Date.now(),
|
||||
name: state.name || '未命名设计',
|
||||
snapshot: snapshot()
|
||||
};
|
||||
versions.unshift(v);
|
||||
saveVersions(versions);
|
||||
emit('versions');
|
||||
return v;
|
||||
}
|
||||
|
||||
function loadVersions() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LS_VERSION_KEY)) || [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
function saveVersions(list) {
|
||||
localStorage.setItem(LS_VERSION_KEY, JSON.stringify(list.slice(0, 50)));
|
||||
}
|
||||
|
||||
function restoreVersion(id) {
|
||||
const v = loadVersions().find(x => x.id === id);
|
||||
if (!v) return false;
|
||||
state = JSON.parse(JSON.stringify(v.snapshot));
|
||||
pushHistory();
|
||||
emit('change');
|
||||
return true;
|
||||
}
|
||||
|
||||
function deleteVersion(id) {
|
||||
saveVersions(loadVersions().filter(x => x.id !== id));
|
||||
emit('versions');
|
||||
}
|
||||
|
||||
function clearVersions() {
|
||||
saveVersions([]);
|
||||
emit('versions');
|
||||
}
|
||||
|
||||
/* ---------- 事件订阅 ---------- */
|
||||
function on(evt, fn) {
|
||||
(listeners[evt] = listeners[evt] || []).push(fn);
|
||||
}
|
||||
|
||||
function emit(evt) {
|
||||
(listeners[evt] || []).forEach(fn => fn());
|
||||
}
|
||||
|
||||
/* ---------- 选择器 ---------- */
|
||||
function get() { return state; }
|
||||
function selected() {
|
||||
return state.elements.find(e => e.id === state.selectedId) || null;
|
||||
}
|
||||
function selectedIndex() {
|
||||
return state.elements.findIndex(e => e.id === state.selectedId);
|
||||
}
|
||||
|
||||
/* ---------- 本地持久化 ---------- */
|
||||
function persistToLocal() {
|
||||
try {
|
||||
const data = { ...snapshot(), savedAt: Date.now() };
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(data));
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
function loadFromLocal() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return false;
|
||||
const data = JSON.parse(raw);
|
||||
if (data && Array.isArray(data.elements)) {
|
||||
state = data;
|
||||
// 迁移遗留演示默认名:历史版本默认项目名「我的留学教育品牌」会污染各行业生成时的品牌回退
|
||||
if (state.name === '我的留学教育品牌') state.name = '我的品牌';
|
||||
history = [snapshot()];
|
||||
historyIdx = 0;
|
||||
return true;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ---------- 初始化 ---------- */
|
||||
function init(restoreLocal = true) {
|
||||
if (restoreLocal && loadFromLocal()) return;
|
||||
state = defaultProject();
|
||||
history = [snapshot()];
|
||||
historyIdx = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
CANVAS_W, CANVAS_H,
|
||||
get, selected, selectedIndex,
|
||||
selectedElements, moveSelection, alignSelection,
|
||||
duplicateSelection, removeSelection, pasteElements,
|
||||
addElement, updateElement, removeElement, duplicateElement, loadPlan, select,
|
||||
layerUp, layerDown, layerFront, layerBack, moveLayer,
|
||||
applyStyle, applyPalette, applyFont,
|
||||
undo, redo, canUndo, canRedo, pushHistory,
|
||||
saveVersion, loadVersions, restoreVersion, deleteVersion, clearVersions,
|
||||
persistToLocal, loadFromLocal, init,
|
||||
on, emit, uid, iconSvg
|
||||
};
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 画个标 · 装饰图形系统
|
||||
* 落地 GitHub logo-design-skill 专业设计方法论:专业 LOGO 的"设计感"来自
|
||||
* 图形层次 —— 双线环、点状环绕、星点、角括号、弧线、绶带、几何衬底(容器)。
|
||||
* 所有装饰原语为 24x24 viewBox 字符串,作为 icon 元素渲染。
|
||||
* fill=true 表示实心填充型(无描边,可用于容器/色块);fill=false 表示描边型。
|
||||
*/
|
||||
const HGB_Decorations = (function () {
|
||||
'use strict';
|
||||
|
||||
let seq = 0;
|
||||
function uid() { return 'dc' + Date.now().toString(36) + (seq++).toString(36); }
|
||||
|
||||
/** 颜色亮化:amt 0~1(值越大越接近白色) */
|
||||
function lighten(hex, amt) {
|
||||
const c = String(hex || '#888888').replace('#', '');
|
||||
if (c.length !== 6) return '#cccccc';
|
||||
const f = (v) => Math.round(v + (255 - v) * Math.min(1, Math.max(0, amt))).toString(16).padStart(2, '0');
|
||||
return '#' + f(parseInt(c.slice(0, 2), 16)) + f(parseInt(c.slice(2, 4), 16)) + f(parseInt(c.slice(4, 6), 16));
|
||||
}
|
||||
/** 颜色加深:amt 0~1 */
|
||||
function shade(hex, amt) {
|
||||
const c = String(hex || '#888888').replace('#', '');
|
||||
if (c.length !== 6) return '#444444';
|
||||
const f = (v) => Math.round(v * (1 - Math.min(1, Math.max(0, amt)))).toString(16).padStart(2, '0');
|
||||
return '#' + f(parseInt(c.slice(0, 2), 16)) + f(parseInt(c.slice(2, 4), 16)) + f(parseInt(c.slice(4, 6), 16));
|
||||
}
|
||||
/** 判断颜色明暗(返回 true 表示偏深色) */
|
||||
function isDark(hex) {
|
||||
const c = String(hex || '#FFFFFF').replace('#', '');
|
||||
if (c.length !== 6) return false;
|
||||
const r = parseInt(c.slice(0, 2), 16), g = parseInt(c.slice(2, 4), 16), b = parseInt(c.slice(4, 6), 16);
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 150;
|
||||
}
|
||||
|
||||
/**
|
||||
* 装饰原语库
|
||||
*/
|
||||
const DECO_KIT = {
|
||||
/* ---- 环绕/边框类(描边型) ---- */
|
||||
'deco-dotring': { name: '点状环绕', fill: true, sw: 0, opacity: 0.9,
|
||||
svg: '<circle cx="12" cy="2" r="1.15"/><circle cx="17.07" cy="4.93" r="1.15"/><circle cx="19.92" cy="10" r="1.15"/><circle cx="19.92" cy="14" r="1.15"/><circle cx="17.07" cy="19.07" r="1.15"/><circle cx="12" cy="22" r="1.15"/><circle cx="6.93" cy="19.07" r="1.15"/><circle cx="4.08" cy="14" r="1.15"/><circle cx="4.08" cy="10" r="1.15"/><circle cx="6.93" cy="4.93" r="1.15"/>' },
|
||||
'deco-rayring': { name: '射线环', fill: false, sw: 2, opacity: 0.95,
|
||||
svg: (function () { let p = ''; for (let i = 0; i < 12; i++) { const a = i * 30 * Math.PI / 180, c = Math.cos(a), s = Math.sin(a); p += 'M' + (12 + 8 * c).toFixed(2) + ' ' + (12 + 8 * s).toFixed(2) + 'L' + (12 + 11.5 * c).toFixed(2) + ' ' + (12 + 11.5 * s).toFixed(2); } return p; })() },
|
||||
'deco-doublering': { name: '双线环', fill: false, sw: 4, opacity: 0.85,
|
||||
svg: '<circle cx="12" cy="12" r="10.5"/><circle cx="12" cy="12" r="7"/>' },
|
||||
'deco-arc-top': { name: '上弧形', fill: false, sw: 5, opacity: 0.9,
|
||||
svg: '<path d="M2 9 A 14 14 0 0 1 22 9"/>' },
|
||||
'deco-arc-bottom': { name: '下弧形', fill: false, sw: 5, opacity: 0.9,
|
||||
svg: '<path d="M2 15 A 14 14 0 0 0 22 15"/>' },
|
||||
'deco-corner-tl': { name: '左上角括号', fill: false, sw: 5, opacity: 0.95,
|
||||
svg: '<path d="M4 11 V4 H11"/>' },
|
||||
'deco-corner-br': { name: '右下角括号', fill: false, sw: 5, opacity: 0.95,
|
||||
svg: '<path d="M20 13 V20 H13"/>' },
|
||||
'deco-stars': { name: '星点散落', fill: true, sw: 0, opacity: 0.9,
|
||||
svg: '<path d="M6 4l.9 2.2L9 7l-2.1.9L6 10l-.9-2.1L3 7l2.1-.9z"/><path d="M18 14l.8 1.9 1.9.8-1.9.8-.8 1.9-.8-1.9-1.9-.8 1.9-.8z"/><path d="M16 3.5l.6 1.4 1.4.6-1.4.6-.6 1.4-.6-1.4-1.4-.6 1.4-.6z"/>' },
|
||||
'deco-divider': { name: '菱形分隔线', fill: true, sw: 0, opacity: 0.95,
|
||||
svg: '<rect x="2" y="11" width="7" height="2" rx="1"/><path d="M12 8.5l2.6 3.5-2.6 3.5-2.6-3.5z"/><rect x="15" y="11" width="7" height="2" rx="1"/>' },
|
||||
'deco-chevron': { name: '人字饰带', fill: false, sw: 5, opacity: 0.9,
|
||||
svg: '<path d="M5 6l6 6-6 6"/><path d="M13 6l6 6-6 6"/>' },
|
||||
'deco-tri-corner': { name: '三角角标', fill: true, sw: 0, opacity: 0.9,
|
||||
svg: '<path d="M12 3L21 21H3z"/>' },
|
||||
'deco-line': { name: '细分隔线', fill: true, sw: 0, opacity: 0.9,
|
||||
svg: '<rect x="0" y="11" width="24" height="2" rx="1"/>' },
|
||||
'deco-spark': { name: '光芒四射', fill: true, sw: 0, opacity: 0.9,
|
||||
svg: '<path d="M12 2l1.8 6.2L20 10l-6.2 1.8L12 18l-1.8-6.2L4 10l6.2-1.8z"/>' },
|
||||
'deco-laurel-l': { name: '左月桂叶', fill: false, sw: 5, opacity: 0.9,
|
||||
svg: '<path d="M14 21a8 8 0 0 1-8-8M14 21c-1 0-2-3-2-8s1-8 2-8M6 13a6 6 0 0 1 3-6c2-1 3 1 3 4"/>' },
|
||||
'deco-laurel-r': { name: '右月桂叶', fill: false, sw: 5, opacity: 0.9,
|
||||
svg: '<path d="M10 21a8 8 0 0 0 8-8M10 21c1 0 2-3 2-8s-1-8-2-8M18 13a6 6 0 0 0-3-6c-2-1-3 1-3 4"/>' },
|
||||
/* ---- 实心几何衬底(容器,图标徽章化) ---- */
|
||||
'deco-shape-circle': { name: '圆形衬底', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<circle cx="12" cy="12" r="11"/>' },
|
||||
'deco-shape-squircle':{ name: '圆角方形衬底', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<rect x="1.6" y="1.6" width="20.8" height="20.8" rx="5"/>' },
|
||||
'deco-shape-hexagon': { name: '六边形衬底', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<path d="M12 1.5L21.3 6.75v10.5L12 22.5 2.7 17.25V6.75z"/>' },
|
||||
'deco-shape-star': { name: '星形衬底', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<path d="M12 1.6l3.2 6.5 7.2 1-5.2 5 1.2 7.1L12 17.9l-6.4 3.3 1.2-7.1-5.2-5 7.2-1z"/>' },
|
||||
'deco-shape-shield': { name: '盾形衬底', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<path d="M12 1.8L20.5 5v7c0 4.6-3.5 8.2-8.5 10.2C7 20.2 3.5 16.6 3.5 12V5z"/>' },
|
||||
'deco-shape-diamond': { name: '菱形衬底', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<path d="M12 1.5L22.5 12 12 22.5 1.5 12z"/>' },
|
||||
'deco-shape-triangle': { name: '三角衬底', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<path d="M12 1.6 L22.4 21.5 H1.6 z"/>' },
|
||||
'deco-shape-arch': { name: '拱形衬底', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<path d="M2 24V11a10 10 0 0 1 20 0v13z"/>' },
|
||||
'deco-shape-pill': { name: '胶囊衬底', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<rect x="1.6" y="7" width="20.8" height="10" rx="5"/>' },
|
||||
'deco-shape-ribbon': { name: '绶带横幅', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<path d="M2 5h20v11h-8.4l-1.6 3.4L10.4 16H2z"/>' },
|
||||
'deco-shape-heart': { name: '爱心衬底', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<path d="M12 21s-8.5-5-10.5-10C-.5 6.5 3 3 6.5 3c2 0 3.6 1 5.5 3 1.9-2 3.5-3 5.5-3C21 3 24.5 6.5 22.5 11 20.5 16 12 21 12 21z"/>' },
|
||||
/* ---- 分屏/背景面板(填充型,铺满画布) ---- */
|
||||
'deco-panel-left': { name: '左半色板', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<rect x="0" y="0" width="12" height="24"/>' },
|
||||
'deco-panel-diag': { name: '对角色板', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<path d="M0 0h16L6 24H0z"/>' },
|
||||
'deco-panel-band': { name: '横向色带', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<rect x="0" y="10" width="24" height="4.4"/>' },
|
||||
'deco-panel-barc': { name: '底边色块', fill: true, sw: 0, opacity: 1,
|
||||
svg: '<path d="M0 16h24v8H0z"/>' }
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造装饰元素(同 icon 元素结构,渲染走 applyIconStyle)
|
||||
* @param {string} id DECO_KIT 键名
|
||||
* @param {number} x 左上角 x
|
||||
* @param {number} y 左上角 y
|
||||
* @param {number} w 宽
|
||||
* @param {number} h 高
|
||||
* @param {string} color 填充/描边色
|
||||
* @param {object} grad {id,type,from,to} 渐变对象(有则覆盖纯色)
|
||||
*/
|
||||
function decoEl(id, x, y, w, h, color, grad) {
|
||||
const def = DECO_KIT[id];
|
||||
if (!def) return null;
|
||||
const fill = grad ? 'url(#' + grad.id + ')' : (def.fill ? color : 'none');
|
||||
return {
|
||||
id: uid(), type: 'icon', name: def.name, iconId: id,
|
||||
svg: def.svg, x, y, w, h, rotation: 0,
|
||||
fill: fill,
|
||||
stroke: def.fill ? 'none' : color,
|
||||
strokeWidth: def.sw || 4,
|
||||
grad: grad || null,
|
||||
opacity: def.opacity != null ? def.opacity : 1,
|
||||
visible: true, locked: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按风格返回装饰组合(供布局选择参考;布局也可自选具体原语)
|
||||
* @param {string} styleId 风格 id
|
||||
* @param {object} palette 配色 {colors:[主,辅,点,背景]}
|
||||
* @returns {{dec:Array, decoColor:string, grad:object}}
|
||||
*/
|
||||
function buildDecoSet(styleId, palette) {
|
||||
const colors = (palette && palette.colors) || ['#2563EB', '#3B82F6', '#F59E0B', '#FFFFFF'];
|
||||
const decoColor = lighten(colors[1] || colors[0], 0.42); // 浅色点缀,低对比
|
||||
const grad = {
|
||||
id: 'grad-main' + (++seq),
|
||||
type: 'linear',
|
||||
from: colors[0],
|
||||
to: lighten(colors[0], 0.28)
|
||||
};
|
||||
const map = {
|
||||
minimal: ['deco-doublering'],
|
||||
gradient: ['deco-dotring'],
|
||||
vintage: ['deco-arc-top', 'deco-arc-bottom'],
|
||||
tech: ['deco-corner-tl', 'deco-corner-br'],
|
||||
playful: ['deco-stars'],
|
||||
luxury: ['deco-divider'],
|
||||
flat: ['deco-shape-circle']
|
||||
};
|
||||
return { dec: map[styleId] || [], decoColor, grad };
|
||||
}
|
||||
|
||||
return { decoEl, buildDecoSet, lighten, shade, isDark, DECO_KIT };
|
||||
})();
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 画个标 · 留学主题字体库
|
||||
* 包含中文/英文排版推荐 + 可选的 Google Fonts 网络加载
|
||||
*/
|
||||
const HGB_Fonts = {
|
||||
groups: [
|
||||
{
|
||||
name: '中文 · 正式',
|
||||
items: [
|
||||
{ family: 'SimSun', cn: '宋体', web: false, desc: '经典衬线,典雅学术' },
|
||||
{ family: 'SimHei', cn: '黑体', web: false, desc: '现代无衬线,稳重清晰' },
|
||||
{ family: 'KaiTi', cn: '楷体', web: false, desc: '手写感,亲和人文' },
|
||||
{ family: 'Microsoft YaHei', cn: '微软雅黑', web: false, desc: '屏幕友好,现代通用' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '英文 · 衬线',
|
||||
items: [
|
||||
{ family: 'Georgia', cn: 'Georgia', web: false, desc: '经典衬线,传统名校' },
|
||||
{ family: 'Times New Roman', cn: 'Times', web: false, desc: '报刊气质,学术规范' },
|
||||
{ family: 'Playfair Display', cn: 'Playfair', web: true, desc: '高端时尚衬线(网络加载)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '英文 · 无衬线',
|
||||
items: [
|
||||
{ family: 'Arial', cn: 'Arial', web: false, desc: '清晰通用,商务首选' },
|
||||
{ family: 'Verdana', cn: 'Verdana', web: false, desc: '宽字距,易读性强' },
|
||||
{ family: 'Montserrat', cn: 'Montserrat', web: true, desc: '现代几何感(网络加载)' },
|
||||
{ family: 'Poppins', cn: 'Poppins', web: true, desc: '圆润时尚(网络加载)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '英文 · 装饰',
|
||||
items: [
|
||||
{ family: 'Impact', cn: 'Impact', web: false, desc: '粗壮有力,标题冲击' },
|
||||
{ family: 'Courier New', cn: 'Courier', web: false, desc: '等宽打字机,复古文艺' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/** 动态加载 Google Fonts(网络可用时) */
|
||||
HGB_Fonts.loadWebFont = function (family) {
|
||||
const id = 'hgb-font-' + family.replace(/[^a-zA-Z0-9]/g, '');
|
||||
if (document.getElementById(id)) return;
|
||||
const link = document.createElement('link');
|
||||
link.id = id;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://fonts.googleapis.com/css2?family=' +
|
||||
encodeURIComponent(family.replace(/ /g, '+')) + ':wght@400;700;900&display=swap';
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
/** 按 family 查找字体项 */
|
||||
HGB_Fonts.find = function (family) {
|
||||
for (const g of this.groups) {
|
||||
const f = g.items.find(i => i.family === family);
|
||||
if (f) return f;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 画个标 · 留学教育元素库 & 基础图形库
|
||||
* 所有图标统一 24x24 viewBox,线性风格(stroke-based)
|
||||
*/
|
||||
const HGB_Icons = {
|
||||
/* ========== 留学教育元素(第一专项模块) ========== */
|
||||
edu: [
|
||||
{
|
||||
id: 'graduation-cap',
|
||||
name: '学士帽',
|
||||
svg: '<polygon points="12 3 1.5 8 12 13 22.5 8 12 3"/><path d="M6 10.6v4.4c0 2.1 2.7 3.6 6 3.6s6-1.5 6-3.6v-4.4"/><line x1="12" y1="13" x2="12" y2="18.6"/><circle cx="12" cy="19.4" r="0.8"/>',
|
||||
desc: '毕业与学术的经典象征'
|
||||
},
|
||||
{
|
||||
id: 'open-book',
|
||||
name: '书籍',
|
||||
svg: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
desc: '知识、学习与阅读'
|
||||
},
|
||||
{
|
||||
id: 'globe',
|
||||
name: '地球仪',
|
||||
svg: '<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
desc: '国际化与全球视野'
|
||||
},
|
||||
{
|
||||
id: 'diploma',
|
||||
name: '毕业文凭',
|
||||
svg: '<circle cx="12" cy="8" r="6"/><polyline points="15.48 12.89 17 22 12 19 7 22 8.52 12.89"/>',
|
||||
desc: '学位证书与荣誉'
|
||||
},
|
||||
{
|
||||
id: 'plane',
|
||||
name: '留学航线',
|
||||
svg: '<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/>',
|
||||
desc: '出国与远行'
|
||||
},
|
||||
{
|
||||
id: 'lighthouse',
|
||||
name: '灯塔',
|
||||
svg: '<polygon points="12 2 9.5 5 14.5 5 12 2"/><rect x="9" y="5" width="6" height="5" rx="0.6"/><path d="M8.5 10h7l1.2 4h-9.4z"/><path d="M7.5 14h9l1 8h-11z"/><circle cx="12" cy="7.5" r="1.1"/>',
|
||||
desc: '指引与方向'
|
||||
},
|
||||
{
|
||||
id: 'pen',
|
||||
name: '钢笔',
|
||||
svg: '<path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/>',
|
||||
desc: '写作与学术'
|
||||
},
|
||||
{
|
||||
id: 'landmark',
|
||||
name: '学府建筑',
|
||||
svg: '<line x1="3" y1="22" x2="21" y2="22"/><line x1="6" y1="18" x2="6" y2="11"/><line x1="10" y1="18" x2="10" y2="11"/><line x1="14" y1="18" x2="14" y2="11"/><line x1="18" y1="18" x2="18" y2="11"/><polygon points="12 2 20 7 4 7"/>',
|
||||
desc: '校园与学院'
|
||||
},
|
||||
{
|
||||
id: 'compass',
|
||||
name: '指南针',
|
||||
svg: '<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>',
|
||||
desc: '探索与方向'
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
name: '未来之匙',
|
||||
svg: '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
|
||||
desc: '开启未来之门'
|
||||
},
|
||||
{
|
||||
id: 'star-grad',
|
||||
name: '五星奖章',
|
||||
svg: '<circle cx="12" cy="12" r="10"/><polygon points="12 5.5 13.8 9.3 18 9.8 15 12.6 15.8 16.8 12 14.7 8.2 16.8 9 12.6 6 9.8 10.2 9.3"/>',
|
||||
desc: '优秀与卓越'
|
||||
},
|
||||
{
|
||||
id: 'lightbulb',
|
||||
name: '灵感之灯',
|
||||
svg: '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V17h6v-.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"/>',
|
||||
desc: '创意与灵感'
|
||||
}
|
||||
],
|
||||
|
||||
/* ========== 基础图形 ========== */
|
||||
shapes: [
|
||||
{ id: 'circle', name: '圆形', svg: '<circle cx="12" cy="12" r="9"/>', desc: '圆润、包容' },
|
||||
{ id: 'square', name: '方形', svg: '<rect x="3.5" y="3.5" width="17" height="17" rx="1"/>', desc: '稳重、可靠' },
|
||||
{ id: 'rounded', name: '圆角方', svg: '<rect x="3.5" y="5" width="17" height="14" rx="4"/>', desc: '现代、亲和' },
|
||||
{ id: 'hexagon', name: '六边形', svg: '<polygon points="12 2.5 20.5 7.25 20.5 16.75 12 21.5 3.5 16.75 3.5 7.25"/>', desc: '科技、独特' },
|
||||
{ id: 'triangle', name: '三角', svg: '<polygon points="12 3 22 20 2 20"/>', desc: '进取、上升' },
|
||||
{ id: 'diamond', name: '菱形', svg: '<polygon points="12 2.5 21.5 12 12 21.5 2.5 12"/>', desc: '优雅、精致' },
|
||||
{ id: 'star', name: '五角星', svg: '<polygon points="12 2.8 14.7 8.7 21.2 9.5 16.5 14 17.6 20.4 12 17.2 6.4 20.4 7.5 14 2.8 9.5 9.3 8.7"/>', desc: '卓越、闪耀' },
|
||||
{ id: 'heart', name: '爱心', svg: '<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z"/>', desc: '关爱、热情' },
|
||||
{ id: 'shield', name: '盾牌', svg: '<path d="M12 2l8 3v6c0 5-3.5 9.5-8 11-4.5-1.5-8-6-8-11V5z"/>', desc: '保障、专业' },
|
||||
{ id: 'ring', name: '圆环', svg: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4.5"/>', desc: '环环相扣' },
|
||||
{ id: 'seal', name: '印章圆', svg: '<circle cx="12" cy="12" r="9.5"/><circle cx="12" cy="12" r="6.5"/>', desc: '认证、官方' },
|
||||
{ id: 'arch', name: '拱门', svg: '<path d="M4 21v-9a8 8 0 0 1 16 0v9"/><path d="M8 21v-9a4 4 0 0 1 8 0v9"/>', desc: '经典、学术' }
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 画个标 · 垂直行业模块库
|
||||
* 每个行业包含:专属元素库、推荐配色方案、推荐风格与字体
|
||||
* 行业元素统一 24x24 viewBox 线性风格
|
||||
*/
|
||||
const HGB_Industries = [
|
||||
/* ================= 留学教育 ================= */
|
||||
{
|
||||
id: 'edu',
|
||||
name: '留学教育',
|
||||
emoji: '🎓',
|
||||
desc: '学士帽、书籍、地球仪等教育视觉元素',
|
||||
icons: HGB_Icons.edu,
|
||||
palettes: [
|
||||
{ name: '学术蓝', desc: '沉稳专业,国际教育主流', colors: ['#1B4F72', '#2980B9', '#AED6F1', '#F4F6F7'], scene: '留学中介' },
|
||||
{ name: '常春藤绿', desc: '常春藤盟校气质', colors: ['#1E8449', '#27AE60', '#D5F5E3', '#FFFFFF'], scene: '本科留学' },
|
||||
{ name: '皇家金', desc: '学历含金量与荣耀', colors: ['#7D6608', '#D4AC0D', '#F9E79F', '#FEF9E7'], scene: '商学院' },
|
||||
{ name: '梦想天蓝', desc: '清新明亮,追梦活力', colors: ['#2471A3', '#5DADE2', '#EBF5FB', '#FFFFFF'], scene: '语言培训' }
|
||||
],
|
||||
styleIds: ['minimal', 'badge', 'vintage'],
|
||||
fontFamilies: ['SimSun', 'Georgia', 'Playfair Display', 'SimHei']
|
||||
},
|
||||
|
||||
/* ================= 餐饮美食 ================= */
|
||||
{
|
||||
id: 'food',
|
||||
name: '餐饮美食',
|
||||
emoji: '🍽️',
|
||||
desc: '厨师帽、咖啡、汉堡等餐饮元素',
|
||||
icons: [
|
||||
{ id: 'chef-hat', name: '厨师帽', svg: '<path d="M17 21a1 1 0 0 0 1-1v-5.35c0-.457.316-.844.727-1.041a4 4 0 0 0-2.134-7.589 5 5 0 0 0-9.186 0 4 4 0 0 0-2.134 7.588c.411.198.727.585.727 1.041V20a1 1 0 0 0 1 1Z"/><path d="M6 17h12"/>', desc: '主厨与专业烹饪' },
|
||||
{ id: 'coffee', name: '咖啡杯', svg: '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/>', desc: '咖啡厅与休闲时光' },
|
||||
{ id: 'utensils', name: '刀叉餐具', svg: '<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/>', desc: '用餐与精致餐饮' },
|
||||
{ id: 'burger', name: '汉堡', svg: '<path d="M3 11a9 9 0 0 1 18 0z"/><path d="M4 13.5h16"/><path d="M4.5 15.5h15"/><path d="M5 16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2z"/>', desc: '快餐与欢乐' },
|
||||
{ id: 'wine', name: '高脚杯', svg: '<path d="M8 22h8"/><path d="M7 10h10"/><path d="M12 15v7"/><path d="M12 15a5 5 0 0 0 5-5c0-2-.8-4-2-6H9c-1.2 2-2 4-2 6a5 5 0 0 0 5 5z"/>', desc: '品酒与格调' },
|
||||
{ id: 'cake', name: '蛋糕', svg: '<circle cx="12" cy="18" r="4"/><path d="M12 14c3.3 0 6-2.2 6-5 0 0-2.5 1-3.5-1.5C13.5 5 14 3 12 3s-1.5 2-2.5 4.5C8.5 10 6 9 6 9c0 2.8 2.7 5 6 5z"/>', desc: '甜品与庆典' },
|
||||
{ id: 'soup', name: '热汤碗', svg: '<path d="M4 11h16a1 1 0 0 1 .5 1.87c-.8.4-1.3 1.3-1.3 2.13V17a3 3 0 0 1-3 3h-8a3 3 0 0 1-3-3v-2c0-.83-.5-1.73-1.3-2.13A1 1 0 0 1 4 11Z"/><path d="M8 10V9"/><path d="M16 10V9"/><path d="M12 10V9"/>', desc: '暖汤与养生' },
|
||||
{ id: 'icecream', name: '冰淇淋', svg: '<path d="M12 2a5 5 0 0 1 5 5c0 3-1 5-2 8H9c-1-3-2-5-2-8a5 5 0 0 1 5-5z"/><path d="M9.5 17h5"/>', desc: '清凉与甜蜜' }
|
||||
],
|
||||
palettes: [
|
||||
{ name: '香橙暖阳', desc: '热情食欲,餐饮首选', colors: ['#E67E22', '#F39C12', '#FDEBD0', '#FFF9F0'], scene: '中西餐厅' },
|
||||
{ name: '红酒典雅', desc: '高格调,成熟稳重', colors: ['#7B241C', '#C0392B', '#F5B7B1', '#FDF2F2'], scene: '西餐酒吧' },
|
||||
{ name: '抹茶清新', desc: '自然清新,健康轻食', colors: ['#1E8449', '#7DCEA0', '#E9F7EF', '#FFFFFF'], scene: '轻食甜品' },
|
||||
{ name: '咖啡醇香', desc: '温暖醇厚,休闲空间', colors: ['#6F4E37', '#A67B5B', '#DCC7AA', '#F7F1E5'], scene: '咖啡烘焙' }
|
||||
],
|
||||
styleIds: ['cartoon', 'hand', 'badge'],
|
||||
fontFamilies: ['Microsoft YaHei', 'Poppins', 'KaiTi', 'Verdana']
|
||||
},
|
||||
|
||||
/* ================= 医疗健康 ================= */
|
||||
{
|
||||
id: 'health',
|
||||
name: '医疗健康',
|
||||
emoji: '⚕️',
|
||||
desc: '十字、心跳、药丸等健康元素',
|
||||
icons: [
|
||||
{ id: 'medical-cross', name: '医疗十字', svg: '<circle cx="12" cy="12" r="10"/><path d="M12 8v8"/><path d="M8 12h8"/>', desc: '医疗与急救' },
|
||||
{ id: 'heart-pulse', name: '心跳', svg: '<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z"/><path d="M7 12h3l1.5-3 2 6 1.5-3h2"/>', desc: '健康与生命活力' },
|
||||
{ id: 'pill', name: '药丸', svg: '<path d="M10.5 20.5a5 5 0 0 1-7-7l8-8a5 5 0 0 1 7 7z"/><path d="M3.5 13.5l7-7"/>', desc: '医药与护理' },
|
||||
{ id: 'stethoscope', name: '听诊器', svg: '<path d="M4.8 2.3A.3.3 0 1 0 5 2H4a2 2 0 0 0-2 2v5a6 6 0 0 0 6 6 6 6 0 0 0 6-6V4a2 2 0 0 0-2-2h-1a.2.2 0 1 0 .3.3"/><path d="M8 15v1a6 6 0 0 0 6 6 6 6 0 0 0 6-6v-4"/><circle cx="20" cy="10" r="2"/>', desc: '诊疗与关怀' },
|
||||
{ id: 'first-aid', name: '急救箱', svg: '<rect x="2" y="6" width="20" height="12" rx="2"/><path d="M12 9v6"/><path d="M9 12h6"/><path d="M8 6V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1"/>', desc: '急救与保障' },
|
||||
{ id: 'syringe', name: '注射器', svg: '<path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/>', desc: '接种与医学' },
|
||||
{ id: 'ambulance', name: '救护车', svg: '<rect x="2" y="5" width="16" height="12" rx="2"/><path d="M18 9h3l2 3v5h-2"/><path d="M7 8v6"/><path d="M4 11h6"/><circle cx="6.5" cy="19" r="1.8"/><circle cx="18" cy="19" r="1.8"/>', desc: '应急与救援' },
|
||||
{ id: 'microscope', name: '显微镜', svg: '<path d="M6 18h8"/><path d="M3 22h18"/><path d="M14 22a7 7 0 1 0 0-14h-1"/><path d="M9 14h2"/><path d="M9 12a2 2 0 0 1-2-2V6h6v4a2 2 0 0 1-2 2Z"/><path d="M12 6V3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3"/>', desc: '研究与实验室' }
|
||||
],
|
||||
palettes: [
|
||||
{ name: '医疗蓝', desc: '专业信赖,医疗主流', colors: ['#1A5276', '#5DADE2', '#D6EAF8', '#FFFFFF'], scene: '综合医院' },
|
||||
{ name: '健康绿', desc: '生机健康,自然疗愈', colors: ['#1E8449', '#58D68D', '#D5F5E3', '#FFFFFF'], scene: '养生保健' },
|
||||
{ name: '纯净白蓝', desc: '洁净清爽,现代诊所', colors: ['#2E86C1', '#85C1E9', '#F0F8FF', '#FFFFFF'], scene: '口腔诊所' },
|
||||
{ name: '安心浅蓝', desc: '温和安心,母婴护理', colors: ['#2874A6', '#5DADE2', '#EBF5FB', '#F4F6F6'], scene: '母婴护理' }
|
||||
],
|
||||
styleIds: ['minimal', 'business', 'tech'],
|
||||
fontFamilies: ['Microsoft YaHei', 'Arial', 'SimHei', 'Montserrat']
|
||||
},
|
||||
|
||||
/* ================= 科技互联网 ================= */
|
||||
{
|
||||
id: 'tech',
|
||||
name: '科技互联网',
|
||||
emoji: '💻',
|
||||
desc: '芯片、火箭、代码等科技元素',
|
||||
icons: [
|
||||
{ id: 'chip', name: '芯片', svg: '<rect x="7" y="7" width="10" height="10" rx="1"/><path d="M9 3v4"/><path d="M15 3v4"/><path d="M9 17v4"/><path d="M15 17v4"/><path d="M3 9h4"/><path d="M3 15h4"/><path d="M17 9h4"/><path d="M17 15h4"/><rect x="10" y="10" width="4" height="4"/>', desc: '半导体与算力' },
|
||||
{ id: 'rocket', name: '火箭', svg: '<path d="M12 2c2.5 3 4 6 4 9l3 3v5h-4l-1.5 2.5L12 22l-1.5-1.5L9 19H5v-5l3-3c0-3 1.5-6 4-9z"/><circle cx="12" cy="11" r="2"/>', desc: '创新与速度' },
|
||||
{ id: 'code', name: '代码', svg: '<path d="M8 6l-6 6 6 6"/><path d="M16 6l6 6-6 6"/>', desc: '开发与编程' },
|
||||
{ id: 'cloud', name: '云服务', svg: '<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>', desc: '云计算与存储' },
|
||||
{ id: 'robot', name: '机器人', svg: '<rect x="4" y="8" width="16" height="12" rx="2"/><path d="M12 8V4"/><circle cx="12" cy="3" r="1"/><circle cx="9" cy="13" r="1"/><circle cx="15" cy="13" r="1"/><path d="M9 17h6"/>', desc: 'AI 与自动化' },
|
||||
{ id: 'gear', name: '齿轮', svg: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3"/><path d="M12 19v3"/><path d="M2 12h3"/><path d="M19 12h3"/><path d="M4.9 4.9l2.1 2.1"/><path d="M17 17l2.1 2.1"/><path d="M19.1 4.9L17 7"/><path d="M7 17l-2.1 2.1"/>', desc: '系统与运转' },
|
||||
{ id: 'monitor', name: '显示器', svg: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/>', desc: '软件与界面' },
|
||||
{ id: 'wifi', name: '无线网络', svg: '<path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a7 7 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/>', desc: '连接与在线' }
|
||||
],
|
||||
palettes: [
|
||||
{ name: '科技蓝紫', desc: '智慧深邃,数字感', colors: ['#4A235A', '#8E44AD', '#BB8FCE', '#F5EEF8'], scene: '互联网公司' },
|
||||
{ name: '电路蓝', desc: '冷静专业,工业科技', colors: ['#1B2631', '#2980B9', '#85C1E9', '#F4F6F7'], scene: '智能硬件' },
|
||||
{ name: '极客霓虹', desc: '活力张扬,年轻潮流', colors: ['#0B132B', '#3A86FF', '#FF006E', '#FB5607'], scene: '游戏娱乐' },
|
||||
{ name: '未来银灰', desc: '极简高级,未来感', colors: ['#2C3E50', '#7F8C8D', '#BDC3C7', '#ECF0F1'], scene: '人工智能' }
|
||||
],
|
||||
styleIds: ['tech', 'gradient', 'minimal'],
|
||||
fontFamilies: ['SimHei', 'Montserrat', 'Verdana', 'Microsoft YaHei']
|
||||
},
|
||||
|
||||
/* ================= 体育竞技 ================= */
|
||||
{
|
||||
id: 'sport',
|
||||
name: '体育竞技',
|
||||
emoji: '🏆',
|
||||
desc: '奖杯、足球、奖牌等运动元素',
|
||||
icons: [
|
||||
{ id: 'trophy', name: '奖杯', svg: '<path d="M8 21h8"/><path d="M12 17v4"/><path d="M7 4h10v6a5 5 0 0 1-10 0z"/><path d="M7 6H4a2.5 2.5 0 0 0 0 5h3"/><path d="M17 6h3a2.5 2.5 0 0 1 0 5h-3"/>', desc: '冠军与荣耀' },
|
||||
{ id: 'soccer', name: '足球', svg: '<circle cx="12" cy="12" r="9"/><path d="M12 7l2.5 1.5L13.5 11h-3l-1-2.5z"/><path d="M12 3v4"/><path d="M5 8.5l3 1"/><path d="M16 20l-1.5-2.5"/><path d="M8 20l1.5-2.5"/>', desc: '绿茵与团队' },
|
||||
{ id: 'basketball', name: '篮球', svg: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a9 9 0 0 0 0 18"/>', desc: '活力与对抗' },
|
||||
{ id: 'medal', name: '奖牌', svg: '<circle cx="12" cy="14" r="5"/><path d="M9 3l-1 3"/><path d="M15 3l1 3"/><path d="M9.5 6h5"/><path d="M12 12l.9 1.7 1.9.3-1.4 1.3.3 1.9-1.7-.9-1.7.9.3-1.9L9.2 14l1.9-.3z"/>', desc: '成就与荣誉' },
|
||||
{ id: 'runner', name: '奔跑', svg: '<circle cx="13" cy="4" r="2"/><path d="M5 21l3-6 2 1 1 5"/><path d="M8 15l2-4 3-1 3 3 2 1"/>', desc: '运动与坚持' },
|
||||
{ id: 'dumbbell', name: '哑铃', svg: '<path d="M6.5 6.5v11"/><path d="M17.5 6.5v11"/><path d="M2 9v6"/><path d="M22 9v6"/><rect x="6.5" y="9" width="11" height="6" rx="1"/>', desc: '力量与健身' },
|
||||
{ id: 'bike', name: '骑行', svg: '<circle cx="6" cy="17" r="3.5"/><circle cx="18" cy="17" r="3.5"/><path d="M6 17L10 8h4l4 9"/><path d="M14 8h2"/>', desc: '健康与户外' },
|
||||
{ id: 'stopwatch', name: '秒表', svg: '<circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2.5"/><path d="M9 2h6"/><path d="M12 2v3"/>', desc: '竞速与计时' }
|
||||
],
|
||||
palettes: [
|
||||
{ name: '活力橙', desc: '阳光热情,运动活力', colors: ['#E67E22', '#F39C12', '#FDEBD0', '#FFF9F0'], scene: '综合健身' },
|
||||
{ name: '赛场绿', desc: '清新自然,健康向上', colors: ['#1E8449', '#52BE80', '#D5F5E3', '#FFFFFF'], scene: '户外运动' },
|
||||
{ name: '冠军金', desc: '尊贵闪耀,胜利荣光', colors: ['#7D6608', '#D4AC0D', '#F9E79F', '#FEF9E7'], scene: '竞技赛事' },
|
||||
{ name: '热血红', desc: '激情澎湃,力量对决', colors: ['#922B21', '#E74C3C', '#F5B7B1', '#FDEDEC'], scene: '搏击格斗' }
|
||||
],
|
||||
styleIds: ['cartoon', 'hand', 'badge'],
|
||||
fontFamilies: ['Impact', 'Microsoft YaHei', 'KaiTi', 'Arial']
|
||||
},
|
||||
|
||||
/* ================= 金融商务 ================= */
|
||||
{
|
||||
id: 'finance',
|
||||
name: '金融商务',
|
||||
emoji: '💼',
|
||||
desc: '钱币、银行、图表等商务元素',
|
||||
icons: [
|
||||
{ id: 'coin', name: '钱币', svg: '<circle cx="12" cy="12" r="9"/><path d="M12 6v12"/><path d="M8.5 9.5h7"/><path d="M8.5 14.5h7"/>', desc: '财富与价值' },
|
||||
{ id: 'bank', name: '银行', svg: '<polygon points="3 9 12 3 21 9"/><rect x="3" y="10" width="18" height="4"/><path d="M5 14v5"/><path d="M9.5 14v5"/><path d="M14.5 14v5"/><path d="M19 14v5"/><path d="M3 19h18"/>', desc: '金融与信用' },
|
||||
{ id: 'trending-up', name: '增长图表', svg: '<path d="M23 6l-9.5 9.5-5-5L1 18"/><path d="M17 6h6v6"/>', desc: '增长与上升' },
|
||||
{ id: 'handshake', name: '握手', svg: '<path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/>', desc: '合作与共赢' },
|
||||
{ id: 'briefcase', name: '公文包', svg: '<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M2 13h20"/>', desc: '商务与职场' },
|
||||
{ id: 'calculator', name: '计算器', svg: '<rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="16" y1="14" x2="16" y2="18"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/>', desc: '财务与计算' },
|
||||
{ id: 'gem', name: '钻石', svg: '<path d="M6 3h12l4 6-10 13L2 9z"/><path d="M2 9h20"/><path d="M12 22L8 9l4-6 4 6z"/>', desc: '珍贵与高端' },
|
||||
{ id: 'money-bag', name: '钱袋', svg: '<path d="M8 7L5 3h14l-3 4"/><path d="M8 7h8v3a4 4 0 0 1-8 0z"/><path d="M6 17a4 4 0 0 0 4 4h4a4 4 0 0 0 4-4v-4H6z"/>', desc: '投资与储蓄' }
|
||||
],
|
||||
palettes: [
|
||||
{ name: '商务深蓝', desc: '专业稳重,金融权威', colors: ['#1B2631', '#2E86C1', '#F1C40F', '#F4F6F7'], scene: '银行保险' },
|
||||
{ name: '金砖', desc: '奢华尊贵,财富象征', colors: ['#7D6608', '#D4AC0D', '#F9E79F', '#FEF9E7'], scene: '投资理财' },
|
||||
{ name: '稳重灰蓝', desc: '严谨理性,商务咨询', colors: ['#34495E', '#5D6D7E', '#AEB6BF', '#FFFFFF'], scene: '企业咨询' },
|
||||
{ name: '翡翠绿', desc: '稳健增长,安心可靠', colors: ['#145A32', '#1E8449', '#ABEBC6', '#F0FDF4'], scene: '证券基金' }
|
||||
],
|
||||
styleIds: ['business', 'minimal', 'vintage'],
|
||||
fontFamilies: ['Arial', 'SimHei', 'Georgia', 'Montserrat']
|
||||
}
|
||||
];
|
||||
|
||||
/** 按 id 查找行业(使用原生 find,避免覆盖数组方法) */
|
||||
HGB_Industries.find = function (id) {
|
||||
return Array.prototype.find.call(this, x => x.id === id) || this[0];
|
||||
};
|
||||
|
||||
/** 当前行业(默认留学教育) */
|
||||
HGB_Industries.current = function () {
|
||||
const id = localStorage.getItem('hgb_industry') || 'edu';
|
||||
return HGB_Industries.find(id);
|
||||
};
|
||||
|
||||
/** 设置当前行业 */
|
||||
HGB_Industries.setCurrent = function (id) {
|
||||
localStorage.setItem('hgb_industry', id);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
# 画个标 · 系统基线(构建自动生成,勿手改)
|
||||
|
||||
> 由 `scripts/build-baseline.js` 生成。素材/技能变更并构建后,重新运行 `node scripts/build-baseline.js` 即自动更新。
|
||||
> 执行「AI 指令库 指令一~四」前,先读本文件获取当前基线,勿依赖记忆中的数字。
|
||||
|
||||
## 生成时间
|
||||
|
||||
2026-08-24T11:30:15.593Z
|
||||
|
||||
## ① 图标库(共享,LOGO/头像/海报共用)
|
||||
|
||||
- 源数:224 | 图标总数:371504
|
||||
- 类型分布:icon 165 / emoji 27 / brand 32
|
||||
- 源清单(含 count/file/workspaces):js/data/manifest/icons.json
|
||||
|
||||
## ② 海报素材库
|
||||
|
||||
- 源数:25 | 素材总数:1582
|
||||
- 类型分布:palette 6 / decoration 4 / bg 3 / font 12
|
||||
- 源清单:js/data/manifest/posters.json
|
||||
|
||||
## ③ 技能库
|
||||
|
||||
- 共享技能:54(分类:layout 10 / color 9 / typography 8 / icon 8 / ai 6 / presentation 5 / export 8)
|
||||
- 海报技能:64(分类:layout 8 / color 8 / typography 8 / grid 8 / hierarchy 8 / print 8 / creative 8 / ai 8)
|
||||
- 表情技能:12(分类:motion 11 / emotion 1)
|
||||
- 三区覆盖(共享,按 scene/workspaces):LOGO 向 54 / 头像向 45 / 海报向 54 / 未标注 0
|
||||
- 三区覆盖(海报专属):LOGO 向 0 / 头像向 0 / 海报向 64
|
||||
- 技能明细:js/data/manifest/skills.json
|
||||
|
||||
## 指令使用提示
|
||||
|
||||
- 指令一(新增图标源):去重以本文件 ① 的源清单 + icon-sources.js 现有 id 为准
|
||||
- 指令二(新增海报源):去重以 ② + poster-sources.js 现有 id 为准
|
||||
- 指令三(新增技能):看 ③ 三区覆盖找缺口(头像向最少则优先补头像);表情向技能看表情技能分类分布
|
||||
@@ -0,0 +1,85 @@
|
||||
# 画个标 · 素材与技能库读取指南(AI / 外部项目专用)
|
||||
|
||||
> 本目录是「画个标」系统对外暴露的**机器可读出口**:三份 JSON manifest + 本指南。
|
||||
> 其他项目或 AI 要复用本库的素材/技能,**先读本指南 + 对应 manifest,再按需定向读数据文件**,即可准确命中,无需猜测文件名或解析大体积 JS 索引。
|
||||
|
||||
## 一、库里有什么(三套资源)
|
||||
|
||||
| 库 | manifest | 数据文件 | 数量(以对应 manifest 的 `total` 为准) |
|
||||
|---|---|---|---|
|
||||
| 共享图标库(LOGO/头像) | [icons.json](icons.json) | `js/data/sources/{id}.js` + `js/data/builtin-icons.js` | 222 源 / 370,268 图标 |
|
||||
| 头像风格(DiceBear) | [icons.json](icons.json)(`dicebear` 字段) | `js/data/sources/dicebear-{style}.js` | 55 风格 |
|
||||
| 海报素材库 | [posters.json](posters.json) | `js/data/posters/{id}.js` + `js/data/posters/{id}/files/` | 25 源 / 1,582 素材 |
|
||||
| 共享技能 | [skills.json](skills.json)(`shared`) | 实现代码 `js/skills/*.js` | 48 个 / 7 类 |
|
||||
| 海报技能 | [skills.json](skills.json)(`poster`) | 实现代码 `js/poster-skills/*.js` | 56 个 / 8 类 |
|
||||
| 表情技能 | [skills.json](skills.json)(`sticker`) | 实现代码 `js/sticker-skills/*.js` | 12 个 / 2 类 |
|
||||
|
||||
> 注:上表数量为快照,**一切以对应 manifest 内的 `total` / `count` 字段为准**(构建时自动更新,本 README 为手写维护)。
|
||||
|
||||
## 二、manifest 结构(标准字段)
|
||||
|
||||
### icons.json / posters.json
|
||||
```json
|
||||
{
|
||||
"kind": "icons", // icons | posters
|
||||
"total": 367988, // 库内素材总数
|
||||
"builtAt": "2026-08-23T…", // 构建时间(ISO)
|
||||
"idRule": "「源:名称」,如 lucide:graduation-cap",
|
||||
"sources": [ // 每个源一条(含实际内置数 count)
|
||||
{
|
||||
"id": "lucide", "type": "icon", // type: icon/emoji/brand(海报: palette/font/bg/decoration…)
|
||||
"name": "Lucide Icons", "nameCn": "Lucide 图标",
|
||||
"desc": "…", "style": "线条",
|
||||
"license": "ISC", "licenseNote": "…", // 协议务必先读,商用需合规
|
||||
"home": "…", "github": "…", "npm": "…",
|
||||
"format": "sprite", // 拉取方式:iconify/dir-files/gh-files/sprite/nodes/remix-files
|
||||
"coordText": "24 坐标", "variants": "…", "added": "2026-08-10",
|
||||
"count": 1767, // 该源实际内置数
|
||||
"file": "sources/lucide.js", // 该源数据文件相对路径(js/data/ 下)
|
||||
"workspaces": ["logo","avatar"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- **图标 id 规范**:内置库 `源:名称`(`lucide:graduation-cap`);行业图标为裸 id。
|
||||
- **数据文件** `js/data/sources/{id}.js` 内容形态:`window['HGB_Source_{id}'] = { icons: [{ id, name, categoryCn, svg, type }] }`。`svg` 为归一化到 24 viewBox 的 body 片段(含 path/几何),渲染时套 `<svg viewBox="0 0 24 24">`。
|
||||
- **头像**:`js/data/sources/dicebear-{style}.js` = `window.HGB_Dicebear_{style} = {…DiceBear 定义…}`(渲染器规则见 DiceBear 官方 schema)。
|
||||
|
||||
### posters.json
|
||||
- **素材 id**:`源:相对路径`(如 `fontsource-inter:latin-400-normal.woff2`)。
|
||||
- 数据文件 `js/data/posters/{id}.js` = `window['HGB_PosterSource_{id}'] = { items: [{ id, name, categoryCn, type, svg?|file?|data? }] }`;二进制(字体/位图)在 `js/data/posters/{id}/files/` 按 `file` 引用直读;配色为内联 `data`。
|
||||
|
||||
### skills.json
|
||||
```json
|
||||
{
|
||||
"kind": "skills",
|
||||
"shared": {
|
||||
"total": 48,
|
||||
"skills": [{
|
||||
"id": "contrast-check", "name": "对比度检查", "category": "color",
|
||||
"emoji": "🎨", "scene": "…", "summary": "…", "params": "…", "source": "自研",
|
||||
"step": 3, // 设计流水线步骤 1品牌洞察/2方案生成/3方案评审/4迭代优化/5交付导出
|
||||
"kind": "check", // gen 生成建议 / check 方案检查 / deliver 交付导出
|
||||
"workspaces": ["logo","avatar","poster"]
|
||||
}]
|
||||
},
|
||||
"poster": { "total": 56, "skills": [ …同字段(无 step/kind,按 category 8 类)… ] },
|
||||
"sticker": { "total": 12, "skills": [ …同字段(无 step/kind,按 category motion/emotion 2 类)… ] }
|
||||
}
|
||||
```
|
||||
- 技能元数据是纯数据可直接当知识用;**run/test 是函数实现**,仅供系统执行(`HGB_Skills.run(id, args)` / `HGB_Skills.test(id)`),外部如需「执行技能」请加载 `js/skills/*.js`(含数据依赖)后调用 API。
|
||||
- 表情技能(`sticker`)实现代码 `js/sticker-skills/*.js`,API 为 `HGB_StickerSkills.run(id, args)` / `HGB_StickerSkills.test(id)`,`workspaces` 统一 `["emoji"]`;其动画决策依赖 `js/core/motion.js`(ANIMS 注册表,预览与导出逐帧采样共用同一套 `apply`)。
|
||||
|
||||
## 三、AI 推荐读取流程
|
||||
|
||||
1. 先读本指南(README.md)了解库结构与 id 规范;
|
||||
2. 读对应 manifest 定位「有哪些源 / 每源多少 / 协议是否合规」;
|
||||
3. 需要具体素材时,按 manifest 的 `file` 字段定向读 `js/data/sources/{id}.js`(或 `posters/{id}.js`),避免读 2.4MB 单行索引 `builtin-icons.js`;
|
||||
4. 协议存疑(BY-SA/BY-NC/BY-ND/GPL)不商用;多色源(type emoji/brand)保留原色。
|
||||
|
||||
## 四、维护(构建后 manifest 自动更新)
|
||||
|
||||
- 图标:`node scripts/build-icons.js` → 重写 `builtin-icons.js` + `sources/{id}.js` + `manifest/icons.json`
|
||||
- 海报:`node scripts/build-posters.js` → 重写 `poster-builtin.js` + `posters/{id}.js` + `manifest/posters.json`
|
||||
- 技能:`node scripts/build-skill-manifest.js` → 重写 `manifest/skills.json`(技能元数据;技能代码改动后需重跑)
|
||||
- 前端页面 sources.html / poster-sources.html 优先读 manifest(fetch 失败自动回退内置配置),skills 页面直接读技能 API(与 manifest 同源)。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,509 @@
|
||||
{
|
||||
"kind": "posters",
|
||||
"total": 1582,
|
||||
"builtAt": "2026-08-23T11:12:08.130Z",
|
||||
"guide": "js/data/manifest/README.md",
|
||||
"idRule": "「源:相对路径」,如 fontsource-inter:latin-400-normal.woff2",
|
||||
"sources": [
|
||||
{
|
||||
"id": "uigradients",
|
||||
"type": "palette",
|
||||
"name": "uiGradients",
|
||||
"nameCn": "UI 渐变配色库",
|
||||
"desc": "GitHub 社区精选 200+ 款渐变配色方案,含品牌渐变、自然风光与经典风格渐变,可直接用于海报背景与主视觉",
|
||||
"license": "MIT",
|
||||
"licenseNote": "MIT 协议,可自由商用、修改、再分发,需保留版权声明",
|
||||
"home": "https://uigradients.com",
|
||||
"github": "https://github.com/ghosh/uiGradients",
|
||||
"format": "gh-files",
|
||||
"coordText": "配色数据(gradients.json,顶层数组:名称 + 十六进制色值序列)",
|
||||
"variants": "单文件 gradients.json,约 200 款渐变",
|
||||
"added": "2026-08-20",
|
||||
"count": 1,
|
||||
"file": "posters/uigradients.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "twemoji",
|
||||
"type": "decoration",
|
||||
"name": "Twemoji",
|
||||
"nameCn": "Twitter 彩色表情",
|
||||
"desc": "Twitter/X 官方彩色 Emoji 表情 SVG(CC BY 4.0),适合作为海报点缀符号与情感化视觉元素",
|
||||
"license": "CC BY 4.0",
|
||||
"licenseNote": "CC BY 4.0,可商用,使用需署名(Twitter/X 官方表情库)",
|
||||
"home": "https://twemoji.twitter.com",
|
||||
"github": "https://github.com/jdecked/twemoji",
|
||||
"format": "gh-files",
|
||||
"coordText": "36×36 视口彩色矢量表情,保留原始比例",
|
||||
"variants": "表情全集 3600+ 过大,首批 maxItems 限量收录 500 个常用表情(活动/运动/物品/符号),后续可按需扩量",
|
||||
"added": "2026-08-20",
|
||||
"count": 500,
|
||||
"file": "posters/twemoji.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "heropatterns",
|
||||
"type": "bg",
|
||||
"name": "Hero Patterns",
|
||||
"nameCn": "可平铺背景底纹",
|
||||
"desc": "Steve Schoger 经典背景底纹集合(lowmess 维护 v2.1),约 90 款几何/纹理/趣味图案,可无缝平铺,直接作为海报背景底纹",
|
||||
"license": "MIT",
|
||||
"licenseNote": "MIT 协议,可自由商用、修改、再分发,需保留版权声明",
|
||||
"home": "https://heropatterns.com",
|
||||
"github": "https://github.com/lowmess/hero-patterns",
|
||||
"format": "gh-files",
|
||||
"coordText": "平铺单元 SVG(各图案尺寸不一,保留原始 viewBox)",
|
||||
"variants": "全量收录 ~90 款,默认黑色填充(可叠加底色或换色后使用)",
|
||||
"added": "2026-08-21",
|
||||
"count": 87,
|
||||
"file": "posters/heropatterns.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "league-spartan",
|
||||
"type": "font",
|
||||
"name": "League Spartan",
|
||||
"nameCn": "League Spartan 字体",
|
||||
"desc": "League of Moveable Type 出品的现代几何无衬线字体(SIL OFL 1.1),粗细跨度 Thin→Black 共 9 个字重,适合海报大标题与品牌主视觉",
|
||||
"license": "OFL-1.1",
|
||||
"licenseNote": "SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名",
|
||||
"home": "https://www.theleagueofmoveabletype.com/league-spartan",
|
||||
"github": "https://github.com/theleagueof/league-spartan",
|
||||
"format": "gh-files",
|
||||
"coordText": "TTF 静态字体文件(9 个字重,浏览器 @font-face 直载)",
|
||||
"variants": "Thin / ExtraLight / Light / Regular / Medium / SemiBold / Bold / ExtraBold / Black",
|
||||
"added": "2026-08-21",
|
||||
"count": 9,
|
||||
"file": "posters/league-spartan.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "open-color",
|
||||
"type": "palette",
|
||||
"name": "Open Color",
|
||||
"nameCn": "Open Color 现代色板",
|
||||
"desc": "MIT 开源现代色板:13 个色系(灰/红/粉/紫/蓝/青/绿/黄/橙…)× 每个 10 级明度,共 130 色,色调统一和谐,适合海报背景、强调色与数据可视化配色",
|
||||
"license": "MIT",
|
||||
"licenseNote": "MIT 协议,可自由商用、修改、再分发,需保留版权声明",
|
||||
"home": "https://yeun.github.io/open-color/",
|
||||
"github": "https://github.com/yeun/open-color",
|
||||
"format": "gh-files",
|
||||
"coordText": "配色数据(open-color.json,13 色系 × 10 明度十六进制色值)",
|
||||
"variants": "单文件 open-color.json,130 色(13 色系 × 10 级)",
|
||||
"added": "2026-08-21",
|
||||
"count": 1,
|
||||
"file": "posters/open-color.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "catppuccin",
|
||||
"type": "palette",
|
||||
"name": "Catppuccin",
|
||||
"nameCn": "Catppuccin 主题色板",
|
||||
"desc": "社区热门主题色板:Latte / Frappe / Macchiato / Mocha 4 套主题 × 26 色(含玫瑰水/火焰/粉/葡萄紫等柔和高对比色),风格柔和不刺眼,适合海报背景与强调色",
|
||||
"license": "MIT",
|
||||
"licenseNote": "MIT 协议,可自由商用、修改、再分发,需保留版权声明",
|
||||
"home": "https://catppuccin.com",
|
||||
"github": "https://github.com/catppuccin/palette",
|
||||
"format": "gh-files",
|
||||
"coordText": "配色数据(palette.json 4 主题 × 26 色 + palette-porcelain.json 变体)",
|
||||
"variants": "2 个 JSON:主色板 palette.json + 陶瓷变体 palette-porcelain.json",
|
||||
"added": "2026-08-21",
|
||||
"count": 2,
|
||||
"file": "posters/catppuccin.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "coolshapes",
|
||||
"type": "decoration",
|
||||
"name": "Coolshapes",
|
||||
"nameCn": "几何装饰形状",
|
||||
"desc": "100% 开源几何装饰形状包:椭圆/花朵/月亮/多边形/星星/三角/轮辐等 66 款矢量图形(200×200),适合作为海报点缀元素与插画局部",
|
||||
"license": "MIT",
|
||||
"licenseNote": "MIT 协议,可自由商用、修改、再分发,需保留版权声明",
|
||||
"home": "https://coolshap.es",
|
||||
"github": "https://github.com/realvjy/coolshapes",
|
||||
"format": "gh-files",
|
||||
"coordText": "200×200 视口矢量图形,保留原始坐标(可等比缩放后叠加使用)",
|
||||
"variants": "全量收录 66 款(ellipse/flower/misc/moon/polygon/star/triangle/wheel)",
|
||||
"added": "2026-08-21",
|
||||
"count": 66,
|
||||
"file": "posters/coolshapes.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fontsource-playfair",
|
||||
"type": "font",
|
||||
"name": "Playfair Display",
|
||||
"nameCn": "Playfair Display 字体",
|
||||
"desc": "经典高对比衬线标题字体(OFL 1.1),字重 400-900 可变 + 斜体,优雅文艺气质,适合海报大标题与品牌视觉",
|
||||
"license": "OFL-1.1",
|
||||
"licenseNote": "SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名",
|
||||
"home": "https://fonts.google.com/specimen/Playfair+Display",
|
||||
"github": "https://github.com/clauseggers/Playfair-Display",
|
||||
"format": "dir-files",
|
||||
"coordText": "可变字体 woff2 子集(latin / latin-ext / cyrillic / vietnamese × normal / italic)",
|
||||
"variants": "8 个子集文件,字重 400-900 可调(wght 轴),拉丁子集覆盖英文/数字/西文标点",
|
||||
"added": "2026-08-21",
|
||||
"count": 8,
|
||||
"file": "posters/fontsource-playfair.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fontsource-oswald",
|
||||
"type": "font",
|
||||
"name": "Oswald",
|
||||
"nameCn": "Oswald 字体",
|
||||
"desc": "高辨识度压缩无衬线标题字体(OFL 1.1),窄而有力的字形,适合海报标题、数据标注与运动/促销风格",
|
||||
"license": "OFL-1.1",
|
||||
"licenseNote": "SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名",
|
||||
"home": "https://fonts.google.com/specimen/Oswald",
|
||||
"github": "https://github.com/googlefonts/OswaldFont",
|
||||
"format": "dir-files",
|
||||
"coordText": "可变字体 woff2 子集(latin / latin-ext / cyrillic / cyrillic-ext / vietnamese)",
|
||||
"variants": "5 个子集文件,字重 200-700 可调(wght 轴),压缩宽高比醒目",
|
||||
"added": "2026-08-21",
|
||||
"count": 5,
|
||||
"file": "posters/fontsource-oswald.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fontsource-space-grotesk",
|
||||
"type": "font",
|
||||
"name": "Space Grotesk",
|
||||
"nameCn": "Space Grotesk 字体",
|
||||
"desc": "几何无衬线标题字体(OFL 1.1),带有现代科技与实验气质,适合科技/数码/潮流风格海报与界面视觉",
|
||||
"license": "OFL-1.1",
|
||||
"licenseNote": "SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名",
|
||||
"home": "https://fonts.google.com/specimen/Space+Grotesk",
|
||||
"github": "https://github.com/floriankarsten/space-grotesk",
|
||||
"format": "dir-files",
|
||||
"coordText": "可变字体 woff2 子集(latin / latin-ext / vietnamese)",
|
||||
"variants": "3 个子集文件,字重 300-700 可调(wght 轴),几何字形现代感强",
|
||||
"added": "2026-08-21",
|
||||
"count": 3,
|
||||
"file": "posters/fontsource-space-grotesk.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fontsource-inter",
|
||||
"type": "font",
|
||||
"name": "Inter",
|
||||
"nameCn": "Inter 字体",
|
||||
"desc": "被誉为「最佳界面字体」的现代无衬线字体(OFL 1.1),字重 100-900 跨度极大且阅读舒适,海报正文、数据信息区与界面设计的最佳选择",
|
||||
"license": "OFL-1.1",
|
||||
"licenseNote": "SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名",
|
||||
"home": "https://fonts.google.com/specimen/Inter",
|
||||
"github": "https://github.com/rsms/inter",
|
||||
"format": "dir-files",
|
||||
"coordText": "可变字体 woff2 子集(latin / latin-ext / cyrillic × normal / italic)",
|
||||
"variants": "6 个子集文件,字重 100-900 可调(wght 轴),海报正文/信息区首选无衬线",
|
||||
"added": "2026-08-21",
|
||||
"count": 6,
|
||||
"file": "posters/fontsource-inter.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fontsource-jetbrains-mono",
|
||||
"type": "font",
|
||||
"name": "JetBrains Mono",
|
||||
"nameCn": "JetBrains Mono 字体",
|
||||
"desc": "JetBrains 出品的等宽编程字体(OFL 1.1),字形锐利辨识度高,适合科技/数码/代码风海报、数据标注与信息图",
|
||||
"license": "OFL-1.1",
|
||||
"licenseNote": "SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名",
|
||||
"home": "https://fonts.google.com/specimen/JetBrains+Mono",
|
||||
"github": "https://github.com/JetBrains/JetBrainsMono",
|
||||
"format": "dir-files",
|
||||
"coordText": "可变字体 woff2 子集(latin / latin-ext / cyrillic × normal / italic)",
|
||||
"variants": "6 个子集文件,字重 100-800 可调(wght 轴),等宽字形适合技术/数据风格",
|
||||
"added": "2026-08-21",
|
||||
"count": 6,
|
||||
"file": "posters/fontsource-jetbrains-mono.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fontsource-cinzel",
|
||||
"type": "font",
|
||||
"name": "Cinzel",
|
||||
"nameCn": "Cinzel 字体",
|
||||
"desc": "灵感源自古罗马碑刻的装饰衬线字体(OFL 1.1),字形庄重典雅,适合历史/文艺/高端品牌海报大标题",
|
||||
"license": "OFL-1.1",
|
||||
"licenseNote": "SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名",
|
||||
"home": "https://fonts.google.com/specimen/Cinzel",
|
||||
"github": "https://github.com/ndiscovered/cinzel",
|
||||
"format": "dir-files",
|
||||
"coordText": "可变字体 woff2 子集(latin / latin-ext,无斜体)",
|
||||
"variants": "2 个子集文件,字重 400-900 可调(wght 轴),罗马碑刻风格标题衬线",
|
||||
"added": "2026-08-21",
|
||||
"count": 2,
|
||||
"file": "posters/fontsource-cinzel.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fontsource-lora",
|
||||
"type": "font",
|
||||
"name": "Lora",
|
||||
"nameCn": "Lora 字体",
|
||||
"desc": "气质古典的正文衬线字体(OFL 1.1),在屏幕与印刷上均有出色可读性,适合正文段落、引言与文艺风格海报",
|
||||
"license": "OFL-1.1",
|
||||
"licenseNote": "SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名",
|
||||
"home": "https://fonts.google.com/specimen/Lora",
|
||||
"github": "https://github.com/cyrealtype/Lora-Cyrillic",
|
||||
"format": "dir-files",
|
||||
"coordText": "可变字体 woff2 子集(latin / latin-ext × normal / italic)",
|
||||
"variants": "4 个子集文件,字重 400-700 可调(wght 轴),古典衬线正文首选",
|
||||
"added": "2026-08-21",
|
||||
"count": 4,
|
||||
"file": "posters/fontsource-lora.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "patternfills",
|
||||
"type": "bg",
|
||||
"name": "PatternFills",
|
||||
"nameCn": "图案填充底纹",
|
||||
"desc": "SVG 图案填充集合(MIT):横/竖条纹、十字、菱格、点阵等 50 款经典灰阶平铺纹理,单元 10×10 无缝平铺,适合海报背景底纹与信息图分区底",
|
||||
"license": "MIT",
|
||||
"licenseNote": "MIT 协议,可自由商用、修改、再分发,需保留版权声明",
|
||||
"home": "https://iros.github.io/patternfills/",
|
||||
"github": "https://github.com/iros/patternfills",
|
||||
"format": "gh-files",
|
||||
"coordText": "10×10 平铺单元 SVG(灰阶 #aaa 线条,可叠底色或换色后使用)",
|
||||
"variants": "全量收录 50 款(水平/垂直条纹、十字、菱格、点阵、犬牙纹等)",
|
||||
"added": "2026-08-21",
|
||||
"count": 49,
|
||||
"file": "posters/patternfills.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "brand-colors",
|
||||
"type": "palette",
|
||||
"name": "Brand Colors",
|
||||
"nameCn": "品牌色板",
|
||||
"desc": "收录全球 130+ 知名品牌官方主色(MIT),单一 JSON 按品牌名映射十六进制色值(部分含多色变体),适合海报调用品牌主色、做品牌对比或多品牌视觉素材",
|
||||
"license": "MIT",
|
||||
"licenseNote": "MIT 协议,可自由商用、修改、再分发,需保留版权声明",
|
||||
"home": "https://brandcolors.net",
|
||||
"github": "https://github.com/reimertz/brand-colors",
|
||||
"format": "gh-files",
|
||||
"coordText": "配色数据(brandColors.json,品牌名 → 十六进制主色扁平映射)",
|
||||
"variants": "单文件 brandColors.json,130+ 品牌、部分含多色变体(-2/-3 后缀)",
|
||||
"added": "2026-08-21",
|
||||
"count": 1,
|
||||
"file": "posters/brand-colors.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pixelarticons",
|
||||
"type": "decoration",
|
||||
"name": "Pixelarticons",
|
||||
"nameCn": "像素风图标",
|
||||
"desc": "手工像素风矢量图标集(MIT,24×24 网格、无抗锯齿),单色可任意换色,适合复古游戏/像素/潮流风格海报点缀元素与信息符号",
|
||||
"license": "MIT",
|
||||
"licenseNote": "MIT 协议,可自由商用、修改、再分发,需保留版权声明",
|
||||
"home": "https://pixelarticons.com",
|
||||
"github": "https://github.com/halfmage/pixelarticons",
|
||||
"format": "dir-files",
|
||||
"coordText": "24×24 视口单色像素图标(fill=currentColor,可换色)",
|
||||
"variants": "maxItems 限量收录 400 枚基础图标(剔除 -sharp 变体,全量 1036 枚)",
|
||||
"added": "2026-08-21",
|
||||
"count": 399,
|
||||
"file": "posters/pixelarticons.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fontsource-montserrat",
|
||||
"type": "font",
|
||||
"name": "Montserrat",
|
||||
"nameCn": "Montserrat 字体",
|
||||
"desc": "几何无衬线标题字体(OFL 1.1),字重 100-900 可变 + 斜体,字形温和圆润,海报/品牌设计领域全球最常用的全能标题字体",
|
||||
"license": "OFL-1.1",
|
||||
"licenseNote": "SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名",
|
||||
"home": "https://fonts.google.com/specimen/Montserrat",
|
||||
"github": "https://github.com/JulietaUla/Montserrat",
|
||||
"format": "dir-files",
|
||||
"coordText": "可变字体 woff2 子集(latin / latin-ext × normal / italic)",
|
||||
"variants": "4 个子集文件,字重 100-900 可调(wght 轴),标题与正文全能无衬线",
|
||||
"added": "2026-08-21",
|
||||
"count": 4,
|
||||
"file": "posters/fontsource-montserrat.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fontsource-bebas-neue",
|
||||
"type": "font",
|
||||
"name": "Bebas Neue",
|
||||
"nameCn": "Bebas Neue 字体",
|
||||
"desc": "经典压缩无衬线大写标题字体(OFL 1.1),窄高字形醒目有力,电影海报/时尚/运动/促销风格的标题标杆字体",
|
||||
"license": "OFL-1.1",
|
||||
"licenseNote": "SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名",
|
||||
"home": "https://fonts.google.com/specimen/Bebas+Neue",
|
||||
"github": "https://github.com/dharmatype/Bebas-Neue",
|
||||
"format": "dir-files",
|
||||
"coordText": "静态字体 woff2 子集(latin / latin-ext,仅 400 常规字重)",
|
||||
"variants": "2 个子集文件,单字重 400 压缩大写,适合大号标题与品牌字标",
|
||||
"added": "2026-08-21",
|
||||
"count": 2,
|
||||
"file": "posters/fontsource-bebas-neue.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "material-colors",
|
||||
"type": "palette",
|
||||
"name": "Material Colors",
|
||||
"nameCn": "Material Design 色板",
|
||||
"desc": "Google Material Design 经典官方色板(MIT):19 色系 × 10 级明度 + 4 个 accent 高饱和变体 + 深浅文本/图标色,共 260+ 色值,色彩体系科学统一,适合海报系统配色与信息可视化",
|
||||
"license": "MIT",
|
||||
"licenseNote": "MIT 协议,可自由商用、修改、再分发,需保留版权声明",
|
||||
"home": "https://material.io/design/color",
|
||||
"github": "https://github.com/shuhei/material-colors",
|
||||
"format": "dir-files",
|
||||
"coordText": "配色数据(colors.json,色系名 → 明度/变体 → 十六进制;含 rgba 文本/图标色)",
|
||||
"variants": "单文件 colors.json,260+ 色值(19 色系 × 50-900/a100-a700 + 文本/图标色)",
|
||||
"added": "2026-08-21",
|
||||
"count": 1,
|
||||
"file": "posters/material-colors.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "circle-flags",
|
||||
"type": "decoration",
|
||||
"name": "Circle Flags",
|
||||
"nameCn": "圆形国旗图标",
|
||||
"desc": "268+ 枚 ISO 3166-1 各国圆形国旗矢量图标(MIT),国旗元素圆角化处理并带白色描边,适合海报中的国家/地区标识、旅行与国际主题、多国对比等装饰元素",
|
||||
"license": "MIT",
|
||||
"licenseNote": "MIT 协议,可自由商用、修改、再分发,需保留版权声明",
|
||||
"home": "https://hatscripts.github.io/circle-flags/",
|
||||
"github": "https://github.com/HatScripts/circle-flags",
|
||||
"format": "gh-files",
|
||||
"coordText": "圆形国旗 SVG(各国比例徽章造型,viewBox 各异逐图标归一化)",
|
||||
"variants": "全量收录 268+ 枚 ISO 3166-1 圆形国旗(cn/us/jp/... 两字母代码命名)",
|
||||
"added": "2026-08-22",
|
||||
"count": 405,
|
||||
"file": "posters/circle-flags.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "glacial-strat-patterns",
|
||||
"type": "bg",
|
||||
"name": "Glacial Strat Patterns",
|
||||
"nameCn": "地质层理图案",
|
||||
"desc": "地质层理结构矢量图案集(图案资产 CC BY 4.0):13 款模拟冰川/沉积岩地层剖面的层理纹理,多层渐变与弯折地质构造,适合地质、自然、科学主题海报的背景与氛围底纹",
|
||||
"license": "CC-BY-4.0",
|
||||
"licenseNote": "图案资产遵循 CC BY 4.0(商用/修改需署名:MNiMORPH/glacial-strat-patterns);仓库代码部分为 MIT",
|
||||
"home": "https://github.com/MNiMORPH/glacial-strat-patterns",
|
||||
"github": "https://github.com/MNiMORPH/glacial-strat-patterns",
|
||||
"format": "gh-files",
|
||||
"coordText": "地质层理剖面 SVG(地质符号命名,如 Cdm/Dcm/Gms,多层渐变填充)",
|
||||
"variants": "全量收录 13 款(svg/ 目录:Cdm/Dcm/Dmm/Dms/Em/Fl/Fm/Gh/Gms/P/R/Sm/Sr)",
|
||||
"added": "2026-08-22",
|
||||
"count": 13,
|
||||
"file": "posters/glacial-strat-patterns.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fontsource-poppins",
|
||||
"type": "font",
|
||||
"name": "Poppins",
|
||||
"nameCn": "Poppins 字体",
|
||||
"desc": "现代几何无衬线字体(OFL 1.1),圆形几何字干、比例均衡,兼具标题力量感与正文易读性,海报/品牌/UI 设计最热门的全能字体之一",
|
||||
"license": "OFL-1.1",
|
||||
"licenseNote": "SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名",
|
||||
"home": "https://fonts.google.com/specimen/Poppins",
|
||||
"github": "https://github.com/itfoundry/Poppins",
|
||||
"format": "dir-files",
|
||||
"coordText": "静态字体 woff2 子集(latin,字重 300/400/500/600/700 常规)",
|
||||
"variants": "5 个 latin 子集文件,常用字重 300-700,正文与标题两用几何无衬线",
|
||||
"added": "2026-08-22",
|
||||
"count": 5,
|
||||
"file": "posters/fontsource-poppins.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fontsource-caveat",
|
||||
"type": "font",
|
||||
"name": "Caveat",
|
||||
"nameCn": "Caveat 手写字体",
|
||||
"desc": "Google Fonts 最热门的手写风格字体(OFL 1.1),笔触自然、随性亲和,字重 400-700 可变,适合海报手账/文艺/婚礼/儿童/美食等亲和力场景的标题、引言与手写感点缀",
|
||||
"license": "OFL-1.1",
|
||||
"licenseNote": "SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名",
|
||||
"home": "https://fonts.google.com/specimen/Caveat",
|
||||
"github": "https://github.com/googlefonts/caveat",
|
||||
"format": "dir-files",
|
||||
"coordText": "可变字体 woff2 子集(latin / latin-ext × wght 轴,400-700 可调)",
|
||||
"variants": "2 个子集文件,字重 400-700 可调(wght 轴),自然手写风标题与引言",
|
||||
"added": "2026-08-23",
|
||||
"count": 2,
|
||||
"file": "posters/fontsource-caveat.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chinese-colors",
|
||||
"type": "palette",
|
||||
"name": "Chinese Traditional Colors",
|
||||
"nameCn": "中国传统色",
|
||||
"desc": "中国传统色彩体系:粉鳳仙/荷花紅/青蓮紫/黛藍等 200+ 款典雅配色,每条含中文名 + 拼音 + 十六进制色值,适合国风/节日/婚礼/茶饮等传统文化主题海报配色",
|
||||
"license": "ISC",
|
||||
"licenseNote": "ISC 协议,可自由商用、修改、再分发(等同 MIT 的宽松许可)",
|
||||
"home": "https://github.com/hecodeit/chinese-colors",
|
||||
"github": "https://github.com/hecodeit/chinese-colors",
|
||||
"format": "dir-files",
|
||||
"coordText": "配色数据(colors.json,顶层数组:中文名 + 拼音 + 十六进制色值)",
|
||||
"variants": "单文件 colors.json,320 款中国传统色(粉/紫/紅/黄/绿/蓝/黛等色系,含拼音标注)",
|
||||
"added": "2026-08-23",
|
||||
"count": 1,
|
||||
"file": "posters/chinese-colors.js",
|
||||
"workspaces": [
|
||||
"poster"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 画个标 · 留学教育行业色彩方案推荐系统
|
||||
* 每套方案:主题色 / 辅助色 / 点缀色 / 背景 / 适用场景说明
|
||||
*/
|
||||
const HGB_Palettes = [
|
||||
{
|
||||
id: 'academic-blue',
|
||||
name: '学术蓝',
|
||||
desc: '沉稳专业,国际教育主流色',
|
||||
colors: ['#1B4F72', '#2980B9', '#AED6F1', '#F4F6F7'],
|
||||
scene: '留学中介、国际学校'
|
||||
},
|
||||
{
|
||||
id: 'ivy-green',
|
||||
name: '常春藤绿',
|
||||
desc: '常春藤盟校气质,学术与成长',
|
||||
colors: ['#1E8449', '#27AE60', '#D5F5E3', '#FFFFFF'],
|
||||
scene: '本科留学、精英教育'
|
||||
},
|
||||
{
|
||||
id: 'crimson',
|
||||
name: '典雅红',
|
||||
desc: '哈佛红传承,权威与荣耀',
|
||||
colors: ['#7B241C', '#C0392B', '#F5B7B1', '#FDEDEC'],
|
||||
scene: '研究生申请、名校项目'
|
||||
},
|
||||
{
|
||||
id: 'royal-gold',
|
||||
name: '皇家金',
|
||||
desc: '贵族气质,彰显学历含金量',
|
||||
colors: ['#7D6608', '#D4AC0D', '#F9E79F', '#FEF9E7'],
|
||||
scene: '商学院、高端留学服务'
|
||||
},
|
||||
{
|
||||
id: 'ocean-blue',
|
||||
name: '海洋蓝',
|
||||
desc: '向往远航,探索世界的决心',
|
||||
colors: ['#154360', '#2E86C1', '#D6EAF8', '#FFFFFF'],
|
||||
scene: '海外游学、交换项目'
|
||||
},
|
||||
{
|
||||
id: 'violet',
|
||||
name: '学术紫',
|
||||
desc: '智慧与创造,文理学院风',
|
||||
colors: ['#4A235A', '#8E44AD', '#D2B4DE', '#F5EEF8'],
|
||||
scene: '艺术留学、文理学院'
|
||||
},
|
||||
{
|
||||
id: 'oxford',
|
||||
name: '牛津灰红',
|
||||
desc: '传统英伦,学院制服配色',
|
||||
colors: ['#566573', '#CB4335', '#F2F3F4', '#FFFFFF'],
|
||||
scene: '英联邦国家留学'
|
||||
},
|
||||
{
|
||||
id: 'sky-dream',
|
||||
name: '梦想天蓝',
|
||||
desc: '清新明亮,追梦与活力',
|
||||
colors: ['#2471A3', '#5DADE2', '#FDFEFE', '#EBF5FB'],
|
||||
scene: '低龄留学、语言培训'
|
||||
}
|
||||
];
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,698 @@
|
||||
/* ============================================================
|
||||
* 海报素材来源配置(唯一权威清单)
|
||||
* 用途:
|
||||
* 1. 构建脚本 scripts/build-posters.js require 本文件获取下载/解析配置
|
||||
* 2. 「海报素材」页面 poster-sources.html 读取本文件渲染来源清单
|
||||
* 新增来源步骤:
|
||||
* 1. 在数组末尾追加配置(GitHub 直拉 gh-files / npm dir-files)
|
||||
* 2. 运行 node scripts/build-posters.js 重新构建
|
||||
* 3. poster.html / poster-sources.html / poster-skills.html 中 bump ?v= 版本
|
||||
* 字段说明:
|
||||
* id 唯一 id(内置库「源:名称」前缀)
|
||||
* type 素材类型:bg(背景底纹) / illustration(插画) / template(成品模板)
|
||||
* decoration(装饰元素) / mockup(展示样机) / photo(无版权图片)
|
||||
* font(字体) / palette(配色方案)
|
||||
* name 英文名 nameCn 中文名 desc 一句话风格描述
|
||||
* license 协议 licenseNote 协议中文说明(署名要求等)
|
||||
* home 官网 github GitHub 仓库
|
||||
* format 拉取方式:gh-files(GitHub 仓库直拉)| dir-files(npm 目录枚举)
|
||||
* gh/ghBranch GitHub 仓库与分支(format=gh-files 必填)
|
||||
* npm npm 包名(format=dir-files 必填)
|
||||
* dirs 目标目录(gh-files / dir-files 均用)
|
||||
* ext 文件扩展名过滤(默认 .svg;海报可含 .png/.jpg/.json/.ttf)
|
||||
* scaleMode wrap(保留原始坐标/比例,适合位图/模板)| body(缩放几何,适合纯矢量装饰)
|
||||
* coordText 坐标系说明(页面展示)
|
||||
* variants 变体说明(页面展示)
|
||||
* added 收录日期(页面展示)
|
||||
* workspaces 适用工作区(海报库固定 ['poster'];预留扩展)
|
||||
* ============================================================ */
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
|
||||
else root.HGB_PosterSources = factory();
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
return [
|
||||
/* ============================================================
|
||||
* 首批合规源(2026-08-20 录入,license 逐条核实 + jsDelivr 可拉取验证):
|
||||
* uiGradients 配色 palette(MIT,gradients.json 顶层为 [{name, colors}] 数组)
|
||||
* twemoji 装饰 decoration(CC BY 4.0,assets/svg 表情全量过大,maxItems 限量收录)
|
||||
* 已筛选但暂不可用(记录备查):
|
||||
* unDraw 自定义协议明令禁止 AI/机器学习训练,与 AI 海报生成冲突 → 排除
|
||||
* Open Doodles CC0 但素材托管于 Dropbox/Webflow,无 GitHub 仓库可直拉 → 不可用
|
||||
* Hero Patterns 官网声明免费但仓库无 LICENSE 文件 → 合规风险,待定
|
||||
* open-peeps / humaaans / MagicPattern svg-backgrounds jsDelivr 未索引(404)→ 不可用
|
||||
* svg-patterns npm 包 p/*.js 为依赖 virtual-dom 的 JS 模板(非静态 SVG)→ 待定
|
||||
* subtlepatterns npm 包存在但许可证未定(Toptal 自定义条款)→ 待定
|
||||
* ============================================================ */
|
||||
{
|
||||
id: 'uigradients',
|
||||
type: 'palette',
|
||||
name: 'uiGradients',
|
||||
nameCn: 'UI 渐变配色库',
|
||||
desc: 'GitHub 社区精选 200+ 款渐变配色方案,含品牌渐变、自然风光与经典风格渐变,可直接用于海报背景与主视觉',
|
||||
license: 'MIT',
|
||||
licenseNote: 'MIT 协议,可自由商用、修改、再分发,需保留版权声明',
|
||||
home: 'https://uigradients.com',
|
||||
github: 'https://github.com/ghosh/uiGradients',
|
||||
format: 'gh-files',
|
||||
gh: 'ghosh/uiGradients',
|
||||
ghBranch: 'master',
|
||||
dirs: ['.'],
|
||||
ext: ['.json'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '配色数据(gradients.json,顶层数组:名称 + 十六进制色值序列)',
|
||||
variants: '单文件 gradients.json,约 200 款渐变',
|
||||
added: '2026-08-20',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'twemoji',
|
||||
type: 'decoration',
|
||||
name: 'Twemoji',
|
||||
nameCn: 'Twitter 彩色表情',
|
||||
desc: 'Twitter/X 官方彩色 Emoji 表情 SVG(CC BY 4.0),适合作为海报点缀符号与情感化视觉元素',
|
||||
license: 'CC BY 4.0',
|
||||
licenseNote: 'CC BY 4.0,可商用,使用需署名(Twitter/X 官方表情库)',
|
||||
home: 'https://twemoji.twitter.com',
|
||||
github: 'https://github.com/jdecked/twemoji',
|
||||
format: 'gh-files',
|
||||
gh: 'jdecked/twemoji',
|
||||
ghBranch: 'master',
|
||||
dirs: ['assets/svg'],
|
||||
ext: ['.svg'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '36×36 视口彩色矢量表情,保留原始比例',
|
||||
exclude: '(?:^|/)1f1[ef]', // 剔除国旗段(1f1e6-1f1ff 区域指示符占位过多,海报价值低;锚定文件名而非完整路径)
|
||||
variants: '表情全集 3600+ 过大,首批 maxItems 限量收录 500 个常用表情(活动/运动/物品/符号),后续可按需扩量',
|
||||
added: '2026-08-20',
|
||||
maxItems: 500,
|
||||
workspaces: ['poster']
|
||||
},
|
||||
/* ============================================================
|
||||
* 第二批合规源(2026-08-21 录入,license 逐条核实 + jsDelivr 可拉取验证):
|
||||
* hero-patterns 背景底纹 bg(lowmess fork v2.1.0,MIT;src/patterns/*.js 为函数模板,
|
||||
* 构建时由 transform:'heropatterns' 专用解析器执行为静态 SVG,全量 ~90 款)
|
||||
* league-spartan 字体 font(SIL OFL 1.1;fonts/ttf/ 9 个字重 TTF,适合海报大标题)
|
||||
* open-color 配色 palette(MIT;open-color.json 13 色系 × 10 明度,经典现代色板)
|
||||
* 本轮探测排除(记录备查):
|
||||
* fxemoji / emoji-data / fluentui-emoji jsDelivr 包超 50MB 拒绝索引(403)→ 不可用
|
||||
* noto-emoji(旧 tag) jsDelivr 未索引该 tag(404)→ 不可用
|
||||
* Open Doodles / LadybugBot illustrations / illlustrations jsDelivr 未索引(404)→ 不可用
|
||||
* color-names(MIT) 31,915 行色名 CSV 无视觉预览,海报场景价值低 → 本轮不收录
|
||||
* Montserrat / Raleway 字体仓库 jsDelivr 超 50MB → 不可用;league-gothic 仅 .glyphs 源文件无成品字体
|
||||
* ostrich-sans / chunk(The League of Moveable Type) jsDelivr 未索引 或 仅 UFO 源文件无 TTF 成品 → 不可用
|
||||
* ============================================================ */
|
||||
{
|
||||
id: 'heropatterns',
|
||||
type: 'bg',
|
||||
name: 'Hero Patterns',
|
||||
nameCn: '可平铺背景底纹',
|
||||
desc: 'Steve Schoger 经典背景底纹集合(lowmess 维护 v2.1),约 90 款几何/纹理/趣味图案,可无缝平铺,直接作为海报背景底纹',
|
||||
license: 'MIT',
|
||||
licenseNote: 'MIT 协议,可自由商用、修改、再分发,需保留版权声明',
|
||||
home: 'https://heropatterns.com',
|
||||
github: 'https://github.com/lowmess/hero-patterns',
|
||||
format: 'gh-files',
|
||||
gh: 'lowmess/hero-patterns',
|
||||
ghBranch: '2.1.0',
|
||||
dirs: ['src/patterns'],
|
||||
ext: ['.js'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '平铺单元 SVG(各图案尺寸不一,保留原始 viewBox)',
|
||||
exclude: '(?:^|/)_', // 剔除 _defaults.js / _utils.js 工具模块(锚定文件名而非完整路径)
|
||||
transform: 'heropatterns', // 构建脚本专用:JS 函数模板 → 静态 SVG
|
||||
variants: '全量收录 ~90 款,默认黑色填充(可叠加底色或换色后使用)',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'league-spartan',
|
||||
type: 'font',
|
||||
name: 'League Spartan',
|
||||
nameCn: 'League Spartan 字体',
|
||||
desc: 'League of Moveable Type 出品的现代几何无衬线字体(SIL OFL 1.1),粗细跨度 Thin→Black 共 9 个字重,适合海报大标题与品牌主视觉',
|
||||
license: 'OFL-1.1',
|
||||
licenseNote: 'SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名',
|
||||
home: 'https://www.theleagueofmoveabletype.com/league-spartan',
|
||||
github: 'https://github.com/theleagueof/league-spartan',
|
||||
format: 'gh-files',
|
||||
gh: 'theleagueof/league-spartan',
|
||||
ghBranch: 'master',
|
||||
dirs: ['fonts/ttf'],
|
||||
ext: ['.ttf'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: 'TTF 静态字体文件(9 个字重,浏览器 @font-face 直载)',
|
||||
variants: 'Thin / ExtraLight / Light / Regular / Medium / SemiBold / Bold / ExtraBold / Black',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'open-color',
|
||||
type: 'palette',
|
||||
name: 'Open Color',
|
||||
nameCn: 'Open Color 现代色板',
|
||||
desc: 'MIT 开源现代色板:13 个色系(灰/红/粉/紫/蓝/青/绿/黄/橙…)× 每个 10 级明度,共 130 色,色调统一和谐,适合海报背景、强调色与数据可视化配色',
|
||||
license: 'MIT',
|
||||
licenseNote: 'MIT 协议,可自由商用、修改、再分发,需保留版权声明',
|
||||
home: 'https://yeun.github.io/open-color/',
|
||||
github: 'https://github.com/yeun/open-color',
|
||||
format: 'gh-files',
|
||||
gh: 'yeun/open-color',
|
||||
ghBranch: '1.9.1',
|
||||
dirs: ['.'],
|
||||
ext: ['.json'],
|
||||
exclude: '(?:^|/)params\\.json$', // 剔除站点元数据 params.json(非配色数据)
|
||||
scaleMode: 'wrap',
|
||||
coordText: '配色数据(open-color.json,13 色系 × 10 明度十六进制色值)',
|
||||
variants: '单文件 open-color.json,130 色(13 色系 × 10 级)',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
/* ============================================================
|
||||
* 第三批合规源(2026-08-21 录入,license 逐条核实 + jsDelivr 可拉取验证):
|
||||
* catppuccin/palette 配色 palette(MIT;palette.json 4 主题 × 26 色 + porcelain 变体)
|
||||
* coolshapes 装饰 decoration(MIT;public/shapes/svg 66 款几何/花朵/月亮/星星等海报点缀形状)
|
||||
* Playfair Display / Oswald / Space Grotesk 字体 font(OFL-1.1;fontsource 可变字体 woff2 子集,
|
||||
* latin/cyrillic/vietnamese 按需直载,适合海报标题与正文,字体包可自由商用)
|
||||
* 本轮探测排除(记录备查):
|
||||
* nord 仅 ACO/ASE/GPL 等专有色板格式,无 JSON 数据 → 不可直接收录
|
||||
* tokyonight.nvim extras JSON 为终端主题配置(含背景/前景/ANSI 索引色),非纯色板 → 不作为配色源
|
||||
* dracula/dracula R 语言包结构,无独立配色 JSON 文件 → 不可用
|
||||
* altercation/solarized jsDelivr 未索引(404)→ 不可用
|
||||
* googlefonts/noto-emoji jsDelivr 仓库超限(403)→ 不可用
|
||||
* ============================================================ */
|
||||
{
|
||||
id: 'catppuccin',
|
||||
type: 'palette',
|
||||
name: 'Catppuccin',
|
||||
nameCn: 'Catppuccin 主题色板',
|
||||
desc: '社区热门主题色板:Latte / Frappe / Macchiato / Mocha 4 套主题 × 26 色(含玫瑰水/火焰/粉/葡萄紫等柔和高对比色),风格柔和不刺眼,适合海报背景与强调色',
|
||||
license: 'MIT',
|
||||
licenseNote: 'MIT 协议,可自由商用、修改、再分发,需保留版权声明',
|
||||
home: 'https://catppuccin.com',
|
||||
github: 'https://github.com/catppuccin/palette',
|
||||
format: 'gh-files',
|
||||
gh: 'catppuccin/palette',
|
||||
ghBranch: 'v0.2.0',
|
||||
dirs: ['.'],
|
||||
ext: ['.json'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '配色数据(palette.json 4 主题 × 26 色 + palette-porcelain.json 变体)',
|
||||
variants: '2 个 JSON:主色板 palette.json + 陶瓷变体 palette-porcelain.json',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'coolshapes',
|
||||
type: 'decoration',
|
||||
name: 'Coolshapes',
|
||||
nameCn: '几何装饰形状',
|
||||
desc: '100% 开源几何装饰形状包:椭圆/花朵/月亮/多边形/星星/三角/轮辐等 66 款矢量图形(200×200),适合作为海报点缀元素与插画局部',
|
||||
license: 'MIT',
|
||||
licenseNote: 'MIT 协议,可自由商用、修改、再分发,需保留版权声明',
|
||||
home: 'https://coolshap.es',
|
||||
github: 'https://github.com/realvjy/coolshapes',
|
||||
format: 'gh-files',
|
||||
gh: 'realvjy/coolshapes',
|
||||
ghBranch: 'main',
|
||||
dirs: ['public/shapes/svg'],
|
||||
ext: ['.svg'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '200×200 视口矢量图形,保留原始坐标(可等比缩放后叠加使用)',
|
||||
variants: '全量收录 66 款(ellipse/flower/misc/moon/polygon/star/triangle/wheel)',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'fontsource-playfair',
|
||||
type: 'font',
|
||||
name: 'Playfair Display',
|
||||
nameCn: 'Playfair Display 字体',
|
||||
desc: '经典高对比衬线标题字体(OFL 1.1),字重 400-900 可变 + 斜体,优雅文艺气质,适合海报大标题与品牌视觉',
|
||||
license: 'OFL-1.1',
|
||||
licenseNote: 'SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名',
|
||||
home: 'https://fonts.google.com/specimen/Playfair+Display',
|
||||
github: 'https://github.com/clauseggers/Playfair-Display',
|
||||
format: 'dir-files',
|
||||
npm: '@fontsource-variable/playfair-display',
|
||||
npmVersion: '5.3.0',
|
||||
dirs: ['files'],
|
||||
ext: ['.woff2'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '可变字体 woff2 子集(latin / latin-ext / cyrillic / vietnamese × normal / italic)',
|
||||
variants: '8 个子集文件,字重 400-900 可调(wght 轴),拉丁子集覆盖英文/数字/西文标点',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'fontsource-oswald',
|
||||
type: 'font',
|
||||
name: 'Oswald',
|
||||
nameCn: 'Oswald 字体',
|
||||
desc: '高辨识度压缩无衬线标题字体(OFL 1.1),窄而有力的字形,适合海报标题、数据标注与运动/促销风格',
|
||||
license: 'OFL-1.1',
|
||||
licenseNote: 'SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名',
|
||||
home: 'https://fonts.google.com/specimen/Oswald',
|
||||
github: 'https://github.com/googlefonts/OswaldFont',
|
||||
format: 'dir-files',
|
||||
npm: '@fontsource-variable/oswald',
|
||||
npmVersion: '5.3.0',
|
||||
dirs: ['files'],
|
||||
ext: ['.woff2'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '可变字体 woff2 子集(latin / latin-ext / cyrillic / cyrillic-ext / vietnamese)',
|
||||
variants: '5 个子集文件,字重 200-700 可调(wght 轴),压缩宽高比醒目',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'fontsource-space-grotesk',
|
||||
type: 'font',
|
||||
name: 'Space Grotesk',
|
||||
nameCn: 'Space Grotesk 字体',
|
||||
desc: '几何无衬线标题字体(OFL 1.1),带有现代科技与实验气质,适合科技/数码/潮流风格海报与界面视觉',
|
||||
license: 'OFL-1.1',
|
||||
licenseNote: 'SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名',
|
||||
home: 'https://fonts.google.com/specimen/Space+Grotesk',
|
||||
github: 'https://github.com/floriankarsten/space-grotesk',
|
||||
format: 'dir-files',
|
||||
npm: '@fontsource-variable/space-grotesk',
|
||||
npmVersion: '5.3.0',
|
||||
dirs: ['files'],
|
||||
ext: ['.woff2'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '可变字体 woff2 子集(latin / latin-ext / vietnamese)',
|
||||
variants: '3 个子集文件,字重 300-700 可调(wght 轴),几何字形现代感强',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
/* ============================================================
|
||||
* 第四批合规源(2026-08-21 录入,license 逐条核实 + jsDelivr 可拉取验证):
|
||||
* Inter / JetBrains Mono / Cinzel / Lora 字体 font(OFL-1.1;fontsource 可变字体 woff2 子集,
|
||||
* 分别覆盖:无衬线正文(Inter)、等宽技术风(JetBrains Mono)、
|
||||
* 罗马碑刻装饰衬线标题(Cinzel)、古典正文衬线(Lora))
|
||||
* PatternFills 背景底纹 bg(MIT;src/patterns 50 款 10×10 灰阶平铺图案 SVG,
|
||||
* 纯静态可独立渲染,叠底色换色后作为海报底纹)
|
||||
* 本轮探测排除(记录备查):
|
||||
* svelte-svg-patterns(Pattern Monster) static 目录仅 1 个 SVG(其余为 Svelte/JS 生成代码)→ 不可用
|
||||
* PatternBolt 图案以 SCSS/CSS data-uri 形式打包,无独立 SVG 文件 → 不可用
|
||||
* pattern-craft Next.js 项目结构,图案为 CSS/JS 代码片段 → 不可用
|
||||
* textures.js 运行时生成库(D3 驱动),非静态素材 → 不可用
|
||||
* illustration 类(unDraw 自定义协议禁 AI / Open Doodles 无仓库直拉 / illlustrations 404)
|
||||
* 、template/mockup/photo 类:GitHub 上无可直拉的合规静态素材 → 本轮不收录,继续观测
|
||||
* ============================================================ */
|
||||
{
|
||||
id: 'fontsource-inter',
|
||||
type: 'font',
|
||||
name: 'Inter',
|
||||
nameCn: 'Inter 字体',
|
||||
desc: '被誉为「最佳界面字体」的现代无衬线字体(OFL 1.1),字重 100-900 跨度极大且阅读舒适,海报正文、数据信息区与界面设计的最佳选择',
|
||||
license: 'OFL-1.1',
|
||||
licenseNote: 'SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名',
|
||||
home: 'https://fonts.google.com/specimen/Inter',
|
||||
github: 'https://github.com/rsms/inter',
|
||||
format: 'dir-files',
|
||||
npm: '@fontsource-variable/inter',
|
||||
npmVersion: '5.3.0',
|
||||
dirs: ['files'],
|
||||
ext: ['.woff2'],
|
||||
exclude: '-(standard|opsz|greek|vietnamese|cyrillic-ext)-', // 剔除重复轴(standard/opsz 与 wght 同内容)与低用例子集
|
||||
scaleMode: 'wrap',
|
||||
coordText: '可变字体 woff2 子集(latin / latin-ext / cyrillic × normal / italic)',
|
||||
variants: '6 个子集文件,字重 100-900 可调(wght 轴),海报正文/信息区首选无衬线',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'fontsource-jetbrains-mono',
|
||||
type: 'font',
|
||||
name: 'JetBrains Mono',
|
||||
nameCn: 'JetBrains Mono 字体',
|
||||
desc: 'JetBrains 出品的等宽编程字体(OFL 1.1),字形锐利辨识度高,适合科技/数码/代码风海报、数据标注与信息图',
|
||||
license: 'OFL-1.1',
|
||||
licenseNote: 'SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名',
|
||||
home: 'https://fonts.google.com/specimen/JetBrains+Mono',
|
||||
github: 'https://github.com/JetBrains/JetBrainsMono',
|
||||
format: 'dir-files',
|
||||
npm: '@fontsource-variable/jetbrains-mono',
|
||||
npmVersion: '5.3.0',
|
||||
dirs: ['files'],
|
||||
ext: ['.woff2'],
|
||||
exclude: '-(greek|vietnamese|cyrillic-ext)-',
|
||||
scaleMode: 'wrap',
|
||||
coordText: '可变字体 woff2 子集(latin / latin-ext / cyrillic × normal / italic)',
|
||||
variants: '6 个子集文件,字重 100-800 可调(wght 轴),等宽字形适合技术/数据风格',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'fontsource-cinzel',
|
||||
type: 'font',
|
||||
name: 'Cinzel',
|
||||
nameCn: 'Cinzel 字体',
|
||||
desc: '灵感源自古罗马碑刻的装饰衬线字体(OFL 1.1),字形庄重典雅,适合历史/文艺/高端品牌海报大标题',
|
||||
license: 'OFL-1.1',
|
||||
licenseNote: 'SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名',
|
||||
home: 'https://fonts.google.com/specimen/Cinzel',
|
||||
github: 'https://github.com/ndiscovered/cinzel',
|
||||
format: 'dir-files',
|
||||
npm: '@fontsource-variable/cinzel',
|
||||
npmVersion: '5.3.0',
|
||||
dirs: ['files'],
|
||||
ext: ['.woff2'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '可变字体 woff2 子集(latin / latin-ext,无斜体)',
|
||||
variants: '2 个子集文件,字重 400-900 可调(wght 轴),罗马碑刻风格标题衬线',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'fontsource-lora',
|
||||
type: 'font',
|
||||
name: 'Lora',
|
||||
nameCn: 'Lora 字体',
|
||||
desc: '气质古典的正文衬线字体(OFL 1.1),在屏幕与印刷上均有出色可读性,适合正文段落、引言与文艺风格海报',
|
||||
license: 'OFL-1.1',
|
||||
licenseNote: 'SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名',
|
||||
home: 'https://fonts.google.com/specimen/Lora',
|
||||
github: 'https://github.com/cyrealtype/Lora-Cyrillic',
|
||||
format: 'dir-files',
|
||||
npm: '@fontsource-variable/lora',
|
||||
npmVersion: '5.3.0',
|
||||
dirs: ['files'],
|
||||
ext: ['.woff2'],
|
||||
exclude: '-(math|symbols|cyrillic|vietnamese)-', // 剔除数学/符号/西里尔/越南语子集,保留拉丁正文
|
||||
scaleMode: 'wrap',
|
||||
coordText: '可变字体 woff2 子集(latin / latin-ext × normal / italic)',
|
||||
variants: '4 个子集文件,字重 400-700 可调(wght 轴),古典衬线正文首选',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'patternfills',
|
||||
type: 'bg',
|
||||
name: 'PatternFills',
|
||||
nameCn: '图案填充底纹',
|
||||
desc: 'SVG 图案填充集合(MIT):横/竖条纹、十字、菱格、点阵等 50 款经典灰阶平铺纹理,单元 10×10 无缝平铺,适合海报背景底纹与信息图分区底',
|
||||
license: 'MIT',
|
||||
licenseNote: 'MIT 协议,可自由商用、修改、再分发,需保留版权声明',
|
||||
home: 'https://iros.github.io/patternfills/',
|
||||
github: 'https://github.com/iros/patternfills',
|
||||
format: 'gh-files',
|
||||
gh: 'iros/patternfills',
|
||||
ghBranch: 'master',
|
||||
dirs: ['src/patterns'],
|
||||
ext: ['.svg'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '10×10 平铺单元 SVG(灰阶 #aaa 线条,可叠底色或换色后使用)',
|
||||
variants: '全量收录 50 款(水平/垂直条纹、十字、菱格、点阵、犬牙纹等)',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
/* ============================================================
|
||||
* 第五批合规源(2026-08-21 录入,license 逐条核实 + jsDelivr 可拉取验证):
|
||||
* brand-colors 配色 palette(MIT;data/brandColors.json 按品牌名映射主色,130+ 品牌,
|
||||
* 部分含多色变体,适合海报调用品牌主色/多品牌对比配色)
|
||||
* pixelarticons 装饰 decoration(MIT;npm 2.4.1,svg/ 1036 枚 24×24 像素风图标,
|
||||
* 单色可换色,适合复古游戏/像素风海报点缀;maxItems 限量收录基础版)
|
||||
* 本轮探测排除(记录备查):
|
||||
* primer/primitives 设计 token JSON 结构复杂(多层嵌套 token 名),非直接可用的色板数据 → 不可用
|
||||
* game-icons(CC BY 3.0)/ OpenMoji(CC BY-SA 4.0) 协议不在白名单(BY-SA / 非 CC BY 4.0)→ 排除
|
||||
* fangpenlin/avataaars 仅 React 组件源码,无成品 SVG 文件 → 不可用
|
||||
* simple-icons / bootstrap-icons / heroicons / pixelarticons 之外的图标库 与 LOGO 库图标功能重叠,仅收
|
||||
* 风格独特的 pixelarticons(像素风);其余不重复收录
|
||||
* illustration / template / mockup / photo 类:仍无可直拉的合规静态源(unDraw 禁 AI / 其余 404 或自定义协议)→ 继续观测
|
||||
* ============================================================ */
|
||||
{
|
||||
id: 'brand-colors',
|
||||
type: 'palette',
|
||||
name: 'Brand Colors',
|
||||
nameCn: '品牌色板',
|
||||
desc: '收录全球 130+ 知名品牌官方主色(MIT),单一 JSON 按品牌名映射十六进制色值(部分含多色变体),适合海报调用品牌主色、做品牌对比或多品牌视觉素材',
|
||||
license: 'MIT',
|
||||
licenseNote: 'MIT 协议,可自由商用、修改、再分发,需保留版权声明',
|
||||
home: 'https://brandcolors.net',
|
||||
github: 'https://github.com/reimertz/brand-colors',
|
||||
format: 'gh-files',
|
||||
gh: 'reimertz/brand-colors',
|
||||
ghBranch: 'master',
|
||||
dirs: ['data'],
|
||||
ext: ['.json'],
|
||||
exclude: 'brandColors\\.(merged|net)\\.json$', // 仅取 brandColors.json(品牌名→hex 扁平映射),剔除重复变体
|
||||
scaleMode: 'wrap',
|
||||
coordText: '配色数据(brandColors.json,品牌名 → 十六进制主色扁平映射)',
|
||||
variants: '单文件 brandColors.json,130+ 品牌、部分含多色变体(-2/-3 后缀)',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'pixelarticons',
|
||||
type: 'decoration',
|
||||
name: 'Pixelarticons',
|
||||
nameCn: '像素风图标',
|
||||
desc: '手工像素风矢量图标集(MIT,24×24 网格、无抗锯齿),单色可任意换色,适合复古游戏/像素/潮流风格海报点缀元素与信息符号',
|
||||
license: 'MIT',
|
||||
licenseNote: 'MIT 协议,可自由商用、修改、再分发,需保留版权声明',
|
||||
home: 'https://pixelarticons.com',
|
||||
github: 'https://github.com/halfmage/pixelarticons',
|
||||
format: 'dir-files',
|
||||
npm: 'pixelarticons',
|
||||
npmVersion: '2.4.1',
|
||||
dirs: ['svg'],
|
||||
ext: ['.svg'],
|
||||
exclude: '-sharp\\.svg$', // 仅收录基础风格(-sharp 变体视觉近重复)
|
||||
scaleMode: 'wrap',
|
||||
coordText: '24×24 视口单色像素图标(fill=currentColor,可换色)',
|
||||
variants: 'maxItems 限量收录 400 枚基础图标(剔除 -sharp 变体,全量 1036 枚)',
|
||||
added: '2026-08-21',
|
||||
maxItems: 400,
|
||||
workspaces: ['poster']
|
||||
},
|
||||
/* ============================================================
|
||||
* 第六批合规源(2026-08-21 录入,license 逐条核实 + jsDelivr 可拉取验证):
|
||||
* Montserrat / Bebas Neue 字体 font(OFL-1.1;fontsource 包 woff2 子集,
|
||||
* 分别覆盖:全能标题无衬线(Montserrat,字重 100-900 可变)、
|
||||
* 经典压缩大写标题(Bebas Neue,单字重 400,电影海报风格))
|
||||
* Material Colors 配色 palette(MIT;dist/colors.json 19 色系 × 10 明度
|
||||
* + 4 accent 高饱和变体 + 深浅文本/图标色,260+ 色值,系统配色基准)
|
||||
* 本轮探测排除(记录备查):
|
||||
* chinese-color-atlas(中国传统色) free 样本为 W3C DTCG 嵌套 token 结构,非纯色板 JSON → 不可用
|
||||
* zerosoul/chinese-colors 色板数据内嵌于 JS(非独立 JSON),仓库混有大量水墨 PNG/字体 → 不可用
|
||||
* anup-a/svg.shapes 无独立 SVG 文件(形状打包进单个 svg.json)→ 不可用
|
||||
* fu4303/svgwave(SVG Wave) 运行时生成器(Preact/Webpack 应用),非静态素材 → 不可用
|
||||
* material-colors 之外(IORoot 色板为 Apple CLR 格式 / DesignColors 为 Java 库 / CARTO 为 R 包) → 不可用
|
||||
* illustration / template / mockup / photo 类:仍无可直拉的合规静态源 → 继续观测
|
||||
* ============================================================ */
|
||||
{
|
||||
id: 'fontsource-montserrat',
|
||||
type: 'font',
|
||||
name: 'Montserrat',
|
||||
nameCn: 'Montserrat 字体',
|
||||
desc: '几何无衬线标题字体(OFL 1.1),字重 100-900 可变 + 斜体,字形温和圆润,海报/品牌设计领域全球最常用的全能标题字体',
|
||||
license: 'OFL-1.1',
|
||||
licenseNote: 'SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名',
|
||||
home: 'https://fonts.google.com/specimen/Montserrat',
|
||||
github: 'https://github.com/JulietaUla/Montserrat',
|
||||
format: 'dir-files',
|
||||
npm: '@fontsource-variable/montserrat',
|
||||
npmVersion: '5.3.0',
|
||||
dirs: ['files'],
|
||||
ext: ['.woff2'],
|
||||
exclude: '-(cyrillic|vietnamese)-', // 仅保留 latin / latin-ext 子集(西里尔/越南语低用例子集剔除)
|
||||
scaleMode: 'wrap',
|
||||
coordText: '可变字体 woff2 子集(latin / latin-ext × normal / italic)',
|
||||
variants: '4 个子集文件,字重 100-900 可调(wght 轴),标题与正文全能无衬线',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'fontsource-bebas-neue',
|
||||
type: 'font',
|
||||
name: 'Bebas Neue',
|
||||
nameCn: 'Bebas Neue 字体',
|
||||
desc: '经典压缩无衬线大写标题字体(OFL 1.1),窄高字形醒目有力,电影海报/时尚/运动/促销风格的标题标杆字体',
|
||||
license: 'OFL-1.1',
|
||||
licenseNote: 'SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名',
|
||||
home: 'https://fonts.google.com/specimen/Bebas+Neue',
|
||||
github: 'https://github.com/dharmatype/Bebas-Neue',
|
||||
format: 'dir-files',
|
||||
npm: '@fontsource/bebas-neue',
|
||||
npmVersion: '5.3.0',
|
||||
dirs: ['files'],
|
||||
ext: ['.woff2'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '静态字体 woff2 子集(latin / latin-ext,仅 400 常规字重)',
|
||||
variants: '2 个子集文件,单字重 400 压缩大写,适合大号标题与品牌字标',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'material-colors',
|
||||
type: 'palette',
|
||||
name: 'Material Colors',
|
||||
nameCn: 'Material Design 色板',
|
||||
desc: 'Google Material Design 经典官方色板(MIT):19 色系 × 10 级明度 + 4 个 accent 高饱和变体 + 深浅文本/图标色,共 260+ 色值,色彩体系科学统一,适合海报系统配色与信息可视化',
|
||||
license: 'MIT',
|
||||
licenseNote: 'MIT 协议,可自由商用、修改、再分发,需保留版权声明',
|
||||
home: 'https://material.io/design/color',
|
||||
github: 'https://github.com/shuhei/material-colors',
|
||||
format: 'dir-files',
|
||||
npm: 'material-colors',
|
||||
npmVersion: '1.2.6',
|
||||
dirs: ['dist'],
|
||||
ext: ['.json'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '配色数据(colors.json,色系名 → 明度/变体 → 十六进制;含 rgba 文本/图标色)',
|
||||
variants: '单文件 colors.json,260+ 色值(19 色系 × 50-900/a100-a700 + 文本/图标色)',
|
||||
added: '2026-08-21',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
/* ============================================================
|
||||
* 第七批合规源(2026-08-22 录入,license 逐条核实 + jsDelivr 可拉取验证):
|
||||
* circle-flags 装饰 decoration(MIT;gh-pages 分支 flags/ 268+ 枚 ISO 3166-1
|
||||
* 圆形国旗矢量图标,圆角化 + 白描边,海报国家/地区标识与旅行主题装饰)
|
||||
* glacial-strat-patterns 背景 bg(图案资产 CC BY 4.0 / 代码 MIT;svg/ 13 款地质层理
|
||||
* 剖面图案,多层渐变弯折层理,地质/自然/科学主题海报背景)
|
||||
* Poppins 字体 font(OFL-1.1;fontsource 包 woff2 子集,
|
||||
* 现代几何无衬线全能字体,常用字重 300-700,正文与标题两用)
|
||||
* 本轮探测排除(记录备查):
|
||||
* Playfair Display(fontsource-playfair-display) 与第四批 fontsource-playfair 重复 → 剔除
|
||||
* dracula/dracula-theme / rose-pine/rose-pine-theme 主题色仅存于各编辑器扩展仓库
|
||||
* (主仓为 .gitmodules 子模块列表或纯徽标文件),无独立配色 JSON → 不可用
|
||||
* Dragoon0x/inkjet(500 参数化图案) 全部为 TS 源码,运行时生成 SVG,无预生成静态文件 → 不可用
|
||||
* mrmrs/patterns jsDelivr 未索引任何可用分支(master/gh-pages 均 404)→ 不可用
|
||||
* illustration / template / mockup / photo 类:仍无可直拉的合规静态源 → 继续观测
|
||||
* ============================================================ */
|
||||
{
|
||||
id: 'circle-flags',
|
||||
type: 'decoration',
|
||||
name: 'Circle Flags',
|
||||
nameCn: '圆形国旗图标',
|
||||
desc: '268+ 枚 ISO 3166-1 各国圆形国旗矢量图标(MIT),国旗元素圆角化处理并带白色描边,适合海报中的国家/地区标识、旅行与国际主题、多国对比等装饰元素',
|
||||
license: 'MIT',
|
||||
licenseNote: 'MIT 协议,可自由商用、修改、再分发,需保留版权声明',
|
||||
home: 'https://hatscripts.github.io/circle-flags/',
|
||||
github: 'https://github.com/HatScripts/circle-flags',
|
||||
format: 'gh-files',
|
||||
gh: 'HatScripts/circle-flags',
|
||||
ghBranch: 'gh-pages',
|
||||
dirs: ['flags'],
|
||||
ext: ['.svg'],
|
||||
exclude: 'flags/(language|other)/', // 仅保留主目录 ISO 3166-1 国旗,剔除语言/特殊图形子目录
|
||||
scaleMode: 'wrap',
|
||||
coordText: '圆形国旗 SVG(各国比例徽章造型,viewBox 各异逐图标归一化)',
|
||||
variants: '全量收录 268+ 枚 ISO 3166-1 圆形国旗(cn/us/jp/... 两字母代码命名)',
|
||||
added: '2026-08-22',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'glacial-strat-patterns',
|
||||
type: 'bg',
|
||||
name: 'Glacial Strat Patterns',
|
||||
nameCn: '地质层理图案',
|
||||
desc: '地质层理结构矢量图案集(图案资产 CC BY 4.0):13 款模拟冰川/沉积岩地层剖面的层理纹理,多层渐变与弯折地质构造,适合地质、自然、科学主题海报的背景与氛围底纹',
|
||||
license: 'CC-BY-4.0',
|
||||
licenseNote: '图案资产遵循 CC BY 4.0(商用/修改需署名:MNiMORPH/glacial-strat-patterns);仓库代码部分为 MIT',
|
||||
home: 'https://github.com/MNiMORPH/glacial-strat-patterns',
|
||||
github: 'https://github.com/MNiMORPH/glacial-strat-patterns',
|
||||
format: 'gh-files',
|
||||
gh: 'MNiMORPH/glacial-strat-patterns',
|
||||
ghBranch: 'main',
|
||||
dirs: ['svg'],
|
||||
ext: ['.svg'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '地质层理剖面 SVG(地质符号命名,如 Cdm/Dcm/Gms,多层渐变填充)',
|
||||
variants: '全量收录 13 款(svg/ 目录:Cdm/Dcm/Dmm/Dms/Em/Fl/Fm/Gh/Gms/P/R/Sm/Sr)',
|
||||
added: '2026-08-22',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
{
|
||||
id: 'fontsource-poppins',
|
||||
type: 'font',
|
||||
name: 'Poppins',
|
||||
nameCn: 'Poppins 字体',
|
||||
desc: '现代几何无衬线字体(OFL 1.1),圆形几何字干、比例均衡,兼具标题力量感与正文易读性,海报/品牌/UI 设计最热门的全能字体之一',
|
||||
license: 'OFL-1.1',
|
||||
licenseNote: 'SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名',
|
||||
home: 'https://fonts.google.com/specimen/Poppins',
|
||||
github: 'https://github.com/itfoundry/Poppins',
|
||||
format: 'dir-files',
|
||||
npm: '@fontsource/poppins',
|
||||
npmVersion: '5.3.0',
|
||||
dirs: ['files'],
|
||||
ext: ['.woff2'],
|
||||
exclude: '-(devanagari|latin-ext|100|200|800|900)-|-italic', // 仅保留 latin 常用字重 300-700 常规
|
||||
scaleMode: 'wrap',
|
||||
coordText: '静态字体 woff2 子集(latin,字重 300/400/500/600/700 常规)',
|
||||
variants: '5 个 latin 子集文件,常用字重 300-700,正文与标题两用几何无衬线',
|
||||
added: '2026-08-22',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
/* ============================================================
|
||||
* 第八批合规源(2026-08-23 录入,license 逐条核实 + jsDelivr 可拉取验证):
|
||||
* Caveat 字体 font(OFL-1.1;fontsource 可变字体 woff2 子集,
|
||||
* 手写风格(handwriting),字重 400-700 可变,填补字体库
|
||||
* 手写/书法风格缺口,适合手账/文艺/婚礼/儿童/美食等亲和力标题)
|
||||
* 本轮探测排除(记录备查):
|
||||
* Dancing Script / Pacifico / Lobster / Amatic SC 同属手写风格,与 Caveat 同类,
|
||||
* 首批先收最具代表性的 Caveat(Google Fonts 手写体最热门),其余待后续按需扩量
|
||||
* illustration / template / mockup / photo 类:仍无可直拉的合规静态源 → 继续观测
|
||||
* ============================================================ */
|
||||
{
|
||||
id: 'fontsource-caveat',
|
||||
type: 'font',
|
||||
name: 'Caveat',
|
||||
nameCn: 'Caveat 手写字体',
|
||||
desc: 'Google Fonts 最热门的手写风格字体(OFL 1.1),笔触自然、随性亲和,字重 400-700 可变,适合海报手账/文艺/婚礼/儿童/美食等亲和力场景的标题、引言与手写感点缀',
|
||||
license: 'OFL-1.1',
|
||||
licenseNote: 'SIL Open Font License 1.1,可自由商用与修改;单独售卖字体文件需注明来源,与软件捆绑分发则无需署名',
|
||||
home: 'https://fonts.google.com/specimen/Caveat',
|
||||
github: 'https://github.com/googlefonts/caveat',
|
||||
format: 'dir-files',
|
||||
npm: '@fontsource-variable/caveat',
|
||||
npmVersion: '5.3.0',
|
||||
dirs: ['files'],
|
||||
ext: ['.woff2'],
|
||||
exclude: '-(cyrillic|cyrillic-ext)-', // 仅保留 latin / latin-ext 子集(西里尔低用例子集剔除)
|
||||
scaleMode: 'wrap',
|
||||
coordText: '可变字体 woff2 子集(latin / latin-ext × wght 轴,400-700 可调)',
|
||||
variants: '2 个子集文件,字重 400-700 可调(wght 轴),自然手写风标题与引言',
|
||||
added: '2026-08-23',
|
||||
workspaces: ['poster']
|
||||
},
|
||||
/* ============================================================
|
||||
* 第九批合规源(2026-08-23 录入,license 逐条核实 + jsDelivr 可拉取验证):
|
||||
* Chinese Traditional Colors 配色 palette(ISC;colors.json 顶层数组,
|
||||
* 200+ 款中国传统色(中文名 + 拼音 + 十六进制色值),
|
||||
* 填补「文化色」类别缺口,适合国风/节日/婚礼/茶饮主题海报)
|
||||
* 本轮探测排除(记录备查):
|
||||
* luozhuzhen/Chinese-colors jsDelivr 未索引(master 404)→ 不可用
|
||||
* @radix-ui/colors 色板仅打包为 CSS 变量文件(amber.css 等),
|
||||
* 无 .json 数据形态 → 不符合 palette 内联 data 规范
|
||||
* nipponcolors / traditional-japanese-colors npm 无此包(404)→ 不可用
|
||||
* zerosoul/chinese-colors 色板数据内嵌于 JS + 仓库混有大量水墨 PNG/字体(前批已排除)
|
||||
* illustration / template / mockup / photo 类:仍无可直拉的合规静态源 → 继续观测
|
||||
* ============================================================ */
|
||||
{
|
||||
id: 'chinese-colors',
|
||||
type: 'palette',
|
||||
name: 'Chinese Traditional Colors',
|
||||
nameCn: '中国传统色',
|
||||
desc: '中国传统色彩体系:粉鳳仙/荷花紅/青蓮紫/黛藍等 200+ 款典雅配色,每条含中文名 + 拼音 + 十六进制色值,适合国风/节日/婚礼/茶饮等传统文化主题海报配色',
|
||||
license: 'ISC',
|
||||
licenseNote: 'ISC 协议,可自由商用、修改、再分发(等同 MIT 的宽松许可)',
|
||||
home: 'https://github.com/hecodeit/chinese-colors',
|
||||
github: 'https://github.com/hecodeit/chinese-colors',
|
||||
format: 'dir-files',
|
||||
npm: 'chinese-colors',
|
||||
npmVersion: '1.0.0',
|
||||
dirs: ['.'],
|
||||
ext: ['.json'],
|
||||
scaleMode: 'wrap',
|
||||
coordText: '配色数据(colors.json,顶层数组:中文名 + 拼音 + 十六进制色值)',
|
||||
variants: '单文件 colors.json,320 款中国传统色(粉/紫/紅/黄/绿/蓝/黛等色系,含拼音标注)',
|
||||
added: '2026-08-23',
|
||||
workspaces: ['poster']
|
||||
}
|
||||
];
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
/* Bebas Neue(由 scripts/build-posters.js 生成,勿手动编辑)@cfg:k6d2c1 */
|
||||
window['HGB_PosterSource_fontsource-bebas-neue'] = {"items":[{"id":"fontsource-bebas-neue:bebas-neue-latin-400-normal","name":"bebas-neue-latin-400-normal","type":"font","category":"font","categoryCn":"字体排版","workspaces":["poster"],"file":"posters/fontsource-bebas-neue/files/bebas-neue-latin-400-normal.woff2","mime":"application/octet-stream","bytes":13768,"sha256":"a7c90c89240c134f7fdd33d40c000ec90b79d675ea53e8cc5a6d423c073de412","width":null,"height":null},{"id":"fontsource-bebas-neue:bebas-neue-latin-ext-400-normal","name":"bebas-neue-latin-ext-400-normal","type":"font","category":"font","categoryCn":"字体排版","workspaces":["poster"],"file":"posters/fontsource-bebas-neue/files/bebas-neue-latin-ext-400-normal.woff2","mime":"application/octet-stream","bytes":8928,"sha256":"16c95ce45a2922f52551d38d565d14c92cf257b8f219c89613407d81fdd21a39","width":null,"height":null}]};
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
/* Caveat(由 scripts/build-posters.js 生成,勿手动编辑)@cfg:1rved7 */
|
||||
window['HGB_PosterSource_fontsource-caveat'] = {"items":[{"id":"fontsource-caveat:caveat-latin-ext-wght-normal","name":"caveat-latin-ext-wght-normal","type":"font","category":"font","categoryCn":"字体排版","workspaces":["poster"],"file":"posters/fontsource-caveat/files/caveat-latin-ext-wght-normal.woff2","mime":"application/octet-stream","bytes":29472,"sha256":"f626b95c1efa95cf6b7c9b043d3bbe8862b6abbe5f55a082102f33f7de2fd4f9","width":null,"height":null},{"id":"fontsource-caveat:caveat-latin-wght-normal","name":"caveat-latin-wght-normal","type":"font","category":"font","categoryCn":"字体排版","workspaces":["poster"],"file":"posters/fontsource-caveat/files/caveat-latin-wght-normal.woff2","mime":"application/octet-stream","bytes":74932,"sha256":"891951df5e8b5b0f0bc760d31375405e795c666b5add70437de12d4a6482b33f","width":null,"height":null}]};
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
/* Cinzel(由 scripts/build-posters.js 生成,勿手动编辑)@cfg:1klcm8m */
|
||||
window['HGB_PosterSource_fontsource-cinzel'] = {"items":[{"id":"fontsource-cinzel:cinzel-latin-ext-wght-normal","name":"cinzel-latin-ext-wght-normal","type":"font","category":"font","categoryCn":"字体排版","workspaces":["poster"],"file":"posters/fontsource-cinzel/files/cinzel-latin-ext-wght-normal.woff2","mime":"application/octet-stream","bytes":14540,"sha256":"8fc2606f8ae6c569f444e138dba052d9a6e3f0657f9031796503bbb94eced600","width":null,"height":null},{"id":"fontsource-cinzel:cinzel-latin-wght-normal","name":"cinzel-latin-wght-normal","type":"font","category":"font","categoryCn":"字体排版","workspaces":["poster"],"file":"posters/fontsource-cinzel/files/cinzel-latin-wght-normal.woff2","mime":"application/octet-stream","bytes":25904,"sha256":"09941fb1c169c38fd414536b37690057fc01b3117a5c63dd6571186540c8f370","width":null,"height":null}]};
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
/* Inter(由 scripts/build-posters.js 生成,勿手动编辑)@cfg:137tz9v */
|
||||
window['HGB_PosterSource_fontsource-inter'] = {"items":[{"id":"fontsource-inter:inter-cyrillic-wght-italic","name":"inter-cyrillic-wght-italic","type":"font","category":"font","categoryCn":"字体排版","workspaces":["poster"],"file":"posters/fontsource-inter/files/inter-cyrillic-wght-italic.woff2","mime":"application/octet-stream","bytes":19980,"sha256":"47d42151dff6d13f1c2b9a1f278290f625593c1f01c89612ee4ae7f063167f7a","width":null,"height":null},{"id":"fontsource-inter:inter-cyrillic-wght-normal","name":"inter-cyrillic-wght-normal","type":"font","category":"font","categoryCn":"字体排版","workspaces":["poster"],"file":"posters/fontsource-inter/files/inter-cyrillic-wght-normal.woff2","mime":"application/octet-stream","bytes":18748,"sha256":"71d5ee93cc1e9f1d520a3a8b66456de18c7879d8df09d57fcd2eaff75fef0075","width":null,"height":null},{"id":"fontsource-inter:inter-latin-ext-wght-italic","name":"inter-latin-ext-wght-italic","type":"font","category":"font","categoryCn":"字体排版","workspaces":["poster"],"file":"posters/fontsource-inter/files/inter-latin-ext-wght-italic.woff2","mime":"application/octet-stream","bytes":91900,"sha256":"71254244ccb1ec21e14db3480fef6f6826d1136dc4aca1c7d3ca7ce31294fcce","width":null,"height":null},{"id":"fontsource-inter:inter-latin-ext-wght-normal","name":"inter-latin-ext-wght-normal","type":"font","category":"font","categoryCn":"字体排版","workspaces":["poster"],"file":"posters/fontsource-inter/files/inter-latin-ext-wght-normal.woff2","mime":"application/octet-stream","bytes":85068,"sha256":"34b9c504cab7a73e37b746343a449132e56cf7b5481af2cb81dc74dcff25c956","width":null,"height":null},{"id":"fontsource-inter:inter-latin-wght-italic","name":"inter-latin-wght-italic","type":"font","category":"font","categoryCn":"字体排版","workspaces":["poster"],"file":"posters/fontsource-inter/files/inter-latin-wght-italic.woff2","mime":"application/octet-stream","bytes":51832,"sha256":"7291b5970da2237441273c03b424a504b70b18f09791473fab99687dcc314720","width":null,"height":null},{"id":"fontsource-inter:inter-latin-wght-normal","name":"inter-latin-wght-normal","type":"font","category":"font","categoryCn":"字体排版","workspaces":["poster"],"file":"posters/fontsource-inter/files/inter-latin-wght-normal.woff2","mime":"application/octet-stream","bytes":48256,"sha256":"3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62","width":null,"height":null}]};
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user