From 5851d40d079da9818499121b5f576a761f448773 Mon Sep 17 00:00:00 2001 From: CodiceFabbrica Date: Wed, 18 Mar 2026 15:52:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(common):=20:sparkles:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?input-rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .czrc | 2 +- .vscode/extensions.json | 8 ++ .vscode/settings.json | 29 ++++- commitlint.config.cjs | 132 +++++-------------- packages/common/src/index.ts | 1 + packages/common/src/input-rule.ts | 163 +++++++++++++++++++++++ packages/common/test/input-rule.test.ts | 166 ++++++++++++++++++++++++ 7 files changed, 400 insertions(+), 101 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 packages/common/src/input-rule.ts create mode 100644 packages/common/test/input-rule.test.ts diff --git a/.czrc b/.czrc index 1d3f7ee..98ed108 100644 --- a/.czrc +++ b/.czrc @@ -1,3 +1,3 @@ { - "path": "node_modules/cz-git" + "path": "cz-git" } diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..ee85441 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "psioniq.psi-header", + "aaron-bond.better-comments", + "streetsidesoftware.code-spell-checker", + "dbaeumer.vscode-eslint" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 0db3279..584af84 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,30 @@ { - + "psi-header.config": { + "forceToTop": true, + "blankLinesAfter": 1, + "overridePathSeparator": "/" + }, + "psi-header.changes-tracking": { + "isActive": true, + "autoHeader": "autoSave", + "modDate": "@lastModified:", + "modDateFormat": "YYYY-MM-DD HH:mm:ss", + "modAuthor": "@lastModifiedBy:", + "exclude": ["markdown", "json"], + "excludeGlob": ["**/settings.json"] + }, + "psi-header.templates": [ + { + "language": "*", + "template": [ + "@file: <>", + "@description: ", + "@author: <> (<>)", + "@date: <>", + "@lastModified: <>", + "@lastModifiedBy: <> (<>)" + ], + "changeLogNaturalOrder": true + } + ] } diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 1df523a..9664965 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -77,105 +77,39 @@ module.exports = { allowCustomScopes: false, allowEmptyScopes: true, messages: { - skip: "跳过", - max: "最多 %d 个字符", - min: "最少 %d 个字符", - emptyWarning: "不能为空", - upperLimitWarning: "超出字数限制", - lowerLimitWarning: "低于字数限制", - }, - questions: { - type: { - description: "选择提交类型", - enum: { - feat: { - description: "一个新特性", - title: "新特性", - emoji: "✨", - }, - fix: { - description: "一个漏洞修复", - title: "漏洞修复", - emoji: "🐛", - }, - docs: { - description: "仅更改文档", - title: "文档", - emoji: "📚", - }, - style: { - description: "不影响代码含义的更改(空白符、格式化、缺少分号等)", - title: "样式", - emoji: "💎", - }, - refactor: { - description: "既不修复错误也不添加功能的代码更改", - title: "重构", - emoji: "📦", - }, - perf: { - description: "提高性能的代码更改", - title: "性能提升", - emoji: "🚀", - }, - test: { - description: "添加缺失的测试或纠正现有的测试", - title: "测试", - emoji: "🚨", - }, - build: { - description: - "影响构建系统或外部依赖项的更改(例如:gulp、broccoli、npm)", - title: "构建", - emoji: "🛠", - }, - ci: { - description: - "更改我们的集成配置文件和脚本(例如:Travis、Circle、BrowserStack、SauceLabs)", - title: "集成", - emoji: "⚙️", - }, - chore: { - description: "不修改源码或测试文件的其他更改", - title: "杂项", - emoji: "♻️", - }, - revert: { - description: "恢复之前的提交", - title: "还原", - emoji: "🗑", - }, - }, - }, - scope: { - description: "更改的范围(scope)(例:common、vue3、uni-app)", - }, - subject: { - description: "主题(subject)", - }, - body: { - description: "主体(body)", - }, - isBreaking: { - description: "是否有破坏性更改(BREAKING CHANGE)?", - }, - breakingBody: { - description: - "破坏性更改(BREAKING CHANGE)需要一个主体(body),请输入详细描述", - }, - breaking: { - description: "描述破坏性更改(BREAKING CHANGE)", - }, - isIssueAffected: { - description: "此改变是否影响打开的问题(open ssues)", - }, - issuesBody: { - description: "如果问题关闭,则需要提交一个主体(body),请输入详细描述", - }, - issues: { - description: - '添加问题参考(issue references) (例: "fix #123"、"re #123")', - }, + type: '选择你要提交的类型:', + scope: '选择一个提交范围(可选):', + customScope: '请输入自定义范围:', + subject: '请简短描述这次改动:\n', + body: '请详细描述这次改动(可选,用 "|" 换行):\n', + breaking: '是否有破坏性变更(可选,用 "|" 换行):\n', + footerPrefixesSelect: '选择关联的 ISSUE 类型(可选):', + customFooterPrefix: '输入 ISSUE 前缀:', + footer: '列出关联的 ISSUE,如: #31, #34:\n', + generatingByAI: '正在通过 AI 生成提交信息...', + generatedSelectByAI: '选择一个 AI 生成的 subject:', + confirmCommit: '是否确认提交以上信息?', }, + // subject 最小长度提示(中文化 "[x more chars needed]") + minSubjectLength: 4, + defaultSubject: '', + types: [ + { value: 'feat', name: 'feat: 新增功能', emoji: ':sparkles:' }, + { value: 'fix', name: 'fix: 修复缺陷', emoji: ':bug:' }, + { value: 'docs', name: 'docs: 文档变更', emoji: ':memo:' }, + { value: 'style', name: 'style: 代码格式(不影响逻辑)', emoji: ':lipstick:' }, + { value: 'refactor', name: 'refactor: 重构(既非新功能,也不是修 bug)', emoji: ':recycle:' }, + { value: 'perf', name: 'perf: 性能优化', emoji: ':zap:' }, + { value: 'test', name: 'test: 添加或修改测试', emoji: ':white_check_mark:' }, + { value: 'build', name: 'build: 构建系统或外部依赖变更', emoji: ':package:' }, + { value: 'ci', name: 'ci: CI 配置文件和脚本变更', emoji: ':ferris_wheel:' }, + { value: 'chore', name: 'chore: 其它不修改 src 或测试的改动', emoji: ':hammer:' }, + { value: 'revert', name: 'revert: 回退之前的 commit', emoji: ':rewind:' }, + ], + // issue 前缀也可以用中文说明 + issuePrefixs: [ + { value: 'link', name: 'link: 关联 ISSUE' }, + { value: 'close', name: 'close: 关闭 ISSUE' }, + ], }, }; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 8e14888..cc18628 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -3,3 +3,4 @@ export * from "./permission"; export * from "./printer"; export * from "./time"; export * from "./timer"; +export * from "./input-rule"; diff --git a/packages/common/src/input-rule.ts b/packages/common/src/input-rule.ts new file mode 100644 index 0000000..78a05ec --- /dev/null +++ b/packages/common/src/input-rule.ts @@ -0,0 +1,163 @@ +/* + * @file: /src/input-rule.ts + * @description: 限制输入框输入的规则 + * @author: tsl (randy1924@163.com) + * @date: 2026-03-18 13:37:11 + * @lastModified: 2026-03-18 15:04:44 + * @lastModifiedBy: tsl (randy1924@163.com) + */ + +export type InputValue = string | number | null; +export interface InputOptions { + min?: number; // 最小值 + max?: number; // 最大值 + init?: string | number; // 初始值 + digits?: number; // 小数位数 + pattern?: string | RegExp; // 匹配模式 + required?: boolean; // 是否必填 + integer?: boolean; // 是否整数 + number?: boolean; // 是否转成数字 + text?: boolean; // 是否使用文本模式 + floor?: boolean; // 是否向下取数 + ceil?: boolean; // 是否向上取数 + keepDecimal?: boolean; // 是否保留小数,即使是小数位是0 + noSign?: boolean; // 不允许输入正负号 +} + +/** + * 根据规则获取目标值 + * @example + * 限制输入的最小值:0,最大值:99999.99,必填,小数位数:2 + * const value = getValueOfRule(e.detail.value, { + * min: 0, + * max: 99999.99, + * required: true, + * digits: 2, + * }) + */ +export function getValueOfRule( + value: InputValue, + options: InputOptions, + callback?: (value: InputValue) => void, +): InputValue { + console.log("getValueOfRule", value, options); + + const { text } = options; + + let targetValue: InputValue; + if (text) { + targetValue = getTextTargetValue(value, options); + } else { + targetValue = getNumberTargetValue(value, options); + } + + console.log("targetValue", targetValue); + callback?.(targetValue); + return targetValue; +} + +/** + * 根据输入的获取处理后值-文本类型 + */ +function getTextTargetValue(innerValue: InputValue, options: InputOptions): InputValue { + const { required, init, pattern, number } = options; + + innerValue ??= ""; + + innerValue = String(innerValue); + let targetValue: string | number = innerValue; + + // 如果提供了pattern参数,检查输入是否匹配该模式 + if (pattern && !new RegExp(pattern).test(innerValue)) { + return ""; + } + + if (!/^[+-]?\d+(\.\d+)?$/.test(innerValue)) { + if (required) { + if (init !== undefined) { + targetValue = String(init); + } + } + } + + if (number) { + targetValue = Number(targetValue); + } + + return targetValue; +} + +/** + * 根据 floor/ceil 选项选择取整方式 + */ +function getRoundFn(floor?: boolean, ceil?: boolean): (n: number) => number { + if (floor) return Math.floor; + if (ceil) return Math.ceil; + return Math.round; +} + +/** + * 判断字符串是否为合法数字输入 + */ +function isValidNumberInput(value: string, noSign?: boolean): boolean { + if (!value) return false; + if (!/^[+-]?\d+(\.\d+)?$/.test(value)) return false; + if (noSign && /[+-]/.test(value)) return false; + return true; +} + +/** + * 获取非法输入时的回退值 + */ +function getFallbackValue(options: InputOptions): string { + const { required, init, min } = options; + if (!required) return ""; + if (init != null) return String(init); + return min == null ? "0" : String(min); +} + +/** + * 对数字应用精度和取整处理 + */ +function applyPrecision(num: number, options: InputOptions): number { + const { digits, integer, floor, ceil, min, max } = options; + let result = num; + + if (min != null) result = Math.max(result, min); + if (max != null) result = Math.min(result, max); + + const roundFn = getRoundFn(floor, ceil); + + if (digits != null) { + const precision = Math.pow(10, digits); + result = roundFn(result * precision) / precision; + } + + if (integer) { + result = roundFn(result); + } + + return result; +} + +/** + * 根据输入的获取处理后值-数字类型 + */ +function getNumberTargetValue(innerValue: InputValue, options: InputOptions): InputValue { + const { number, keepDecimal, noSign, digits } = options; + const strValue = String(innerValue ?? ""); + + let targetValue: string; + + if (isValidNumberInput(strValue, noSign)) { + targetValue = String(applyPrecision(Number(strValue), options)); + } else { + targetValue = getFallbackValue(options); + } + + if (digits != null && keepDecimal) { + targetValue = Number(targetValue).toFixed(digits); + } + + return number ? Number(targetValue) : targetValue; +} diff --git a/packages/common/test/input-rule.test.ts b/packages/common/test/input-rule.test.ts new file mode 100644 index 0000000..753f8c8 --- /dev/null +++ b/packages/common/test/input-rule.test.ts @@ -0,0 +1,166 @@ +import { test, expect, describe } from "@jest/globals"; +import { getValueOfRule, InputOptions } from "../src/input-rule"; + +describe("getValueOfRule - 文本模式 (text: true)", () => { + const textOpts: InputOptions = { text: true }; + + test("普通文本直接返回", () => { + expect(getValueOfRule("hello", { ...textOpts })).toBe("hello"); + }); + + test("null 值返回空字符串", () => { + expect(getValueOfRule(null, { ...textOpts })).toBe(""); + }); + + test("required + init:非数字文本回退到 init", () => { + expect(getValueOfRule("abc", { ...textOpts, required: true, init: "默认" })).toBe("默认"); + }); + + test("required 无 init:非数字文本保持原值", () => { + expect(getValueOfRule("abc", { ...textOpts, required: true })).toBe("abc"); + }); + + test("number 选项将结果转为数字", () => { + expect(getValueOfRule("123", { ...textOpts, number: true })).toBe(123); + }); + + test("pattern 匹配失败返回空字符串", () => { + expect(getValueOfRule("abc", { ...textOpts, pattern: "^\\d+$" })).toBe(""); + }); + + test("pattern 匹配成功返回原值", () => { + expect(getValueOfRule("123", { ...textOpts, pattern: "^\\d+$" })).toBe("123"); + }); +}); + +describe("getValueOfRule - 数字模式", () => { + test("正常数字字符串返回字符串", () => { + expect(getValueOfRule("42", {})).toBe("42"); + }); + + test("number 选项返回数字类型", () => { + expect(getValueOfRule("42", { number: true })).toBe(42); + }); + + test("非法输入 + required 回退到 init", () => { + expect(getValueOfRule("abc", { required: true, init: 10 })).toBe("10"); + }); + + test("非法输入 + required 无 init 回退到 min", () => { + expect(getValueOfRule("abc", { required: true, min: 5 })).toBe("5"); + }); + + test("非法输入 + required 无 init 无 min 回退到 0", () => { + expect(getValueOfRule("abc", { required: true })).toBe("0"); + }); + + test("非法输入 + 非 required 返回空字符串", () => { + expect(getValueOfRule("abc", {})).toBe(""); + }); + + test("null 值 + required 回退到 init", () => { + expect(getValueOfRule(null, { required: true, init: 0 })).toBe("0"); + }); + + test("null 值 + 非 required 返回空字符串", () => { + expect(getValueOfRule(null, {})).toBe(""); + }); +}); + +describe("getValueOfRule - min/max 限制", () => { + test("值小于 min 时限制为 min", () => { + expect(getValueOfRule("3", { min: 5 })).toBe("5"); + }); + + test("值大于 max 时限制为 max", () => { + expect(getValueOfRule("100", { max: 50 })).toBe("50"); + }); + + test("值在 min/max 范围内保持不变", () => { + expect(getValueOfRule("25", { min: 0, max: 50 })).toBe("25"); + }); + + test("负数受 min 限制", () => { + expect(getValueOfRule("-10", { min: 0 })).toBe("0"); + }); +}); + +describe("getValueOfRule - digits 小数位数", () => { + test("保留 2 位小数(四舍五入)", () => { + expect(getValueOfRule("3.456", { digits: 2 })).toBe("3.46"); + }); + + test("保留 0 位小数", () => { + expect(getValueOfRule("3.6", { digits: 0 })).toBe("4"); + }); + + test("floor 向下取整小数", () => { + expect(getValueOfRule("3.456", { digits: 2, floor: true })).toBe("3.45"); + }); + + test("ceil 向上取整小数", () => { + expect(getValueOfRule("3.451", { digits: 2, ceil: true })).toBe("3.46"); + }); + + test("keepDecimal 保留尾部零", () => { + expect(getValueOfRule("3", { digits: 2, keepDecimal: true })).toBe("3.00"); + }); + + test("keepDecimal + number 返回数字类型", () => { + expect(getValueOfRule("3.10", { digits: 2, keepDecimal: true, number: true })).toBe(3.1); + }); +}); + +describe("getValueOfRule - integer 整数模式", () => { + test("integer 四舍五入取整", () => { + expect(getValueOfRule("3.6", { integer: true })).toBe("4"); + }); + + test("integer + floor 向下取整", () => { + expect(getValueOfRule("3.9", { integer: true, floor: true })).toBe("3"); + }); + + test("integer + ceil 向上取整", () => { + expect(getValueOfRule("3.1", { integer: true, ceil: true })).toBe("4"); + }); +}); + +describe("getValueOfRule - noSign 不允许正负号", () => { + test("带正号的输入被视为非法", () => { + expect(getValueOfRule("+5", { noSign: true, required: true, init: 0 })).toBe("0"); + }); + + test("带负号的输入被视为非法", () => { + expect(getValueOfRule("-5", { noSign: true, required: true, init: 0 })).toBe("0"); + }); + + test("无符号数字正常通过", () => { + expect(getValueOfRule("5", { noSign: true })).toBe("5"); + }); +}); + +describe("getValueOfRule - callback", () => { + test("callback 接收最终处理后的值", () => { + let received: unknown; + getValueOfRule("42", { number: true }, (v) => { received = v; }); + expect(received).toBe(42); + }); +}); + +describe("getValueOfRule - 综合场景", () => { + test("限制输入 min:0, max:99999.99, required, digits:2", () => { + expect(getValueOfRule("100.456", { min: 0, max: 99999.99, required: true, digits: 2 })).toBe("100.46"); + }); + + test("超出 max 时截断到 max", () => { + expect(getValueOfRule("100000", { min: 0, max: 99999.99, required: true, digits: 2 })).toBe("99999.99"); + }); + + test("空输入 required 回退到 min", () => { + expect(getValueOfRule("", { min: 0, max: 99999.99, required: true, digits: 2 })).toBe("0"); + }); + + test("负数输入限制到 min:0", () => { + expect(getValueOfRule("-5", { min: 0, max: 100, required: true })).toBe("0"); + }); +});