Compare commits

...

2 Commits

Author SHA1 Message Date
夏犀麟
8ee1f71b41 Merge remote-tracking branch 'origin/main'
All checks were successful
Build and Deploy (tool.xpcool.com) / build-and-deploy (push) Successful in 1m0s
2026-08-22 15:25:49 +08:00
夏犀麟
42d4de46ba feat(tools): 添加工具分类机制并新增多个生活助手工具
- 实现工具分类功能,支持按工作/生活等维度分组展示
- 新增BMI计算器,支持按中国成人标准分级与健康体重范围
- 新增房贷计算器,支持等额本息与等额本金两种还款方式对比
- 新增账单拆分工具,支持多人AA制自动生成转账方案
- 新增随机决定工具,支持选项抽签与随机数生成
- 新增番茄钟功能,支持专注与休息倒计时及轮数记录
- 更新首页与侧边栏按分类分组渲染工具列表
- 优化工具注册表支持category字段配置分类归属
2026-08-22 15:25:29 +08:00
9 changed files with 1439 additions and 85 deletions

View File

@ -47,17 +47,20 @@ export interface ToolItem {
name: string // 工具名(菜单/卡片/页面标题)
desc: string // 一句话描述
icon: Component // TDesign 图标组件
category?: string // 分类 key对应 toolCategories缺省归入第一个分类
component: () => Promise<Component> // 懒加载组件
}
```
**分类机制**`toolCategories` 数组定义分类(目前 `work` 工作效率、`life` 生活助手),`groupedTools()` 按分类分组返回工具,侧边导航(`SideNav.vue`)与首页分区(`HomeView.vue`)均消费该函数。**新增分类零改动**:在 `toolCategories` 追加一条 `{ key/label/desc }`,再把对应工具的 `category` 指向新 key 即可。
**新增一个工具的完整步骤:**
1. 新建 `src/views/<tool-name>/<ToolName>View.vue`,参考现有页面结构(标题 → 表单卡片 → 错误提示 → 结果卡片 → 历史面板)。
2. 在 `tools.ts``tools` 数组末尾追加一条配置,`component` 使用 `() => import('@/views/<tool-name>/<ToolName>View.vue')`。
2. 在 `tools.ts``tools` 数组对应分类分区末尾追加一条配置,`component` 使用 `() => import('@/views/<tool-name>/<ToolName>View.vue')`,并填写 `category`。
3. 完成——菜单、首页卡片、路由、文档标题自动生效。可运行 `pnpm type-check` 验证。
**注意:** `path` 必须与页面内 `useHistory('xxx')` 的 key 一致(历史记录约定),并避免与现有 27 个 path 重复。
**注意:** `path` 必须与页面内 `useHistory('xxx')` 的 key 一致(历史记录约定),并避免与现有工具的 path 重复。
### 3.2 历史记录约定
@ -115,6 +118,11 @@ export interface ToolItem {
| 日期计算 | `views/date-calc` | 原生 Date本地时间构造 | 三个 tab差值/推算/年龄生肖。`parseDate` 用 `new Date(y, m-1, d)` + 回读校验防时区偏移与 2 月 30 类非法日期;月/年推算先归 1 号再 setMonth/setFullYear溢出钳制到目标月最后一天生肖 `((year-4)%12+12)%12` 正模 |
| 密码生成 | `views/password-gen` | `crypto.getRandomValues` + 拒绝采样 | 每种选中字符集至少出现一次 + Fisher-Yates 洗牌t-slider 数值用外部 `<span>` 显示label 不能传函数);强度 = 长度×log2(池大小) 分四档 |
| HTTP 状态码 | `views/http-status` | 内置静态数据56 条1xx-5xx | 搜索code/短语/中文)+ 分类筛选;历史存搜索词 |
| BMI 计算器 | `views/bmi` | 纯 TS 计算 | 中国成人标准四级分类(<18.5/24/28健康体重范围按 18.5~23.9 反推历史存 `身高|体重` |
| 房贷计算器 | `views/mortgage` | 纯 TS 金融公式 | 等额本息/等额本金一次算出并对比;等额本息 `M = P·r·(1+r)^n / ((1+r)^n 1)`,等额本金总利息等差求和 `(n+1)·P·r/2`;利率为 0 需特判;历史存 JSON 快照 |
| 账单拆分 | `views/bill-split` | 纯 TS 贪心结算 | 每人已付 → 人均 → 净差额;欠款方升序/收款方降序配对生成转账方案;最多 20 人;历史存 `[[名字,金额],...]` |
| 随机决定 | `views/random-decision` | `crypto.getRandomValues` 拒绝采样 | 选项抽签 + 随机数两个 tab`secureRandInt` 拒绝采样避免取模偏斜;不重复模式小范围洗牌/大范围 Set历史存 JSON 快照 |
| 番茄钟 | `views/pomodoro` | 原生定时器 + Web Audio | 基于目标时间戳计时(`endTime` + 250ms tick后台节流不漂移阶段结束自动切换并计轮提示音用 AudioContext 880Hz 振荡器;`onBeforeUnmount` 清理定时器;历史存 `focus=25 | rest=5` |
## 5. OCR 特殊说明(重要)
@ -177,4 +185,4 @@ pnpm preview # 预览 dist
- **PWA / 离线**:可接入 `vite-plugin-pwa`,配合第 5 节 OCR 离线方案实现完全离线工具站。
- **国际化**:文案目前硬编码中文,若需 i18n 建议 vue-i18n工具注册表的 `name/desc` 改为 key。
- **体积优化**TDesign 目前全量注册(约 1.4MB gzip 373KB若追求极致可改按需引入unplugin-vue-components + TDesignResolver
- **更多工具**图片格式互转canvas、Markdown 预览markdown-it、SQL 格式化sql-formatter、CSS 渐变生成器、番茄钟、BMI/贷款计算器等均可按现有模式快速复制
- **更多工具**图片格式互转canvas、Markdown 预览markdown-it、SQL 格式化sql-formatter、CSS 渐变生成器、农历/节假日查询等均可按现有模式快速复制;新增分类见 3.1 节

View File

@ -5,33 +5,38 @@
<span>首页</span>
</router-link>
<div class="nav-group-title">全部工具</div>
<template v-for="group in groups" :key="group.category.key">
<div class="nav-group-title">{{ group.category.label }}</div>
<router-link
v-for="tool in tools"
:key="tool.path"
:to="`/${tool.path}`"
class="nav-item"
:class="{ active: route.path === `/${tool.path}` }"
@click="emit('navigate')"
>
<component :is="tool.icon" class="nav-icon" />
<span>{{ tool.name }}</span>
</router-link>
<router-link
v-for="tool in group.items"
:key="tool.path"
:to="`/${tool.path}`"
class="nav-item"
:class="{ active: route.path === `/${tool.path}` }"
@click="emit('navigate')"
>
<component :is="tool.icon" class="nav-icon" />
<span>{{ tool.name }}</span>
</router-link>
</template>
</nav>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { HomeIcon } from 'tdesign-icons-vue-next'
import { tools } from '@/router/tools'
import { groupedTools } from '@/router/tools'
/**
* 侧边导航数据源为工具注册表 tools.ts
* 侧边导航数据源为工具注册表 tools.ts按分类分组渲染
* PC 端渲染在左侧固定栏移动端渲染在抽屉内点击后由父组件关闭抽屉
*/
const route = useRoute()
/** 分类分组(新增分类自动出现,无需改动本组件) */
const groups = groupedTools()
const emit = defineEmits<{
/** 点击菜单项后通知父组件(移动端用于关闭抽屉) */
navigate: []

View File

@ -24,6 +24,11 @@ import {
MoneyIcon,
InternetIcon,
KeyIcon,
HeartIcon,
WalletIcon,
UsergroupIcon,
GiftIcon,
AlarmIcon,
} from 'tdesign-icons-vue-next'
/**
@ -32,9 +37,28 @@ import {
*
*
* 1. src/views/<工具名>/
* 2. tools path/name/desc/icon/component
* 2. tools path/name/desc/icon/category/component
* 3.
*
* 🗂 toolCategories { key/label/desc }
* category key
*/
/** 工具分类定义(数据驱动,顺序即展示顺序) */
export interface ToolCategory {
/** 分类唯一标识,与 ToolItem.category 对应 */
key: string
/** 分类名称(导航分组标题、首页分区标题) */
label: string
/** 一句话描述(首页分区副标题) */
desc: string
}
export const toolCategories: ToolCategory[] = [
{ key: 'work', label: '工作效率', desc: '开发编程、办公文本与文件处理' },
{ key: 'life', label: '生活助手', desc: '日常生活中的计算、查询与决策小工具' },
]
export interface ToolItem {
/** 路由路径(相对根路径,不含前导斜杠) */
path: string
@ -44,37 +68,28 @@ export interface ToolItem {
desc: string
/** 菜单/卡片图标TDesign 图标组件) */
icon: Component
/** 所属分类 key必须在 toolCategories 中存在),缺省归入第一个分类 */
category?: string
/** 页面组件(懒加载,减小首屏体积) */
component: () => Promise<Component>
}
export const tools: ToolItem[] = [
/* ==================== 工作效率 ==================== */
{
path: 'image-compress',
name: '图片压缩',
desc: '本地压缩 JPG/PNG/WebP可调质量压缩后直接下载',
icon: ImageIcon,
category: 'work',
component: () => import('@/views/image-compress/ImageCompressView.vue'),
},
{
path: 'image-cropper',
name: '图片裁剪',
desc: '按比例裁剪图片,实时预览,导出 JPG/PNG',
icon: FrameIcon,
component: () => import('@/views/image-cropper/ImageCropperView.vue'),
},
{
path: 'qrcode',
name: '二维码生成解析',
desc: '文本/链接生成二维码,上传图片解析二维码内容',
icon: QrcodeIcon,
component: () => import('@/views/qrcode/QrcodeView.vue'),
},
{
path: 'json-format',
name: 'JSON 格式化',
desc: 'JSON 格式化、压缩、校验,一键复制结果',
icon: CodeIcon,
category: 'work',
component: () => import('@/views/json-format/JsonFormatView.vue'),
},
{
@ -82,6 +97,7 @@ export const tools: ToolItem[] = [
name: '时间戳转换',
desc: '时间戳与北京时间互转,支持秒/毫秒与批量转换',
icon: TimeIcon,
category: 'work',
component: () => import('@/views/timestamp/TimestampView.vue'),
},
{
@ -89,6 +105,7 @@ export const tools: ToolItem[] = [
name: 'Base64 编解码',
desc: '文本 Base64 编解码,图片文件与 Base64 互转',
icon: FileIcon,
category: 'work',
component: () => import('@/views/base64/Base64View.vue'),
},
{
@ -96,6 +113,7 @@ export const tools: ToolItem[] = [
name: 'JWT 解析',
desc: '解析 JWT 的 Header 与 Payload展示过期时间',
icon: LockOnIcon,
category: 'work',
component: () => import('@/views/jwt-parse/JwtParseView.vue'),
},
{
@ -103,6 +121,7 @@ export const tools: ToolItem[] = [
name: 'Cron 表达式',
desc: '解析 Cron 下次执行时间,可视化生成表达式',
icon: CalendarIcon,
category: 'work',
component: () => import('@/views/cron/CronView.vue'),
},
{
@ -110,6 +129,7 @@ export const tools: ToolItem[] = [
name: '进制转换',
desc: '二进制、八进制、十进制、十六进制互转',
icon: CalculatorIcon,
category: 'work',
component: () => import('@/views/radix-convert/RadixConvertView.vue'),
},
{
@ -117,27 +137,15 @@ export const tools: ToolItem[] = [
name: '哈希计算',
desc: '文本/文件计算 MD5、SHA1、SHA256、SHA512',
icon: FingerprintIcon,
category: 'work',
component: () => import('@/views/hash/HashView.vue'),
},
{
path: 'idcard',
name: '身份证解析',
desc: '校验身份证合法性,解析生日、性别、年龄、地区',
icon: CreditcardIcon,
component: () => import('@/views/idcard/IdcardView.vue'),
},
{
path: 'address-parse',
name: '地址解析',
desc: '拆分中文地址的省、市、区与详细地址',
icon: LocationIcon,
component: () => import('@/views/address-parse/AddressParseView.vue'),
},
{
path: 'url-parse',
name: 'URL 解析',
desc: '解析协议/域名/路径/参数,也支持构造 URL',
icon: LinkIcon,
category: 'work',
component: () => import('@/views/url-parse/UrlParseView.vue'),
},
{
@ -145,6 +153,7 @@ export const tools: ToolItem[] = [
name: '翻译',
desc: '中文、英文、韩文、日文互译(免费在线接口)',
icon: TranslateIcon,
category: 'work',
component: () => import('@/views/translate/TranslateView.vue'),
},
{
@ -152,6 +161,7 @@ export const tools: ToolItem[] = [
name: '图片 OCR 识别',
desc: '本地识别图片文字支持中英文WASM',
icon: BrowseIcon,
category: 'work',
component: () => import('@/views/ocr/OcrView.vue'),
},
{
@ -159,6 +169,7 @@ export const tools: ToolItem[] = [
name: 'UUID 生成',
desc: '批量生成 UUID v4支持大小写与去连字符',
icon: RefreshIcon,
category: 'work',
component: () => import('@/views/uuid/UuidView.vue'),
},
{
@ -166,6 +177,7 @@ export const tools: ToolItem[] = [
name: '正则测试',
desc: '实时测试正则匹配、分组与替换,匹配高亮显示',
icon: ControlPlatformIcon,
category: 'work',
component: () => import('@/views/regex-test/RegexTestView.vue'),
},
{
@ -173,6 +185,7 @@ export const tools: ToolItem[] = [
name: '命名转换',
desc: '驼峰、蛇形、短横线等命名风格一键互转',
icon: CodeIcon,
category: 'work',
component: () => import('@/views/case-convert/CaseConvertView.vue'),
},
{
@ -180,20 +193,15 @@ export const tools: ToolItem[] = [
name: '色值转换',
desc: 'HEX、RGB、HSL、HSV 互转,取色器 + 预设色板',
icon: Palette1Icon,
category: 'work',
component: () => import('@/views/color-convert/ColorConvertView.vue'),
},
{
path: 'unit-convert',
name: '单位换算',
desc: '长度、质量、温度、数据存储等 8 类单位换算',
icon: SwapIcon,
component: () => import('@/views/unit-convert/UnitConvertView.vue'),
},
{
path: 'yaml-json',
name: 'YAML ↔ JSON',
desc: 'YAML 与 JSON 双向转换,报错定位清晰',
icon: FileCodeIcon,
category: 'work',
component: () => import('@/views/yaml-json/YamlJsonView.vue'),
},
{
@ -201,27 +209,73 @@ export const tools: ToolItem[] = [
name: '字数统计',
desc: '统计字符、汉字、单词、段落与阅读时长',
icon: ChartIcon,
category: 'work',
component: () => import('@/views/word-count/WordCountView.vue'),
},
{
path: 'rmb-upper',
name: '人民币大写',
desc: '数字金额转人民币大写,处理角分与负数',
icon: MoneyIcon,
component: () => import('@/views/rmb-upper/RmbUpperView.vue'),
},
{
path: 'zh-convert',
name: '简繁转换',
desc: '简体与繁体中文互转opencc 词库)',
icon: TranslateIcon,
category: 'work',
component: () => import('@/views/zh-convert/ZhConvertView.vue'),
},
/* ==================== 生活助手 ==================== */
{
path: 'image-cropper',
name: '图片裁剪',
desc: '按比例裁剪图片,实时预览,导出 JPG/PNG',
icon: FrameIcon,
category: 'life',
component: () => import('@/views/image-cropper/ImageCropperView.vue'),
},
{
path: 'qrcode',
name: '二维码生成解析',
desc: '文本/链接生成二维码,上传图片解析二维码内容',
icon: QrcodeIcon,
category: 'life',
component: () => import('@/views/qrcode/QrcodeView.vue'),
},
{
path: 'idcard',
name: '身份证解析',
desc: '校验身份证合法性,解析生日、性别、年龄、地区',
icon: CreditcardIcon,
category: 'life',
component: () => import('@/views/idcard/IdcardView.vue'),
},
{
path: 'address-parse',
name: '地址解析',
desc: '拆分中文地址的省、市、区与详细地址',
icon: LocationIcon,
category: 'life',
component: () => import('@/views/address-parse/AddressParseView.vue'),
},
{
path: 'unit-convert',
name: '单位换算',
desc: '长度、质量、温度、数据存储等 8 类单位换算',
icon: SwapIcon,
category: 'life',
component: () => import('@/views/unit-convert/UnitConvertView.vue'),
},
{
path: 'rmb-upper',
name: '人民币大写',
desc: '数字金额转人民币大写,处理角分与负数',
icon: MoneyIcon,
category: 'life',
component: () => import('@/views/rmb-upper/RmbUpperView.vue'),
},
{
path: 'date-calc',
name: '日期计算',
desc: '日期差值、推算与年龄生肖星座计算',
icon: CalendarIcon,
category: 'life',
component: () => import('@/views/date-calc/DateCalcView.vue'),
},
{
@ -229,13 +283,63 @@ export const tools: ToolItem[] = [
name: '密码生成',
desc: '随机强密码批量生成,含强度评估',
icon: KeyIcon,
category: 'life',
component: () => import('@/views/password-gen/PasswordGenView.vue'),
},
{
path: 'http-status',
name: 'HTTP 状态码',
desc: '常用 HTTP 状态码速查,支持搜索与分类',
icon: InternetIcon,
component: () => import('@/views/http-status/HttpStatusView.vue'),
path: 'bmi',
name: 'BMI 计算器',
desc: '计算身体质量指数,中国标准分级与健康体重范围',
icon: HeartIcon,
category: 'life',
component: () => import('@/views/bmi/BmiView.vue'),
},
{
path: 'mortgage',
name: '房贷计算器',
desc: '等额本息/等额本金对比,月供、总利息一目了然',
icon: WalletIcon,
category: 'life',
component: () => import('@/views/mortgage/MortgageView.vue'),
},
{
path: 'bill-split',
name: '账单拆分',
desc: '多人聚餐 AA 分账,自动生成转账方案',
icon: UsergroupIcon,
category: 'life',
component: () => import('@/views/bill-split/BillSplitView.vue'),
},
{
path: 'random-decision',
name: '随机决定',
desc: '选项抽签治选择困难,随机数/骰子一键生成',
icon: GiftIcon,
category: 'life',
component: () => import('@/views/random-decision/RandomDecisionView.vue'),
},
{
path: 'pomodoro',
name: '番茄钟',
desc: '专注与休息倒计时,记录完成轮数',
icon: AlarmIcon,
category: 'life',
component: () => import('@/views/pomodoro/PomodoroView.vue'),
},
]
/** 工具默认分类:未填写 category 时归入第一个分类 */
export function toolCategoryKey(tool: ToolItem): string {
return tool.category ?? toolCategories[0].key
}
/**
* toolCategories
* 使
*/
export function groupedTools(): Array<{ category: ToolCategory; items: ToolItem[] }> {
return toolCategories.map((category) => ({
category,
items: tools.filter((t) => toolCategoryKey(t) === category.key),
}))
}

View File

@ -4,7 +4,7 @@
<div class="hero">
<h1 class="hero-title">在线工具箱</h1>
<p class="hero-desc">
14 个常用工具全部在浏览器本地完成计算与文件处理不上传任何数据到服务器
{{ tools.length }} 个常用工具全部在浏览器本地完成计算与文件处理不上传任何数据到服务器
</p>
<div class="hero-badges">
<t-tag theme="success" variant="light">纯前端</t-tag>
@ -13,30 +13,40 @@
</div>
</div>
<!-- 工具卡片导航 -->
<div class="tool-grid">
<router-link
v-for="tool in tools"
:key="tool.path"
:to="`/${tool.path}`"
class="tool-card"
>
<span class="tool-icon">
<component :is="tool.icon" />
</span>
<span class="tool-name">{{ tool.name }}</span>
<span class="tool-desc">{{ tool.desc }}</span>
<span class="tool-arrow">
<ChevronRightIcon />
</span>
</router-link>
</div>
<!-- 按分类分区的工具卡片导航 -->
<section v-for="group in groups" :key="group.category.key" class="category-section">
<div class="category-head">
<h2 class="category-title">{{ group.category.label }}</h2>
<span class="category-desc">{{ group.category.desc }}</span>
<span class="category-count">{{ group.items.length }} 个工具</span>
</div>
<div class="tool-grid">
<router-link
v-for="tool in group.items"
:key="tool.path"
:to="`/${tool.path}`"
class="tool-card"
>
<span class="tool-icon">
<component :is="tool.icon" />
</span>
<span class="tool-name">{{ tool.name }}</span>
<span class="tool-desc">{{ tool.desc }}</span>
<span class="tool-arrow">
<ChevronRightIcon />
</span>
</router-link>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon } from 'tdesign-icons-vue-next'
import { tools } from '@/router/tools'
import { tools, groupedTools } from '@/router/tools'
/** 分类分组(新增分类自动出现,无需改动本页面) */
const groups = groupedTools()
</script>
<style scoped>
@ -63,6 +73,32 @@ import { tools } from '@/router/tools'
flex-wrap: wrap;
}
/* 分类分区 */
.category-section {
margin-bottom: 28px;
}
.category-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.category-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.category-desc {
font-size: 13px;
color: var(--td-text-color-secondary);
}
.category-count {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
/* 工具卡片栅格:响应式,移动端单列 */
.tool-grid {
display: grid;

View File

@ -0,0 +1,276 @@
<!--
账单拆分AA 分账录入每人已付金额计算人均消费与净差额自动生成最少转账方案
-->
<template>
<div class="page-wrap">
<h2 class="page-title">账单拆分</h2>
<p class="page-desc">多人聚餐出游的 AA 分账录入每人已付金额自动计算人均与转账方案</p>
<t-card class="tool-section" title="成员与已付金额">
<div v-for="(item, index) in entries" :key="index" class="split-row">
<t-input v-model="item.name" :placeholder="`成员 ${index + 1}`" class="split-name" />
<t-input v-model="item.amount" placeholder="0.00" class="split-amount">
<template #suffix></template>
</t-input>
<t-button
theme="danger"
variant="text"
shape="square"
:disabled="entries.length <= 2"
@click="removeEntry(index)"
>
<template #icon><DeleteIcon /></template>
</t-button>
</div>
<div class="form-actions">
<t-button variant="dashed" @click="addEntry">
<template #icon><AddCircleIcon /></template>
添加成员
</t-button>
</div>
<div class="form-actions">
<t-button theme="primary" @click="calc">计算分账</t-button>
<t-button variant="outline" @click="resetEntries">重置</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="result" class="tool-section" title="分账结果">
<div class="avg-line">
{{ result.count }} 总消费 <b>{{ result.total }}</b>人均 <b>{{ result.avg }}</b>
</div>
<div v-if="result.transfers.length" class="transfer-list">
<div v-for="(t, i) in result.transfers" :key="i" class="transfer-item">
<span class="transfer-from">{{ t.from }}</span>
<span class="transfer-arrow"></span>
<span class="transfer-to">{{ t.to }}</span>
<b class="transfer-amount">{{ t.amount }}</b>
</div>
</div>
<div v-else class="avg-line">大家付得一样多无需转账</div>
<div class="form-actions">
<CopyButton :text="result.summary" />
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { AddCircleIcon, DeleteIcon } from 'tdesign-icons-vue-next'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
interface SplitEntry {
name: string
amount: string
}
interface Transfer {
from: string
to: string
amount: string
}
interface SplitResult {
count: number
total: string
avg: string
transfers: Transfer[]
summary: string
}
const history = useHistory('bill-split')
function defaultEntries(): SplitEntry[] {
return [
{ name: '', amount: '' },
{ name: '', amount: '' },
]
}
const entries = ref<SplitEntry[]>(defaultEntries())
const error = ref('')
const result = ref<SplitResult | null>(null)
function addEntry() {
if (entries.value.length >= 20) {
error.value = '最多支持 20 人'
return
}
entries.value.push({ name: '', amount: '' })
}
function removeEntry(index: number) {
if (entries.value.length <= 2) return
entries.value.splice(index, 1)
}
function resetEntries() {
entries.value = defaultEntries()
error.value = ''
result.value = null
}
/** 金额格式化为「元」展示:千分位 + 两位小数 */
function fmtYuan(v: number): string {
return `${v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`
}
/**
* 贪心结算欠款方按净差额从小到大收款方从大到小依次配对
* 可以在 O(n) 轮内给出笔数较少的转账方案
*/
function settle(balances: Array<{ name: string; net: number }>): Transfer[] {
const eps = 0.005
const debtors = balances.filter((b) => b.net < -eps).sort((a, b) => a.net - b.net)
const creditors = balances.filter((b) => b.net > eps).sort((a, b) => b.net - a.net)
const transfers: Transfer[] = []
let i = 0
let j = 0
while (i < debtors.length && j < creditors.length) {
const pay = Math.min(-debtors[i].net, creditors[j].net)
transfers.push({ from: debtors[i].name, to: creditors[j].name, amount: fmtYuan(pay) })
debtors[i].net += pay
creditors[j].net -= pay
if (debtors[i].net > -eps) i++
if (creditors[j].net < eps) j++
}
return transfers
}
function calc() {
error.value = ''
result.value = null
const parsed: Array<{ name: string; paid: number }> = []
for (let i = 0; i < entries.value.length; i++) {
const raw = entries.value[i].amount.trim()
if (!raw) {
error.value = `请填写「${entries.value[i].name.trim() || `成员 ${i + 1}`}」的已付金额`
return
}
const paid = Number(raw)
if (!Number.isFinite(paid) || paid < 0) {
error.value = `${entries.value[i].name.trim() || `成员 ${i + 1}`}」的金额不合法`
return
}
parsed.push({ name: entries.value[i].name.trim() || `成员 ${i + 1}`, paid })
}
const total = parsed.reduce((s, p) => s + p.paid, 0)
if (total <= 0) {
error.value = '总消费需大于 0'
return
}
const avg = total / parsed.length
// =
const balances = parsed.map((p) => ({ name: p.name, net: p.paid - avg }))
const transfers = settle(balances)
result.value = {
count: parsed.length,
total: fmtYuan(total),
avg: fmtYuan(avg),
transfers,
summary: [
`总消费 ${fmtYuan(total)}${parsed.length} 人,人均 ${fmtYuan(avg)}`,
transfers.length
? '转账方案:\n' + transfers.map((t) => ` ${t.from}${t.to}${t.amount}`).join('\n')
: '大家付得一样多,无需转账',
].join('\n'),
}
history.add(JSON.stringify(entries.value.map((e) => [e.name, e.amount])))
}
function onHistorySelect(text: string) {
// JSON [[, ], ...]
try {
const list = JSON.parse(text) as unknown
if (!Array.isArray(list) || list.length < 2) return
const restored: SplitEntry[] = []
for (const item of list) {
if (!Array.isArray(item) || restored.length >= 20) continue
restored.push({ name: String(item[0] ?? ''), amount: String(item[1] ?? '') })
}
if (restored.length < 2) return
entries.value = restored
calc()
} catch {
//
}
}
</script>
<style scoped>
.split-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.split-name {
flex: 1;
min-width: 120px;
}
.split-amount {
width: 180px;
flex-shrink: 0;
}
.avg-line {
font-size: 14px;
color: var(--td-text-color-secondary);
margin-bottom: 12px;
}
.avg-line b {
color: var(--td-text-color-primary);
}
.transfer-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.transfer-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 8px;
background: var(--td-bg-color-secondarycontainer);
font-size: 14px;
}
.transfer-from,
.transfer-to {
color: var(--td-text-color-primary);
font-weight: 500;
word-break: break-all;
}
.transfer-arrow {
color: var(--td-brand-color);
}
.transfer-amount {
margin-left: auto;
color: var(--td-brand-color);
flex-shrink: 0;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
@media (max-width: 767px) {
.split-amount {
width: 140px;
}
}
</style>

163
src/views/bmi/BmiView.vue Normal file
View File

@ -0,0 +1,163 @@
<!--
BMI 计算器根据身高体重计算身体质量指数按中国成人标准分级并给出健康体重范围
-->
<template>
<div class="page-wrap">
<h2 class="page-title">BMI 计算器</h2>
<p class="page-desc">计算身体质量指数BMI按中国成人标准分级并给出当前身高的健康体重范围</p>
<t-card class="tool-section" title="输入身高体重">
<div class="form-row">
<span class="form-label">身高</span>
<t-input v-model="height" placeholder="170" clearable style="max-width: 200px">
<template #suffix>cm</template>
</t-input>
</div>
<div class="form-row" style="margin-top: 12px">
<span class="form-label">体重</span>
<t-input v-model="weight" placeholder="65" clearable style="max-width: 200px">
<template #suffix>kg</template>
</t-input>
</div>
<div class="form-actions">
<t-button theme="primary" @click="calc">计算</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="result" class="tool-section" title="计算结果">
<div class="bmi-line">
<span class="bmi-value">{{ result.bmi }}</span>
<t-tag :theme="result.theme" variant="light" size="large">{{ result.level }}</t-tag>
</div>
<div class="code-block">{{ result.detail }}</div>
<p class="form-tip">分级采用中国成人标准&lt;18.5 偏瘦18.5~23.9 正常24~27.9 超重28 肥胖结果仅供参考不构成医学建议</p>
<div class="form-actions">
<CopyButton :text="result.detail" />
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
interface BmiResult {
bmi: string
level: string
theme: 'success' | 'primary' | 'warning' | 'danger'
detail: string
}
const height = ref('')
const weight = ref('')
const error = ref('')
const result = ref<BmiResult | null>(null)
const history = useHistory('bmi')
/** 按中国成人标准分级,返回等级与展示主题色 */
function classify(bmi: number): { level: string; theme: BmiResult['theme'] } {
if (bmi < 18.5) return { level: '偏瘦', theme: 'primary' }
if (bmi < 24) return { level: '正常', theme: 'success' }
if (bmi < 28) return { level: '超重', theme: 'warning' }
return { level: '肥胖', theme: 'danger' }
}
function calc() {
error.value = ''
result.value = null
// Number NaN
const h = Number(height.value.trim().replace('。', '.'))
const w = Number(weight.value.trim().replace('。', '.'))
if (!Number.isFinite(h) || h <= 0) {
error.value = '请输入合法的身高(单位 cm'
return
}
if (h < 50 || h > 272) {
error.value = '身高需在 50 ~ 272 cm 之间'
return
}
if (!Number.isFinite(w) || w <= 0) {
error.value = '请输入合法的体重(单位 kg'
return
}
if (w < 10 || w > 500) {
error.value = '体重需在 10 ~ 500 kg 之间'
return
}
const meter = h / 100
const bmi = w / (meter * meter)
const { level, theme } = classify(bmi)
// BMI 18.5 ~ 23.9
const minKg = 18.5 * meter * meter
const maxKg = 23.9 * meter * meter
const diff = w < minKg ? `距健康下限还差 ${(minKg - w).toFixed(1)} kg`
: w > maxKg ? `超出健康上限 ${(w - maxKg).toFixed(1)} kg`
: '处于健康体重区间'
result.value = {
bmi: bmi.toFixed(1),
level,
theme,
detail: [
`身高:${h} cm体重${w} kg`,
`BMI = ${bmi.toFixed(2)}${level}`,
`健康体重范围:${minKg.toFixed(1)} ~ ${maxKg.toFixed(1)} kg`,
diff,
].join('\n'),
}
history.add(`${height.value.trim()}|${weight.value.trim()}`)
}
function onHistorySelect(text: string) {
// |
const [h, w] = text.split('|')
if (!h || !w) return
height.value = h
weight.value = w
calc()
}
</script>
<style scoped>
.form-label {
width: 56px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.bmi-line {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.bmi-value {
font-size: 32px;
font-weight: 700;
color: var(--td-text-color-primary);
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.form-tip {
margin: 12px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
</style>

View File

@ -0,0 +1,257 @@
<!--
房贷计算器等额本息与等额本金一次算出并对比展示月供总利息与总还款额
-->
<template>
<div class="page-wrap">
<h2 class="page-title">房贷计算器</h2>
<p class="page-desc">输入贷款金额期限与年利率同时计算等额本息与等额本金两种还款方式并对比</p>
<t-card class="tool-section" title="贷款信息">
<div class="form-row">
<span class="form-label">贷款金额</span>
<t-input v-model="amount" placeholder="100" clearable style="max-width: 200px">
<template #suffix>万元</template>
</t-input>
</div>
<div class="form-row" style="margin-top: 12px">
<span class="form-label">贷款期限</span>
<t-input v-model="years" placeholder="30" clearable style="max-width: 200px">
<template #suffix></template>
</t-input>
</div>
<div class="form-row" style="margin-top: 12px">
<span class="form-label">年利率</span>
<t-input v-model="rate" placeholder="3.1" clearable style="max-width: 200px">
<template #suffix>%</template>
</t-input>
<t-space size="small">
<t-button size="small" variant="outline" @click="rate = '3.1'">商贷示例 3.1%</t-button>
<t-button size="small" variant="outline" @click="rate = '2.6'">公积金示例 2.6%</t-button>
</t-space>
</div>
<p class="form-tip">示例利率仅为快速填充参考请以实际执行利率为准支持组合贷场景下分段自行计算后相加</p>
<div class="form-actions">
<t-button theme="primary" @click="calc">计算</t-button>
</div>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="result" class="tool-section" title="计算结果">
<div class="result-grid">
<div class="result-col">
<div class="result-col-title">等额本息每月还款额固定</div>
<div class="result-line"><span>每月还款</span><b>{{ result.instMonthly }}</b></div>
<div class="result-line"><span>总利息</span><b>{{ result.instInterest }}</b></div>
<div class="result-line"><span>总还款额</span><b>{{ result.instTotal }}</b></div>
</div>
<div class="result-col">
<div class="result-col-title">等额本金每月递减</div>
<div class="result-line"><span>首月还款</span><b>{{ result.capFirst }}</b></div>
<div class="result-line"><span>每月递减</span><b>{{ result.capDecrease }}</b></div>
<div class="result-line"><span>末月还款</span><b>{{ result.capLast }}</b></div>
<div class="result-line"><span>总利息</span><b>{{ result.capInterest }}</b></div>
<div class="result-line"><span>总还款额</span><b>{{ result.capTotal }}</b></div>
</div>
</div>
<div class="code-block">{{ result.summary }}</div>
<div class="form-actions">
<CopyButton :text="result.summary" />
</div>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
interface MortgageResult {
instMonthly: string
instInterest: string
instTotal: string
capFirst: string
capDecrease: string
capLast: string
capInterest: string
capTotal: string
summary: string
}
const amount = ref('')
const years = ref('30')
const rate = ref('3.1')
const error = ref('')
const result = ref<MortgageResult | null>(null)
const history = useHistory('mortgage')
/** 金额格式化为「元」展示:千分位 + 两位小数 */
function fmtYuan(v: number): string {
return `${v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`
}
/** 金额格式化为「万元」展示:保留两位小数 */
function fmtWan(v: number): string {
return `${(v / 10000).toFixed(2)} 万元`
}
/** 等额本息每月还款额固定M = P·r·(1+r)^n / ((1+r)^n 1) */
function equalInstallment(principal: number, monthRate: number, n: number) {
if (monthRate === 0) {
const monthly = principal / n
return { monthly, totalInterest: 0 }
}
const f = Math.pow(1 + monthRate, n)
const monthly = (principal * monthRate * f) / (f - 1)
return { monthly, totalInterest: monthly * n - principal }
}
/** 等额本金:每月归还固定本金 + 剩余本金利息,逐月递减 */
function equalPrincipal(principal: number, monthRate: number, n: number) {
const base = principal / n
const first = base + principal * monthRate
const last = base + base * monthRate
const decrease = base * monthRate
// = (n+1)·P·r / 2
const totalInterest = ((n + 1) * principal * monthRate) / 2
return { first, last, decrease, totalInterest }
}
function calc() {
error.value = ''
result.value = null
const a = Number(amount.value.trim())
const y = Number(years.value.trim())
const r = Number(rate.value.trim())
if (!Number.isFinite(a) || a <= 0) {
error.value = '请输入合法的贷款金额(万元)'
return
}
if (a > 10000) {
error.value = '贷款金额不能超过 10000 万元'
return
}
if (!Number.isFinite(y) || y <= 0 || !Number.isInteger(y)) {
error.value = '贷款期限需为正整数(年)'
return
}
if (y > 50) {
error.value = '贷款期限不能超过 50 年'
return
}
if (!Number.isFinite(r) || r < 0) {
error.value = '请输入合法的年利率(%'
return
}
if (r > 24) {
error.value = '年利率不能超过 24%'
return
}
const principal = a * 10000
const n = y * 12
const monthRate = r / 100 / 12
const inst = equalInstallment(principal, monthRate, n)
const cap = equalPrincipal(principal, monthRate, n)
const save = inst.totalInterest - cap.totalInterest
result.value = {
instMonthly: fmtYuan(inst.monthly),
instInterest: fmtYuan(inst.totalInterest),
instTotal: fmtYuan(inst.monthly * n),
capFirst: fmtYuan(cap.first),
capDecrease: fmtYuan(cap.decrease),
capLast: fmtYuan(cap.last),
capInterest: fmtYuan(cap.totalInterest),
capTotal: fmtYuan(principal + cap.totalInterest),
summary: [
`贷款 ${a} 万元,期限 ${y} 年(${n} 期),年利率 ${r}%`,
`等额本息:每月固定还款 ${fmtYuan(inst.monthly)},总利息 ${fmtWan(inst.totalInterest)}`,
`等额本金:首月 ${fmtYuan(cap.first)},每月递减 ${fmtYuan(cap.decrease)},末月 ${fmtYuan(cap.last)},总利息 ${fmtWan(cap.totalInterest)}`,
save > 0
? `等额本金比等额本息少付利息 ${fmtYuan(save)},但前期月供压力更大`
: '两种方式总利息相同(利率为 0',
].join('\n'),
}
history.add(JSON.stringify({ a: amount.value.trim(), y: years.value.trim(), r: rate.value.trim() }))
}
function onHistorySelect(text: string) {
// JSON
try {
const h = JSON.parse(text) as { a?: string; y?: string; r?: string } | null
if (!h) return
if (typeof h.a === 'string') amount.value = h.a
if (typeof h.y === 'string') years.value = h.y
if (typeof h.r === 'string') rate.value = h.r
calc()
} catch {
//
}
}
</script>
<style scoped>
.form-label {
width: 72px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.result-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.result-col {
padding: 12px 14px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 8px;
background: var(--td-bg-color-secondarycontainer);
}
.result-col-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.result-line {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 3px 0;
font-size: 13px;
color: var(--td-text-color-secondary);
}
.result-line b {
color: var(--td-text-color-primary);
font-weight: 600;
word-break: break-all;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.form-tip {
margin: 12px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
@media (max-width: 767px) {
.result-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,226 @@
<!--
番茄钟专注/休息倒计时基于目标时间戳计时后台标签页不漂移
阶段结束自动切换并提示音提醒记录已完成轮数
-->
<template>
<div class="page-wrap">
<h2 class="page-title">番茄钟</h2>
<p class="page-desc">番茄工作法计时专注一段时间后短暂休息循环往复页面保持打开即可持续计时</p>
<t-card class="tool-section">
<div class="timer-head">
<t-tag :theme="phase === 'focus' ? 'primary' : 'success'" variant="light" size="large">
{{ phase === 'focus' ? '专注中' : '休息中' }}
</t-tag>
<span class="rounds">已完成 {{ rounds }} </span>
<t-checkbox v-model="sound">结束提示音</t-checkbox>
</div>
<div class="timer-display" :class="{ break: phase === 'break' }">{{ display }}</div>
<div class="timer-actions">
<t-button v-if="!running" theme="primary" size="large" @click="start">
{{ remaining < phaseDuration() ? '继续' : '开始' }}
</t-button>
<t-button v-else theme="warning" size="large" @click="pause">暂停</t-button>
<t-button variant="outline" size="large" @click="reset">重置</t-button>
</div>
</t-card>
<t-card class="tool-section" title="时长设置">
<div class="form-row">
<span class="form-label">专注时长</span>
<t-input-number v-model="focusMin" :min="1" :max="180" theme="normal" />
<span class="unit-text">分钟</span>
</div>
<div class="form-row" style="margin-top: 12px">
<span class="form-label">休息时长</span>
<t-input-number v-model="restMin" :min="1" :max="60" theme="normal" />
<span class="unit-text">分钟</span>
</div>
<p class="form-tip">计时进行中修改时长不影响当前倒计时重置后按新时长生效推荐 25 分钟专注 + 5 分钟休息</p>
</t-card>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
const history = useHistory('pomodoro')
const focusMin = ref<number | null>(25)
const restMin = ref<number | null>(5)
const phase = ref<'focus' | 'break'>('focus')
const running = ref(false)
/** 剩余秒数 */
const remaining = ref(25 * 60)
/** 已完成专注轮数 */
const rounds = ref(0)
const sound = ref(true)
/** 当前阶段是否还未开始过(未开始时修改时长直接同步剩余时间) */
let untouched = true
/** 倒计时目标时间戳:基于它计算剩余时间,避免 setInterval 在后台被节流导致漂移 */
let endTime = 0
let timer: ReturnType<typeof setInterval> | null = null
function validFocus(): number {
return focusMin.value && focusMin.value > 0 ? focusMin.value : 25
}
function validRest(): number {
return restMin.value && restMin.value > 0 ? restMin.value : 5
}
/** 当前阶段的完整时长(秒) */
function phaseDuration(): number {
return (phase.value === 'focus' ? validFocus() : validRest()) * 60
}
const display = computed(() => {
const s = Math.max(0, remaining.value)
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
})
/** 未开始时调整时长,倒计时同步刷新 */
watch([focusMin, restMin], () => {
if (!running.value && untouched) remaining.value = phaseDuration()
})
function start() {
if (running.value) return
running.value = true
untouched = false
endTime = Date.now() + remaining.value * 1000
timer = setInterval(tick, 250)
}
function pause() {
if (!running.value) return
running.value = false
if (timer) {
clearInterval(timer)
timer = null
}
}
function reset() {
pause()
phase.value = 'focus'
untouched = true
remaining.value = phaseDuration()
}
function tick() {
const s = Math.max(0, Math.round((endTime - Date.now()) / 1000))
remaining.value = s
if (s <= 0) onPhaseEnd()
}
/** 阶段结束:专注结束计一轮并切休息,休息结束切回专注,均自动开始下一阶段 */
function onPhaseEnd() {
beep()
if (phase.value === 'focus') {
rounds.value++
phase.value = 'break'
} else {
phase.value = 'focus'
}
untouched = false
remaining.value = phaseDuration()
endTime = Date.now() + remaining.value * 1000
addHistory()
}
/** 简短提示音Web Audio 生成 880Hz 正弦波,无需外部音频资源 */
function beep() {
if (!sound.value) return
try {
const ctx = new AudioContext()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain)
gain.connect(ctx.destination)
osc.frequency.value = 880
gain.gain.setValueAtTime(0.2, ctx.currentTime)
osc.start()
osc.stop(ctx.currentTime + 0.4)
osc.onended = () => ctx.close()
} catch {
//
}
}
/** 历史只存时长参数计时状态不持久化格式focus=25 | rest=5 */
function addHistory() {
history.add(`focus=${validFocus()} | rest=${validRest()}`)
}
function onHistorySelect(text: string) {
// focus=N | rest=N
const f = /focus=(\d+)/.exec(text)
const r = /rest=(\d+)/.exec(text)
if (f) focusMin.value = Math.min(180, Math.max(1, Number(f[1])))
if (r) restMin.value = Math.min(60, Math.max(1, Number(r[1])))
if (f || r) reset()
}
//
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped>
.timer-head {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.rounds {
font-size: 13px;
color: var(--td-text-color-secondary);
}
.timer-display {
margin: 24px 0;
text-align: center;
font-size: 72px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--td-brand-color);
}
.timer-display.break {
color: var(--td-success-color);
}
.timer-actions {
display: flex;
justify-content: center;
gap: 12px;
}
.form-label {
width: 72px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.unit-text {
font-size: 13px;
color: var(--td-text-color-secondary);
}
.form-tip {
margin: 12px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
@media (max-width: 767px) {
.timer-display {
font-size: 56px;
}
}
</style>

View File

@ -0,0 +1,279 @@
<!--
随机决定选项抽签治选择困难与随机数生成两个面板全部使用 crypto 安全随机源
-->
<template>
<div class="page-wrap">
<h2 class="page-title">随机决定</h2>
<p class="page-desc">今天吃什么谁去取快递把选项交给随机数也支持批量生成随机数</p>
<t-tabs v-model="tab">
<!-- 选项抽签 -->
<t-tab-panel value="pick" label="选项抽签">
<t-card class="tool-section" title="候选选项(每行一个)">
<t-textarea
v-model="options"
:autosize="{ minRows: 6, maxRows: 12 }"
placeholder="每行填写一个选项"
/>
<div class="form-actions">
<t-button theme="primary" @click="pick">抽一个</t-button>
<t-button variant="outline" @click="loadPreset('meal')">今天吃什么</t-button>
<t-button variant="outline" @click="loadPreset('do')">做不做</t-button>
<t-checkbox v-model="excludePicked">抽过的选项不再参与</t-checkbox>
<t-button v-if="excluded.size" size="small" variant="text" @click="excluded.clear()">
恢复全部选项已排除 {{ excluded.size }}
</t-button>
</div>
</t-card>
<ErrorAlert :message="error1" @close="error1 = ''" />
<t-card v-if="picked" class="tool-section">
<div class="picked-box">{{ picked }}</div>
<div class="form-actions">
<CopyButton :text="picked" />
</div>
</t-card>
</t-tab-panel>
<!-- 随机数 -->
<t-tab-panel value="number" label="随机数">
<t-card class="tool-section" title="随机数范围">
<div class="form-row">
<span class="form-label">范围</span>
<t-input v-model="numMin" placeholder="1" class="num-input" />
<span class="num-sep">~</span>
<t-input v-model="numMax" placeholder="100" class="num-input" />
</div>
<div class="form-row" style="margin-top: 12px">
<span class="form-label">数量</span>
<t-input v-model="numCount" placeholder="1" class="num-input" />
<t-checkbox v-model="numUnique">不重复</t-checkbox>
<t-checkbox v-model="numSorted">升序排列</t-checkbox>
</div>
<div class="form-actions">
<t-button theme="primary" @click="genNumbers">生成</t-button>
</div>
</t-card>
<ErrorAlert :message="error2" @close="error2 = ''" />
<t-card v-if="numbers" class="tool-section" title="生成结果">
<div class="code-block">{{ numbers }}</div>
<div class="form-actions">
<CopyButton :text="numbers" />
</div>
</t-card>
</t-tab-panel>
</t-tabs>
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory'
/** 历史记录结构:两个面板各存所需字段 */
interface RandomHistory {
tab: string
options?: string
min?: string
max?: string
count?: string
unique?: boolean
sorted?: boolean
}
const history = useHistory('random-decision')
const tab = ref('pick')
/** 安全随机整数:基于 crypto.getRandomValues 拒绝采样,避免取模偏斜 */
function secureRandInt(upperExclusive: number): number {
const limit = Math.floor(0x100000000 / upperExclusive) * upperExclusive
const buf = new Uint32Array(1)
let v: number
do {
crypto.getRandomValues(buf)
v = buf[0]
} while (v >= limit)
return v % upperExclusive
}
/* ---------- 选项抽签 ---------- */
const PRESETS: Record<string, string> = {
meal: ['火锅', '烧烤', '麻辣烫', '黄焖鸡', '兰州拉面', '沙县小吃', '饺子', '盖浇饭', '轻食沙拉', '寿司', '披萨', '炸鸡'].join('\n'),
do: ['做', '不做', '再想想', '抛硬币无效,必须做'].join('\n'),
}
const options = ref(PRESETS.meal)
const picked = ref('')
const excluded = reactive(new Set<string>())
const error1 = ref('')
function loadPreset(key: string) {
options.value = PRESETS[key]
picked.value = ''
excluded.clear()
error1.value = ''
}
function parseOptions(): string[] {
return options.value
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
}
function pick() {
error1.value = ''
const all = parseOptions()
if (all.length === 0) {
error1.value = '请至少填写一个选项'
return
}
const candidates = all.filter((o) => !excluded.has(o))
if (candidates.length === 0) {
error1.value = '所有选项都已被排除,请先恢复选项'
return
}
const winner = candidates[secureRandInt(candidates.length)]
picked.value = winner
if (excludePicked.value) excluded.add(winner)
history.add(JSON.stringify({ tab: 'pick', options: options.value }))
}
const excludePicked = ref(false)
/* ---------- 随机数 ---------- */
const numMin = ref('1')
const numMax = ref('100')
const numCount = ref('1')
const numUnique = ref(true)
const numSorted = ref(false)
const numbers = ref('')
const error2 = ref('')
function genNumbers() {
error2.value = ''
numbers.value = ''
const min = Number(numMin.value.trim())
const max = Number(numMax.value.trim())
const count = Number(numCount.value.trim())
if (!Number.isInteger(min) || !Number.isInteger(max)) {
error2.value = '范围需为整数'
return
}
if (min > max) {
error2.value = '最小值不能大于最大值'
return
}
if (!Number.isInteger(count) || count < 1 || count > 1000) {
error2.value = '数量需为 1 ~ 1000 的整数'
return
}
const span = max - min + 1
if (span > 1e9) {
error2.value = '范围跨度不能超过 10 亿'
return
}
if (numUnique.value && count > span) {
error2.value = `不重复模式下,范围内只有 ${span} 个整数,无法生成 ${count}`
return
}
const out: number[] = []
if (numUnique.value) {
// Set
if (span <= 100000) {
const pool = Array.from({ length: span }, (_, i) => min + i)
for (let i = pool.length - 1; i > 0; i--) {
const j = secureRandInt(i + 1)
;[pool[i], pool[j]] = [pool[j], pool[i]]
}
out.push(...pool.slice(0, count))
} else {
const seen = new Set<number>()
while (out.length < count) {
const v = min + secureRandInt(span)
if (!seen.has(v)) {
seen.add(v)
out.push(v)
}
}
}
} else {
for (let i = 0; i < count; i++) out.push(min + secureRandInt(span))
}
if (numSorted.value) out.sort((a, b) => a - b)
numbers.value = out.join('')
history.add(
JSON.stringify({
tab: 'number',
min: numMin.value.trim(),
max: numMax.value.trim(),
count: numCount.value.trim(),
unique: numUnique.value,
sorted: numSorted.value,
}),
)
}
function onHistorySelect(text: string) {
// JSON tab JSON
try {
const h = JSON.parse(text) as Partial<RandomHistory> | null
if (!h || typeof h.tab !== 'string') return
if (h.tab === 'pick' && typeof h.options === 'string') {
tab.value = 'pick'
options.value = h.options
excluded.clear()
picked.value = ''
} else if (h.tab === 'number') {
tab.value = 'number'
if (typeof h.min === 'string') numMin.value = h.min
if (typeof h.max === 'string') numMax.value = h.max
if (typeof h.count === 'string') numCount.value = h.count
if (typeof h.unique === 'boolean') numUnique.value = h.unique
if (typeof h.sorted === 'boolean') numSorted.value = h.sorted
genNumbers()
}
} catch {
//
}
}
</script>
<style scoped>
.form-label {
width: 56px;
flex-shrink: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
}
.num-input {
width: 120px;
}
.num-sep {
color: var(--td-text-color-placeholder);
}
.picked-box {
padding: 24px;
text-align: center;
font-size: 28px;
font-weight: 700;
color: var(--td-brand-color);
word-break: break-all;
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
</style>