xhs-minitool-creator 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,143 +1,80 @@
1
1
  # xhs-minitool-creator
2
2
 
3
- 一个命令行工具,帮你创建、打包、检查**小红书小工具**项目。
3
+ 小红书「小工具」项目脚手架:创建、构建、打包、合规校验,一条命令完成。
4
4
 
5
- 你只需要会最基本的终端操作:输入一条命令,就得到一个可以直接上传小红书的工具页面。不需要懂打包配置,不需要懂 zip 格式,也不需要提前了解小红书的开发规范——它都帮你处理好了。
5
+ 你不需要先啃小红书小工具的平台规范,脚手架已把平台约束内置到构建配置与校验规则中。用它产出的 zip 直接上传创作服务平台即可。
6
6
 
7
- ---
8
-
9
- ## 快速上手(3 步)
10
-
11
- > 前提:电脑上装好 [Node.js](https://nodejs.org)(18.17 或更新版本)。装好后在终端输入 `node -v` 能显示版本号即可。
12
-
13
- ### 第 1 步:创建项目
14
-
15
- 在终端里输入(把 `my-tool` 换成你喜欢的名字,只能用小写字母、数字和短横线):
16
-
17
- ```bash
18
- npx xhs-minitool-creator my-tool
19
- ```
20
-
21
- 输入后它会问你几个简单的问题(页面标题、描述等),**不想回答就一路按回车**,全部用默认值即可。
22
-
23
- ### 第 2 步:进入项目并安装依赖
7
+ ## 快速开始
24
8
 
25
9
  ```bash
10
+ npx xhs-minitool-creator my-tool -y
26
11
  cd my-tool
27
12
  npm install
13
+ npm run build # 产出 my-tool-xhs-minitool.zip,上传创作服务平台即可
28
14
  ```
29
15
 
30
- 第一次会下载一些文件,等待几十秒。
31
-
32
- ### 第 3 步:打包
33
-
34
- ```bash
35
- npm run build
36
- ```
37
-
38
- 完成后,项目目录下会多出一个 `my-tool-xhs-minitool.zip`。**把这个 zip 上传到小红书创作服务平台,你的小工具就上线了。**
39
-
40
- ---
41
-
42
- ## 日常使用
16
+ 交互式创建(自定义标题 / 描述):
43
17
 
44
18
  ```bash
45
- npm run dev # 本地预览:改代码时边改边看效果
46
- npm run build # 打包:生成可上传的 zip
47
- npm run validate # 体检:检查有没有违反平台规则的地方
19
+ npx xhs-minitool-creator my-tool
48
20
  ```
49
21
 
50
- 改完代码后,重新执行 `npm run build`,把新生成的 zip 再传一次即可更新。
51
-
52
- ---
53
-
54
- ## 常见问题
22
+ ## 命令
55
23
 
56
- **我需要会写代码吗?**
57
-
58
- 会用就行。它生成的项目自带一个能跑通的示例页面(输入名字、显示计数),你可以直接照葫芦画瓢改文字、改颜色。想做得更复杂,可以基于示例继续加代码,或让 AI 按项目里自带的开发规范(`.agents/` 目录)帮你改。
59
-
60
- **打包时报错了怎么办?**
61
-
62
- 执行 `npm run validate`,它会告诉你哪里不合规、怎么改。大多数问题(比如引用了网络图片)它都能直接指出。
63
-
64
- **zip 最大能多大?**
65
-
66
- 平台硬性上限 10 MB,建议控制在 2 MB 以内。图片尽量压缩后再放进去。打包时工具会自动汇报体积。
67
-
68
- **为什么不能直接引用网络图片?**
69
-
70
- 小红书小工具在离线环境运行,网络图片、网络请求都会被禁止。把图片下载下来放进 `public/` 目录即可。
71
-
72
- **这个工具和小红书官方是什么关系?**
73
-
74
- 没有隶属关系。它只是把小红书的官方开发规范内置进来帮你自动检查,规范原文就在项目 `.agents/skills/` 目录里,会随官方更新。
75
-
76
- ---
77
-
78
- ## 命令速查
79
-
80
- ```
81
- npx xhs-minitool-creator <名字> 创建项目(默认)
82
- npx xhs-minitool-creator <名字> --yes 跳过提问,全部用默认值
83
- npx xhs-minitool-creator <名字> --title "我的工具" 指定页面标题
84
- npx xhs-minitool-creator <名字> --theme "#ff2442" 指定主题色
85
- ```
86
-
87
- | 选项 | 说明 |
24
+ | 命令 | 作用 |
88
25
  | --- | --- |
89
- | `--yes` | 跳过提问,全部用默认值 |
90
- | `--title` | 页面标题 |
91
- | `--desc` | 项目描述 |
92
- | `--icon` | 首页图标字符(默认「工」) |
93
- | `--theme` | 主题色(默认 `#ff2442`) |
26
+ | `npm run dev` | 本地预览(Vite dev server) |
27
+ | `npm run build` | 构建 + 打包,输出 `<name>-xhs-minitool.zip` |
28
+ | `npm run validate` | 静态合规校验(输出 ERROR / WARNING 汇总) |
29
+ | `npm run skill` | 查看项目内置官方规范版本 |
94
30
 
95
- ---
31
+ CLI 子命令 `create / pack / validate / skill`,详见 `npx xhs-minitool-creator --help`。
96
32
 
97
- ## 进阶
33
+ create 选项:`--yes`(跳过交互)、`--title`、`--desc`。
98
34
 
99
- ### 项目里自带什么?
35
+ ## 生成的项目
100
36
 
101
- 创建出的项目包含:
37
+ - Vite 项目,构建预设已内置平台约束,输出目录 `xhs-minitool/`
38
+ - 示例页面:单页多视图切换、适配平台原生导航栏与安全区
39
+ - 官方打包规范 `minitool-zip-builder` 快照(`.agents/skills/`),可直接供 AI 参考辅助开发
102
40
 
103
- - 一个开箱即用的示例页面(单页多视图,适配平台顶部导航栏与安全区)
104
- - 已配置好的构建工具(Vite),**默认输出到 `xhs-minitool/` 目录**
105
- - 打包与检查命令(纯 Node 实现,Windows / macOS / Linux 都能用)
106
- - 官方开发规范 `minitool-zip-builder`(放在 `.agents/skills/`,给 AI 或人参考)
41
+ ## 平台约束速览
107
42
 
108
- ### 更新官方开发规范
43
+ - **完全离线**:禁 `fetch` / `XMLHttpRequest` / `WebSocket`;外部 `http(s)://` 资源(图片、字体、CDN 脚本)必须下载进包改相对路径
44
+ - **CSP 限制**:禁内联 `<script>` 与行内事件(`onclick=` 等),脚本必须外置 + `addEventListener`
45
+ - **经典脚本**:产物不允许 `type="module"` / `import`(离线环境下模块解析不可靠)——脚手架打包时自动转换,勿自行改回
46
+ - **单页应用**:容器不打开新页面,多视图在单页内用 JS 切换 DOM
47
+ - **WebView 基线低**:Chrome 61 / ES2017。构建预设已设 `build.target: ['es2017', 'chrome61']`,若自己覆盖 Vite 配置别把它弄丢(否则 `??` 等 ES2020 语法会泄漏,真机白屏无报错)
48
+ - **体积**:zip 硬上限 10 MiB,建议 ≤ 2 MiB;单条 Base64 ≤ 1 MiB
49
+ - **禁用 API**:`window.open`、Worker、剪贴板、地理定位、设备传感器、WebAssembly 等(`validate` 会扫描)
50
+ - **安全区**:`viewport-fit=cover` + `env(safe-area-inset-*)`,且顶部需为平台原生导航栏让位
51
+ - **原生能力**:官方提供 4 个 JSBridge API(发笔记、存相册等,见 `.agents/skills/` 内 `jsbridge-api.md`)
109
52
 
110
- 官方规范会持续更新,且没有自动更新入口。做法:
53
+ `npm run validate` 内置上述规则的静态扫描;静态检查无法覆盖运行时行为,交付前务必真机预览。
111
54
 
112
- 1. 从官方渠道拿到最新规范包的下载地址(`.skill` 文件)
113
- 2. 在项目目录执行:
55
+ ## 更新官方规范
114
56
 
115
- ```bash
116
- npx xhs-minitool-creator skill update --url <地址>
117
- ```
118
-
119
- ### 检查命令的更多用法
57
+ 官方规范经小红书 CDN 分发,**无稳定 latest 端点**(URL 含时间戳),需手动更新:
120
58
 
121
59
  ```bash
122
- npx xhs-minitool-creator validate ./xhs-minitool # 检查构建产物目录
123
- npx xhs-minitool-creator validate ./xxx-xhs-minitool.zip # 检查已打包的 zip
124
- npx xhs-minitool-creator pack --outDir <目录> # 指定产物目录再打包
60
+ npx xhs-minitool-creator skill update --url <官方 .skill 包地址>
125
61
  ```
126
62
 
127
- ### 编程式使用
63
+ ## 编程式使用
128
64
 
129
65
  ```js
130
66
  import { defineMinitoolConfig } from 'xhs-minitool-creator/vite';
131
67
  import { pack, validateDir } from 'xhs-minitool-creator';
132
68
  ```
133
69
 
134
- ---
70
+ ## 打包器说明
71
+
72
+ 打包优先使用系统 `zip` 命令(macOS 自带),未安装则回退内置纯 Node 实现,两条路径产物一致;校验与 zip 读写零系统依赖(Windows / Linux 均可用)。
135
73
 
136
74
  ## 说明
137
75
 
138
- - 打包时若检测到系统自带 `zip` 命令(macOS 自带)会优先使用;没有则自动改用内置打包器,结果一致
139
- - 静态检查无法替代真机验证,上线前请务必在小红书真机预览
140
- - 平台规则可能随时调整,关键决策以小红书创作服务平台实时规则为准
76
+ - 本工具与小红书官方无隶属关系,内置规范内容版权归小红书
77
+ - 平台规则可能调整,关键决策以创作服务平台实时规则为准
141
78
 
142
79
  ## License
143
80
 
package/bin/create.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  * 零运行时依赖,仅使用 Node 内置模块。
12
12
  */
13
13
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
14
- import { join, resolve, relative, dirname } from 'node:path';
14
+ import { join, resolve, relative, dirname, basename } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { createInterface } from 'node:readline/promises';
17
17
  import { stdin, stdout } from 'node:process';
@@ -36,8 +36,6 @@ create 选项:
36
36
  -y, --yes 跳过交互,全部使用默认值
37
37
  --title <标题> 页面标题(默认同项目名)
38
38
  --desc <描述> 项目描述
39
- --icon <字符> 首页图标字符(默认「工」)
40
- --theme <颜色> 主题色(默认 #ff2442)
41
39
 
42
40
  pack 选项:
43
41
  --outDir <目录> 构建产物目录(默认 xhs-minitool)
@@ -63,8 +61,7 @@ function parseArgs(argv) {
63
61
  if (a === '--yes' || a === '-y') args.flags.yes = true;
64
62
  else if (a === '--strict') args.flags.strict = true;
65
63
  else if (a === '--no-strict' || a === '--strict=false') args.flags.strict = false;
66
- else if (a === '--title' || a === '--desc' || a === '--icon' || a === '--theme' ||
67
- a === '--outDir' || a === '--zipName' || a === '--url') {
64
+ else if (a === '--title' || a === '--desc' || a === '--outDir' || a === '--zipName' || a === '--url') {
68
65
  const v = argv[i + 1];
69
66
  if (!v) fail(`选项 ${a} 缺少参数`);
70
67
  args.flags[a.slice(2)] = v;
@@ -119,6 +116,9 @@ function copyTemplate(targetDir, vars) {
119
116
 
120
117
  let count = 0;
121
118
  for (const file of walk(srcDir)) {
119
+ // macOS 目录垃圾文件不入模板
120
+ if (basename(file) === '.DS_Store') continue;
121
+
122
122
  let rel = relative(srcDir, file).split('\\').join('/');
123
123
  // npm 发布时 .gitignore 会被吞掉,模板内用 _gitignore 占位,落盘时改回
124
124
  if (rel === '_gitignore') rel = '.gitignore';
@@ -137,10 +137,7 @@ function copyTemplate(targetDir, vars) {
137
137
  text = text
138
138
  .replace(/\{\{NAME\}\}/g, vars.title)
139
139
  .replace(/\{\{TITLE\}\}/g, vars.title)
140
- .replace(/\{\{DESC\}\}/g, vars.desc)
141
- .replace(/\{\{ICON\}\}/g, vars.icon);
142
- } else if (rel.endsWith('.css') && vars.theme && vars.theme !== '#ff2442') {
143
- text = text.replace(/--accent:\s*#[0-9a-fA-F]{3,8};/, `--accent: ${vars.theme};`);
140
+ .replace(/\{\{DESC\}\}/g, vars.desc);
144
141
  }
145
142
  content = Buffer.from(text, 'utf8');
146
143
  }
@@ -160,26 +157,20 @@ async function createProject(args, flags) {
160
157
  if (!flags.yes && tty) {
161
158
  console.log('\n创建小红书小工具项目\n');
162
159
 
163
- let input = name;
164
- while (!input || !validName(input)) {
165
- input = await prompt(
166
- '项目名(小写字母/数字/连字符,如 lucky-draw):',
167
- name || '',
168
- ).then((v) => normalizeName(v));
169
- if (!input) {
170
- console.log(' 名称无效,请重新输入');
160
+ while (!name || !validName(name)) {
161
+ const answer = await prompt('项目名(必填,小写字母/数字/连字符,如 lucky-draw):', '');
162
+ name = normalizeName(answer);
163
+ if (!name || !validName(name)) {
164
+ console.log(' 名称无效:需以小写字母开头,仅含小写字母/数字/连字符');
171
165
  }
172
166
  }
173
- name = input;
174
167
 
175
- const title = await prompt(`页面标题(默认「${name}」):`, name);
176
- const desc = await prompt('项目描述(可选):', '一个小红书小工具');
177
- const icon = await prompt('首页图标字符(默认「工」):', '工');
178
- const theme = await prompt('主题色(默认 #ff2442):', '#ff2442');
168
+ const title = await prompt(`页面标题(回车跳过,默认「${name}」):`, name);
169
+ const desc = await prompt('项目描述(回车跳过):', '一个小红书小工具');
179
170
 
180
- const finalVars = { name, title, desc, icon, theme };
181
- console.log(`\n将创建:${name}(标题「${title}」,主题色 ${theme})`);
182
- const ok = await prompt('确认?(Y/n) :', 'Y');
171
+ const finalVars = { name, title, desc };
172
+ console.log(`\n将创建:./${name}(标题「${title}」)`);
173
+ const ok = await prompt('确认创建?(Y/n,回车确认):', 'Y');
183
174
  if (ok && !/^y/i.test(ok)) fail('已取消');
184
175
  await doCreate(name, finalVars);
185
176
  } else {
@@ -193,8 +184,6 @@ async function createProject(args, flags) {
193
184
  name,
194
185
  title: flags.title || name,
195
186
  desc: flags.desc || '一个小红书小工具',
196
- icon: flags.icon || '工',
197
- theme: flags.theme || '#ff2442',
198
187
  };
199
188
  await doCreate(name, finalVars);
200
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xhs-minitool-creator",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "小红书小工具脚手架:创建项目 / Vite 构建预设 / 打包 / 静态校验,内置官方打包规范 Skill",
5
5
  "type": "module",
6
6
  "bin": {
package/src/pack.mjs CHANGED
@@ -10,8 +10,8 @@
10
10
  * 关键改进:zip 用纯 Node 实现(src/zip.mjs),
11
11
  * 不再依赖系统的 zip / zipinfo 二进制,Windows 亦可用。
12
12
  */
13
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
14
- import { dirname, extname, join, relative, resolve } from 'node:path';
13
+ import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
14
+ import { extname, join, relative, resolve } from 'node:path';
15
15
  import { spawnSync } from 'node:child_process';
16
16
  import { createZip, listZipEntries } from './zip.mjs';
17
17
  import { ALLOWED_EXT, validateDir, validateZip, formatIssues } from './validate.mjs';
@@ -89,17 +89,6 @@ export function patchIndexHtml(outDir) {
89
89
  return scripts.length;
90
90
  }
91
91
 
92
- function copyIcons(outDir, publicDir) {
93
- for (const icon of ['icon-192.svg', 'icon-512.svg']) {
94
- const dest = join(outDir, 'icons', icon);
95
- const src = join(publicDir, 'icons', icon);
96
- if (!existsSync(dest) && existsSync(src)) {
97
- mkdirSync(dirname(dest), { recursive: true });
98
- copyFileSync(src, dest);
99
- }
100
- }
101
- }
102
-
103
92
  function readPackageName(cwd) {
104
93
  const pkgPath = join(cwd, 'package.json');
105
94
  if (!existsSync(pkgPath)) return 'minitool';
@@ -143,8 +132,8 @@ function makeZip(outDir, zipPath, entries) {
143
132
 
144
133
  /**
145
134
  * @param {{
146
- * cwd?: string, outDir?: string, zipName?: string, publicDir?: string,
147
- * skipValidate?: boolean, strict?: boolean
135
+ * cwd?: string, outDir?: string, zipName?: string,
136
+ * strict?: boolean
148
137
  * }} [options]
149
138
  */
150
139
  export function pack(options = {}) {
@@ -152,14 +141,12 @@ export function pack(options = {}) {
152
141
  const outDir = resolve(cwd, options.outDir || 'xhs-minitool');
153
142
  const zipName = options.zipName || `${readPackageName(cwd)}-xhs-minitool.zip`;
154
143
  const zipPath = resolve(cwd, zipName);
155
- const publicDir = resolve(cwd, options.publicDir || 'public');
156
144
 
157
145
  if (!existsSync(outDir)) {
158
146
  throw new Error(`缺少输出目录 ${relative(cwd, outDir)},请先执行构建`);
159
147
  }
160
148
 
161
149
  console.log('\n▸ 整理产物');
162
- copyIcons(outDir, publicDir);
163
150
  removeUnsupported(outDir);
164
151
  pruneEmptyDirs(outDir);
165
152
  const scriptCount = patchIndexHtml(outDir);
@@ -16,7 +16,7 @@
16
16
  -->
17
17
  <section class="page is-active" data-page="home">
18
18
  <header class="hero">
19
- <div class="hero-icon">{{ICON}}</div>
19
+ <div class="hero-icon">😊</div>
20
20
  <h1 class="hero-title">{{TITLE}}</h1>
21
21
  <p class="hero-desc">{{DESC}}</p>
22
22
  </header>
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "devDependencies": {
16
16
  "vite": "^6.3.5",
17
- "xhs-minitool-creator": "^1.1.0"
17
+ "xhs-minitool-creator": "^1.2.0"
18
18
  },
19
19
  "engines": {
20
20
  "node": ">=18.17.0"
@@ -1,18 +1,15 @@
1
1
  import './styles/app.css';
2
- import { safeStorage } from './lib/storage.js';
3
2
 
4
3
  /**
5
4
  * 示例:一个最小可用的小工具页面。
6
5
  * 演示三件事:
7
- * 1. 事件用 addEventListener 绑定(容器禁止 onclick 等行内事件)
8
- * 2. 本地存储用 safeStorage 包装(个别环境 localStorage 会抛错)
9
- * 3. 多视图在单页内切换(容器不支持打开新页面)
6
+ * 1. 事件用 addEventListener 绑定(容器 CSP 禁止 onclick 等行内事件)
7
+ * 2. 多视图在单页内切换(容器不支持打开新页面)
8
+ * 3. 提示用页内 toast 元素实现(容器不支持系统弹窗)
10
9
  */
11
10
 
12
- const THEME_KEY = 'demo_theme';
13
- const COUNT_KEY = 'demo_count';
11
+ let count = 0;
14
12
 
15
- /** 朴素但够用的 toast */
16
13
  function toast(message) {
17
14
  const el = document.getElementById('toast');
18
15
  if (!el) return;
@@ -28,7 +25,7 @@ function toast(message) {
28
25
 
29
26
  function renderCount() {
30
27
  const el = document.getElementById('count');
31
- if (el) el.textContent = String(Number(safeStorage.get(COUNT_KEY) || 0));
28
+ if (el) el.textContent = String(count);
32
29
  }
33
30
 
34
31
  function init() {
@@ -37,12 +34,6 @@ function init() {
37
34
  const resetBtn = document.getElementById('resetBtn');
38
35
  const greet = document.getElementById('greet');
39
36
 
40
- // 恢复上次输入
41
- const saved = safeStorage.get('demo_name');
42
- if (saved && input) input.value = saved;
43
-
44
- renderCount();
45
-
46
37
  goBtn?.addEventListener('click', () => {
47
38
  const name = (input?.value || '').trim();
48
39
  if (!name) {
@@ -50,22 +41,17 @@ function init() {
50
41
  input?.focus();
51
42
  return;
52
43
  }
53
- safeStorage.set('demo_name', name);
54
-
55
- const next = Number(safeStorage.get(COUNT_KEY) || 0) + 1;
56
- safeStorage.set(COUNT_KEY, String(next));
44
+ count += 1;
57
45
  renderCount();
58
-
59
46
  if (greet) {
60
- greet.textContent = `你好,${name}!这是第 ${next} 次使用。`;
47
+ greet.textContent = `你好,${name}!这是第 ${count} 次使用。`;
61
48
  greet.hidden = false;
62
49
  }
63
- toast('已保存,下次打开还在');
50
+ toast('已更新');
64
51
  });
65
52
 
66
53
  resetBtn?.addEventListener('click', () => {
67
- safeStorage.remove(COUNT_KEY);
68
- safeStorage.remove('demo_name');
54
+ count = 0;
69
55
  if (input) input.value = '';
70
56
  if (greet) greet.hidden = true;
71
57
  renderCount();
@@ -74,7 +60,7 @@ function init() {
74
60
 
75
61
  // 主题跟随系统(仅作演示,可删)
76
62
  const dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
77
- document.body.dataset.theme = safeStorage.get(THEME_KEY) || (dark ? 'dark' : 'light');
63
+ document.body.dataset.theme = dark ? 'dark' : 'light';
78
64
  }
79
65
 
80
66
  // 经典脚本环境下 DOM 已就绪(脚本在 body 末尾),直接初始化即可
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  :root {
9
- --accent: #ff2442; /* 由脚手架按主题色注入 */
9
+ --accent: #ff2442; /* 主题色,改成你喜欢的颜色即可 */
10
10
  --bg: #f6f6f8;
11
11
  --card: #ffffff;
12
12
  --text: #1a1a1e;
@@ -1,4 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
2
- <rect width="192" height="192" rx="42" fill="#FF2442"/>
3
- <text x="96" y="118" font-size="72" text-anchor="middle" fill="#fff" font-family="-apple-system,sans-serif" font-weight="700">工</text>
4
- </svg>
@@ -1,4 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
2
- <rect width="192" height="192" rx="112" fill="#FF2442"/>
3
- <text x="256" y="310" font-size="192" text-anchor="middle" fill="#fff" font-family="-apple-system,sans-serif" font-weight="700">工</text>
4
- </svg>
@@ -1,54 +0,0 @@
1
- /**
2
- * localStorage 安全包装
3
- *
4
- * 容器内 localStorage 通常可用,但个别环境(隐私模式、存储被禁用)会直接抛异常。
5
- * 未捕获会导致整个脚本中断、页面白屏。这里统一 try/catch,
6
- * 失败时降级到内存 Map,保证功能不崩(数据在本次会话内有效)。
7
- */
8
-
9
- const memory = new Map();
10
-
11
- function canUse() {
12
- try {
13
- const k = '__xmc_probe__';
14
- window.localStorage.setItem(k, '1');
15
- window.localStorage.removeItem(k);
16
- return true;
17
- } catch {
18
- return false;
19
- }
20
- }
21
-
22
- const usable = canUse();
23
-
24
- export const safeStorage = {
25
- /** @returns {string|null} */
26
- get(key) {
27
- try {
28
- if (usable) return window.localStorage.getItem(key);
29
- } catch { /* 降级 */ }
30
- return memory.has(key) ? memory.get(key) : null;
31
- },
32
- set(key, value) {
33
- try {
34
- if (usable) {
35
- window.localStorage.setItem(key, value);
36
- return;
37
- }
38
- } catch { /* 降级 */ }
39
- memory.set(key, value);
40
- },
41
- remove(key) {
42
- try {
43
- if (usable) {
44
- window.localStorage.removeItem(key);
45
- return;
46
- }
47
- } catch { /* 降级 */ }
48
- memory.delete(key);
49
- },
50
- /** localStorage 是否真的可用(false 表示已降级到内存) */
51
- persistent: usable,
52
- };
53
-
54
- export default safeStorage;