01MVP 标识01MVP
包文档UI 与工具utils 工具函数

utils 工具函数

通用工具函数库,提供用户名处理、手机号验证、资料验证、成员等级等功能

概述

@mono/utils 是项目的通用工具函数库,提供了项目中常用的工具函数、验证逻辑和格式化功能。它包含用户名处理、手机号国际化支持、资料完整性验证、成员等级系统等核心功能。

为什么需要这个包?

虽然有很多第三方工具库,但我们仍然需要一个统一的工具包来:

  1. 业务逻辑封装 - 封装项目特定的业务规则和验证逻辑
  2. 类型安全 - 完整的 TypeScript 类型定义
  3. 国际化支持 - 支持多国家/地区的手机号格式
  4. 一致性 - 确保整个项目使用统一的工具函数
  5. 可维护性 - 集中管理常用功能,便于维护和更新

核心功能

1. 核心工具函数

提供 className 合并、URL 生成等基础工具。

className 合并

import { cn } from "@mono/utils";

// 合并 Tailwind CSS 类名,自动处理冲突
const className = cn("px-2 py-1", "px-4");
// 结果: "py-1 px-4" (px-4 覆盖 px-2)

// 支持条件类名
const buttonClass = cn(
  "px-4 py-2 rounded",
  isActive && "bg-blue-500",
  isDisabled && "opacity-50"
);

URL 生成

import { getBaseUrl } from "@mono/utils";

// 自动获取应用的基础 URL
const baseUrl = getBaseUrl();
// 生产环境: "https://example.com"
// 开发环境: "http://localhost:3000"

// 用于生成完整 URL
const shareUrl = `${getBaseUrl()}/events/${eventId}`;

2. 用户名处理

提供用户名清理、验证和生成功能。

用户名清理

import { sanitizeUsername } from "@mono/utils";

// 清理用户输入,生成有效的用户名格式
sanitizeUsername("John Doe!"); // "john_doe"
sanitizeUsername("张三 123"); // "123"
sanitizeUsername("user@example.com"); // "userexamplecom"

用户名验证

import { isValidUsername, RESERVED_USERNAMES } from "@mono/utils";

// 验证用户名是否有效
if (isValidUsername("john_doe")) {
  console.log("用户名有效");
}

// 验证规则:
// - 长度 2-20 字符
// - 仅包含字母、数字、下划线
// - 不以下划线开头或结尾
// - 不在保留列表中

// 检查保留用户名
if (RESERVED_USERNAMES.includes("admin")) {
  console.log("admin 是保留用户名");
}

用户名生成

import { generateUsername } from "@mono/utils";

// 生成唯一用户名,自动处理冲突
const username = generateUsername("张三", ["zhangsan", "zhangsan1"]);
// 可能返回: "user_123456" (中文无法转换时的备选方案)

const username2 = generateUsername("John Doe", ["john_doe"]);
// 可能返回: "john_doe_dev" 或 "john_doe1"

3. 显示名称验证

import { isValidDisplayName } from "@mono/utils";

// 验证显示名称
if (isValidDisplayName("张三")) {
  console.log("显示名称有效");
}

// 验证规则:
// - 长度 1-50 字符
// - 不包含 < > " ` 等危险字符
// - 支持中文、英文、数字等

4. 手机号格式化

支持多国家/地区的手机号格式化和标准化。

标准化手机号

import { normalizePhoneNumber } from "@mono/utils";

// 将各种格式统一为标准格式 (+国家代码+号码)
normalizePhoneNumber("138 1234 5678"); // "+8613812345678"
normalizePhoneNumber("8613812345678"); // "+8613812345678"
normalizePhoneNumber("+86 138-1234-5678"); // "+8613812345678"

格式化显示

import { formatPhoneNumberForDisplay } from "@mono/utils";

// 格式化手机号用于显示
formatPhoneNumberForDisplay("+8613812345678");
// "+86 138 1234 5678"

formatPhoneNumberForDisplay("+12025551234");
// "+1 (202) 555-1234"

formatPhoneNumberForDisplay("+4420712345678");
// "+4420712345678" (其他国家保持原格式)

提取信息

import {
  extractCountryCode,
  extractPhoneNumber,
  isStandardPhoneNumber,
} from "@mono/utils";

// 提取国家代码
extractCountryCode("+8613812345678"); // "+86"

// 提取本地号码
extractPhoneNumber("+8613812345678"); // "13812345678"

// 检查是否为标准格式
isStandardPhoneNumber("+8613812345678"); // true
isStandardPhoneNumber("13812345678"); // false

5. 手机号验证

提供严格的手机号验证,支持多个国家/地区。

基础验证

import { validatePhoneNumber } from "@mono/utils";

// 验证手机号格���
const result = validatePhoneNumber("+86", "13800138000");
if (result.isValid) {
  console.log("手机号有效");
} else {
  console.log(result.errorMessage); // "中国大陆手机号至少需要11位数字"
  console.log(result.suggestion); // "示例: 13800138000"
}

支持的国家/地区

// 中国大陆 (+86)
validatePhoneNumber("+86", "13800138000"); // ✅

// 香港 (+852)
validatePhoneNumber("+852", "91234567"); // ✅

// 澳门 (+853)
validatePhoneNumber("+853", "61234567"); // ✅

// 台湾 (+886)
validatePhoneNumber("+886", "912345678"); // ✅

// 美国/加拿大 (+1)
validatePhoneNumber("+1", "2025551234"); // ✅

// 新加坡 (+65)
validatePhoneNumber("+65", "91234567"); // ✅

// 英国 (+44)
validatePhoneNumber("+44", "7912345678"); // ✅

// 日本 (+81)
validatePhoneNumber("+81", "9012345678"); // ✅

// 韩国 (+82)
validatePhoneNumber("+82", "1012345678"); // ✅

完整手机号验证

import { validateFullPhoneNumber } from "@mono/utils";

// 验证包含国家代码的完整手机号
const result = validateFullPhoneNumber("+8613800138000");
if (result.isValid) {
  console.log("手机号有效");
}

// 自动识别国家代码
validateFullPhoneNumber("+12025551234"); // 美国号码
validateFullPhoneNumber("+8529123456"); // 香港号码

获取验证规则

import { getPhoneValidationRule } from "@mono/utils";

// 获取国家的验证规则
const rule = getPhoneValidationRule("+86");
console.log(rule.countryName); // "中国大陆"
console.log(rule.minLength); // 11
console.log(rule.maxLength); // 11
console.log(rule.example); // "13800138000"

6. 资料验证

提供用户资料完整性验证功能。

核心资料验证

import { validateCoreProfile } from "@mono/utils";

const user = {
  name: "张三",
  email: "zhangsan@example.com",
  phoneNumber: "+8613800138000",
  bio: "一个热爱编程的开发者",
  userRoleString: "全栈工程师",
  currentWorkOn: "开发 MVP 项目",
  lifeStatus: "在职",
};

const validation = validateCoreProfile(user);
console.log(validation.isComplete); // true/false
console.log(validation.completionPercentage); // 85
console.log(validation.missingFields); // ["个人简介", "当前在做"]
console.log(validation.missingCount); // 2

必填字段

核心资料的必填字段包括:

  • 姓名
  • 手机号
  • 邮箱
  • 个人简介
  • 主要角色
  • 当前在做
  • 当前状态

推荐字段

import { getMissingRecommendedProfileFields } from "@mono/utils";

// 获取缺失的推荐字段
const missing = getMissingRecommendedProfileFields(user);
// ["微信号", "技能标签", "可以提供的帮助", "寻找什么"]

组织申请验证

import { validateProfileForOrganizationApplication } from "@mono/utils";

// 验证组织申请所需的资料 (比核心资料要求更多)
const validation = validateProfileForOrganizationApplication(user);

// 额外必填字段:
// - 用户名
// - 地区
// - 性别
// - 微信号

生成提示消息

import { getProfileCompletionMessage } from "@mono/utils";

const validation = validateCoreProfile(user);
const message = getProfileCompletionMessage(validation);

console.log(message.title); // "资料待完善"
console.log(message.description); // "还需要完善 2 项信息,包括:个人简介、当前在做"
console.log(message.actionText); // "完善资料"

7. 成员等级系统

基于贡献值 (CP) 的成员等级系统。

获取成员等级

import { getMemberLevel } from "@mono/utils";

const level = getMemberLevel(50);
console.log(level.level); // 3
console.log(level.name); // "贡献者"
console.log(level.color); // "text-blue-600 bg-blue-100"
console.log(level.badge); // "⭐"

等级划分

CP 值等级名称徽章
00观众👀
1-91注册用户👋
10-492社区成员🔥
50-1993贡献者
200+4核心贡献者🌟

权限检查

import { canViewMemberQrCode } from "@mono/utils";

// 检查是否可以查看成员二维码 (需要社区成员及以上)
if (canViewMemberQrCode(user.cpValue)) {
  console.log("可以查看二维码");
}

8. 常量配置

提供项目中常用的常量配置。

城市列表

import { CITIES } from "@mono/utils";

// 城市选项
console.log(CITIES);
// ["北京", "上海", "深圳", "广州", "杭州", "成都", ...]

标签预设

import { TAG_PRESETS, getAllTags } from "@mono/utils";

// 按类别获取标签
console.log(TAG_PRESETS.技术领域);
// ["AI", "Web3", "硬件", "独立开发", "设计", "出海", "开源"]

console.log(TAG_PRESETS.活动类型);
// ["黑客松", "线下聚会", "线上分享", "工作坊", ...]

// 获取所有标签
const allTags = getAllTags();
// ["AI", "Web3", ..., "黑客松", ..., "教育", ...]

实战示例

示例 1: 用户注册表单

import {
  sanitizeUsername,
  isValidUsername,
  validatePhoneNumber,
} from "@mono/utils";
import { useState } from "react";

export function RegisterForm() {
  const [username, setUsername] = useState("");
  const [phone, setPhone] = useState("");
  const [errors, setErrors] = useState<Record<string, string>>({});

  const handleUsernameChange = (value: string) => {
    const sanitized = sanitizeUsername(value);
    setUsername(sanitized);

    if (!isValidUsername(sanitized)) {
      setErrors((prev) => ({
        ...prev,
        username: "用户名格式不正确 (2-20字符,仅字母数字下划线)",
      }));
    } else {
      setErrors((prev) => {
        const { username, ...rest } = prev;
        return rest;
      });
    }
  };

  const handlePhoneChange = (value: string) => {
    setPhone(value);
    const result = validatePhoneNumber("+86", value);

    if (!result.isValid) {
      setErrors((prev) => ({
        ...prev,
        phone: result.errorMessage || "手机号格式不正确",
      }));
    } else {
      setErrors((prev) => {
        const { phone, ...rest } = prev;
        return rest;
      });
    }
  };

  return (
    <form>
      <div>
        <input
          value={username}
          onChange={(e) => handleUsernameChange(e.target.value)}
          placeholder="用户名"
        />
        {errors.username && <p className="error">{errors.username}</p>}
      </div>

      <div>
        <input
          value={phone}
          onChange={(e) => handlePhoneChange(e.target.value)}
          placeholder="手机号"
        />
        {errors.phone && <p className="error">{errors.phone}</p>}
      </div>

      <button type="submit" disabled={Object.keys(errors).length > 0}>
        注册
      </button>
    </form>
  );
}

示例 2: 资料完善提示

import {
  validateCoreProfile,
  getProfileCompletionMessage,
} from "@mono/utils";

export function ProfileCompletionBanner({ user }) {
  const validation = validateCoreProfile(user);

  // 资料完整时不显示
  if (validation.isComplete) {
    return null;
  }

  const message = getProfileCompletionMessage(validation);

  return (
    <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
      <div className="flex items-start justify-between">
        <div className="flex-1">
          <h3 className="font-semibold text-yellow-900">{message.title}</h3>
          <p className="text-sm text-yellow-700 mt-1">{message.description}</p>

          {/* 进度条 */}
          <div className="mt-3 bg-yellow-200 rounded-full h-2">
            <div
              style={{ width: `${validation.completionPercentage}%` }}
              className="bg-yellow-500 h-2 rounded-full transition-all"
            />
          </div>

          <p className="text-xs text-yellow-600 mt-1">
            已完成 {validation.completedCount}/{validation.totalRequiredFields} 
          </p>
        </div>

        <a
          href="/profile/edit"
          className="ml-4 px-4 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600"
        >
          {message.actionText}
        </a>
      </div>
    </div>
  );
}

示例 3: 手机号输入组件

import {
  normalizePhoneNumber,
  formatPhoneNumberForDisplay,
  validatePhoneNumber,
} from "@mono/utils";
import { useState } from "react";

interface PhoneInputProps {
  value: string;
  onChange: (value: string) => void;
  countryCode?: string;
}

export function PhoneInput({
  value,
  onChange,
  countryCode = "+86",
}: PhoneInputProps) {
  const [displayValue, setDisplayValue] = useState(
    formatPhoneNumberForDisplay(value)
  );
  const [error, setError] = useState("");

  const handleChange = (input: string) => {
    // 更新显示值
    setDisplayValue(input);

    // 标准化并验证
    const normalized = normalizePhoneNumber(input);
    const phoneNumber = normalized.replace(countryCode, "");
    const validation = validatePhoneNumber(countryCode, phoneNumber);

    if (validation.isValid) {
      onChange(normalized);
      setError("");
    } else {
      setError(validation.errorMessage || "");
    }
  };

  const handleBlur = () => {
    // 失焦时格式化显示
    if (value) {
      setDisplayValue(formatPhoneNumberForDisplay(value));
    }
  };

  return (
    <div>
      <input
        type="tel"
        value={displayValue}
        onChange={(e) => handleChange(e.target.value)}
        onBlur={handleBlur}
        placeholder="请输入手机号"
        className={error ? "border-red-500" : ""}
      />
      {error && <p className="text-sm text-red-600 mt-1">{error}</p>}
    </div>
  );
}

示例 4: 成员等级展示

import { getMemberLevel, canViewMemberQrCode } from "@mono/utils";

export function MemberBadge({ cpValue }: { cpValue: number }) {
  const level = getMemberLevel(cpValue);

  return (
    <div className="flex items-center gap-3">
      <div className={`px-3 py-1 rounded-full ${level.color}`}>
        <span className="mr-1">{level.badge}</span>
        <span className="text-sm font-medium">{level.name}</span>
      </div>

      {canViewMemberQrCode(cpValue) && (
        <button className="text-sm text-blue-600 hover:underline">
          查看二维码
        </button>
      )}
    </div>
  );
}

示例 5: 组织申请检查

import { validateProfileForOrganizationApplication } from "@mono/utils";
import { redirect } from "next/navigation";

export default async function OrganizationApplicationPage() {
  const user = await getUser();
  const validation = validateProfileForOrganizationApplication(user);

  if (!validation.isComplete) {
    return (
      <div className="max-w-2xl mx-auto p-6">
        <div className="bg-red-50 border border-red-200 rounded-lg p-6">
          <h2 className="text-xl font-semibold text-red-900 mb-2">
            资料不完整
          </h2>
          <p className="text-red-700 mb-4">
            申请创建组织需要完善以下信息:
          </p>
          <ul className="list-disc list-inside space-y-1 text-red-700">
            {validation.missingFields.map((field) => (
              <li key={field}>{field}</li>
            ))}
          </ul>
          <a
            href="/profile/edit"
            className="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
          >
            完善资料
          </a>
        </div>
      </div>
    );
  }

  return <OrganizationApplicationForm />;
}

最佳实践

1. 用户名生成

始终使用 generateUsername 而不是手动拼接:

// ✅ 推荐
const username = generateUsername(user.name, existingUsernames);

// ❌ 不推荐
const username = user.name.toLowerCase().replace(/\s/g, "_");

2. 手机号处理

存储标准化格式,显示格式化版本:

// ✅ 推荐
// 存储
const normalized = normalizePhoneNumber(input);
await db.user.update({ phoneNumber: normalized });

// 显示
const display = formatPhoneNumberForDisplay(user.phoneNumber);

// ❌ 不推荐
// 直接存储用户输入
await db.user.update({ phoneNumber: input });

3. 资料验证

在多个场景使用统一的验证逻辑:

// ✅ 推荐
const validation = validateCoreProfile(user);
if (!validation.isComplete) {
  return <ProfileCompletionPrompt validation={validation} />;
}

// ❌ 不推荐
// 在每个组件中重复验证逻辑
if (!user.name || !user.email || !user.phoneNumber) {
  // ...
}

4. 错误提示

使用验证结果中的建议信息:

// ✅ 推荐
const result = validatePhoneNumber("+86", phone);
if (!result.isValid) {
  showError(result.errorMessage);
  if (result.suggestion) {
    showHint(result.suggestion);
  }
}

// ❌ 不推荐
if (!isValidPhone(phone)) {
  showError("手机号格式不正确");
}

5. 成员等级展示

使用预定义的颜色和徽章:

// ✅ 推荐
const level = getMemberLevel(user.cpValue);
return <div className={level.color}>{level.badge} {level.name}</div>;

// ❌ 不推荐
// 硬编码等级逻辑
const levelName = user.cpValue >= 50 ? "贡献者" : "成员";

故障排查

用户名验证失败

问题: isValidUsername() 返回 false

解决方案:

  1. 检查长度是否在 2-20 字符之间
  2. 确认不包含特殊字符
  3. 检查是否在保留列表中
// 调试代码
console.log("Username:", username);
console.log("Length:", username.length);
console.log("Is reserved:", RESERVED_USERNAMES.includes(username.toLowerCase()));

手机号格式化错误

问题: normalizePhoneNumber() 返回意外结果

解决方案: 确保输入包含足够的数字位数

// ✅ 正确
normalizePhoneNumber("13812345678"); // "+8613812345678"

// ❌ 错误
normalizePhoneNumber("138"); // "+138" (不完整)

资料验证不准确

问题: validateCoreProfile() 结果不符合预期

解决方案: 检查用户对象是否包含所有必需字段

// 调试代码
const validation = validateCoreProfile(user);
console.log("Missing fields:", validation.missingFields);
console.log("User object:", user);

手机号验证失败

问题: 有效的手机号被标记为无效

解决方案:

  1. 确认国家代码正确
  2. 检查号码长度
  3. 验证号码格式
// 调试代码
const result = validatePhoneNumber("+86", phone);
console.log("Validation result:", result);

const rule = getPhoneValidationRule("+86");
console.log("Validation rule:", rule);

API 参考

完整的 API 文档请参考 README.md

相关资源