一、做什么
当用户面临临时注册个账号、填个表单,需要一串随机密码,但手上不方便掏手机或掏手机还需找到对应工具才能生成,又或是连6位数验证码都记不住的小伙伴,这时候我的智能体的用处就派上用场啦。密码骰子就是为这个场景做的:说一句话打开页面,屏幕上直接显示16位带符号的密码,抄下来就能用。
它只做三件事:生成、重新生成、退出。没有保存,没有复制,没有联网,关掉页面这串字符就没了。
语音只说一句“已生成16位密码”,不会念出密码内容。眼镜是外放的,旁边的人听得见,念出来就不算密码了。
二、界面
页面是480×352的单页,黑底绿字,从上到下四块:标题行、密码卡片、空白、按钮行。
标题行左边是“密码骰子”,右边是“本地随机 · 即用即弃”。中间是密码卡片,卡片里有三行:小字“参考密码”、30px的密码本体、“16位 · 含符号”。底部两个按钮,“生成”和“退出”。
两张图的区别只有底部按钮的边框:当前焦点是3px实线绿边加浅绿底,字号从16px放大到18px并加粗。
尺寸是排过的。页面左右各16px内边距,内容宽448px;两个按钮各220px、间距8px,正好填满。卡片固定170px高,按钮固定42px,中间用一个弹性空隙推开,所以换几串密码位置都不会跳。
三、代码结构
pass-dice/
├── app.json # 单页配置、意图提示词、空权限
├── modules/passgen.js # 字符集与生成算法
└── pages/gen/gen.ink # 页面、样式、按键
代码分工很简单,passgen.js不碰页面,输入长度和随机函数,输出一串字符,可以单独用Node跑。gen.ink不关心字符怎么来的,只负责把结果放到屏幕上,并处理按键。
app.json里permissions是空数组,没有网络、存储、录音权限。
四、密码怎么生成
四组字符:
export const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
export const LOWER = 'abcdefghijklmnopqrstuvwxyz';
export const DIGITS = '0123456789';
export const SYMBOLS = '!@#$%^&*-_=+?';
符号只挑了13个键盘上常见的,引号、反斜杠、空格这些容易出问题的没放进去。
下面这段是密码的生成函数:
export function generatePassword(length = DEFAULT_LENGTH, random = Math.random) {
const safeLength = length === 16 ? 16 : DEFAULT_LENGTH;
const classes = [UPPER, LOWER, DIGITS, SYMBOLS];
const pool = classes.join('');
const chars = [];
for (const group of classes) chars.push(pick(group, random));
while (chars.length < safeLength) chars.push(pick(pool, random));
shuffle(chars, random);
return chars.join('');
}
一共分三步走:先从四类里各取一个(4个字符),再从全部字符里补到16位,最后整体洗牌。
第一步如果直接从全池抽16个,可能出现一个符号都没有、或者全是字母的情况,很多网站会因此拒绝这个密码。先各拿一个,保证四类齐全;后面的洗牌再打散位置,否则每次密码的开头永远是大写、小写、数字、符号这个固定顺序。
取随机数时做了点二次加工处理:
function randomInt(max, random) {
const value = Number(random());
if (!Number.isFinite(value) || value < 0) return 0;
return Math.floor(value * max) % max;
}
random() 返回非数字或负数时按0处理,末尾取模防止越界。
这一版的长度写死16位:页面调用 generatePassword(16, this._random),函数里也用 safeLength 挡一下,传别进来也还是16。
五、按键怎么处理
页面只有两个动作,按键处理也就很轻量啦:
onKeyUp(event) {
const code = event && (event.code || event.key);
if (code === 'Backspace') return;
if (code !== 'Enter' && code !== 'ArrowUp' && code !== 'ArrowDown') return;
if (event && typeof event.preventDefault === 'function') event.preventDefault();
if (event && event.repeat) return;
if (code === 'Enter') {
this.activate();
return;
}
const direction = code === 'ArrowDown' ? 1 : -1;
this._focus = (this._focus + direction + ACTION_COUNT) % ACTION_COUNT;
this.render();
}
不过有三个地方值得注意:
Backspace直接放行,页面不拦,交回给用户处理,避免和系统的返回逻辑冲突。
preventDefault放在event.repeat判断之前。长按方向键时系统会连续发重复事件,每次都先阻止默认滚动,再被 repeat 挡掉,这样焦点只走一步,不会一路跳到底。
焦点用取模循环,两个按钮时上下方向等价:在“生成”上按上或下都到“退出”,再按又回来。
执行时按下标分发:
activate() {
if (this._focus === 1) this.exitAgent();
else this.regenerate();
}
“生成”重新掷一次,位数不变。“退出”调用 wx.exitMiniProgram,成功失败都挂了空回调——退不出去也不影响页面继续用。
触控是另一条路径:
onActionTap(event) {
const index = Number(event && event.currentTarget && event.currentTarget.dataset && event.currentTarget.dataset.index);
if (!Number.isInteger(index) || index < 0 || index >= ACTION_COUNT) return;
this._focus = index;
this.activate();
}
这里楼主是先校验下标是不是合法整数、在不在范围内,再移动焦点并执行,保证点到的和生效的是同一个按钮。
六、语音只念位数
speak() {
try {
if (wx.speech && typeof wx.speech.playTTS === 'function') {
const playback = wx.speech.playTTS('已生成16位密码');
if (playback && typeof playback.catch === 'function') playback.catch(() => {});
}
} catch (error) {}
}
播报内容只有一个位数,原因是我们做的是一个随机密码的生成,所以这么隐私的东西当然不能通过眼镜的语音播报透漏出来,不然站在旁边的小伙伴不小心截获了就GG了!但实际想真的听也听不清,!@#$%^&*-_=+?这一堆符合读出来,恐怕也记不起来吧,所以直接播报位数是最为合理的。
七、模拟验证
passgen.js 和 gen.ink 里的脚本楼主都通过 node --check进行验证了,而且是通过的,至少语法层面没问题是过关的。语法检查管不到运行结果,比如正则少写一个字符依然能通过,所以还得实际跑。
下面录屏是整个在AIUI Studio平台的模拟调试全过程的交互流程录屏,大概流程就是:
- 页面加载后直接出现16位密码,meta显示“16位 · 含符号”;
- 点“生成”换一串,位数和格式不变;
- 上下键在两个按钮切换,焦点样式跟着切换;
- 点“生成”就会生成一套随机16位密码,也就是上面介绍的一整套生成逻辑;
- 点“退出”走到
exitMiniProgram,然后就退出智能体。
这里具体的交互可以直接通过录屏进行体现:
八、仍有不足
我这次搞得智能体,还有很多的局限性,我大概列出能有以下几点:
- 位数固定16位,可能更多的用户场景这部分支持长度的自定义的话,受众群体会更多。
- 随机源是
Math.random,属于伪随机,不保证不可预测。 - 符号只有13个,只能保证四类都有,满足不了某些网站更细的密码规则。
- 没有保存和复制的功能,只能靠用户自己记下来,智能体关掉就没了,后面添加历史记录查询会更好
九、附:文件与配置
| 文件 | 职责 |
|---|---|
app.json | 单页路由、agentPrompt、空权限 |
modules/passgen.js | 字符集、生成、洗牌 |
pages/gen/gen.ink | 布局、样式、按键、播报 |
"permissions": [],
"pages": ["pages/gen/gen"]