Compare commits
2 Commits
833720b7bf
...
8ee1f71b41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee1f71b41 | ||
|
|
42d4de46ba |
14
agents.md
14
agents.md
@ -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 节。
|
||||
|
||||
@ -5,10 +5,11 @@
|
||||
<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"
|
||||
v-for="tool in group.items"
|
||||
:key="tool.path"
|
||||
:to="`/${tool.path}`"
|
||||
class="nav-item"
|
||||
@ -18,20 +19,24 @@
|
||||
<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: []
|
||||
|
||||
@ -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),
|
||||
}))
|
||||
}
|
||||
|
||||
@ -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,10 +13,16 @@
|
||||
</div>
|
||||
</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 tools"
|
||||
v-for="tool in group.items"
|
||||
:key="tool.path"
|
||||
:to="`/${tool.path}`"
|
||||
class="tool-card"
|
||||
@ -31,12 +37,16 @@
|
||||
</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;
|
||||
|
||||
276
src/views/bill-split/BillSplitView.vue
Normal file
276
src/views/bill-split/BillSplitView.vue
Normal 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
163
src/views/bmi/BmiView.vue
Normal 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">分级采用中国成人标准:<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>
|
||||
257
src/views/mortgage/MortgageView.vue
Normal file
257
src/views/mortgage/MortgageView.vue
Normal 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>
|
||||
226
src/views/pomodoro/PomodoroView.vue
Normal file
226
src/views/pomodoro/PomodoroView.vue
Normal 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>
|
||||
279
src/views/random-decision/RandomDecisionView.vue
Normal file
279
src/views/random-decision/RandomDecisionView.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user