feat(tools): 添加工具分类机制并新增多个生活助手工具
- 实现工具分类功能,支持按工作/生活等维度分组展示 - 新增BMI计算器,支持按中国成人标准分级与健康体重范围 - 新增房贷计算器,支持等额本息与等额本金两种还款方式对比 - 新增账单拆分工具,支持多人AA制自动生成转账方案 - 新增随机决定工具,支持选项抽签与随机数生成 - 新增番茄钟功能,支持专注与休息倒计时及轮数记录 - 更新首页与侧边栏按分类分组渲染工具列表 - 优化工具注册表支持category字段配置分类归属
This commit is contained in:
parent
ea2737f589
commit
42d4de46ba
14
agents.md
14
agents.md
@ -47,17 +47,20 @@ export interface ToolItem {
|
|||||||
name: string // 工具名(菜单/卡片/页面标题)
|
name: string // 工具名(菜单/卡片/页面标题)
|
||||||
desc: string // 一句话描述
|
desc: string // 一句话描述
|
||||||
icon: Component // TDesign 图标组件
|
icon: Component // TDesign 图标组件
|
||||||
|
category?: string // 分类 key(对应 toolCategories),缺省归入第一个分类
|
||||||
component: () => Promise<Component> // 懒加载组件
|
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`,参考现有页面结构(标题 → 表单卡片 → 错误提示 → 结果卡片 → 历史面板)。
|
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` 验证。
|
3. 完成——菜单、首页卡片、路由、文档标题自动生效。可运行 `pnpm type-check` 验证。
|
||||||
|
|
||||||
**注意:** `path` 必须与页面内 `useHistory('xxx')` 的 key 一致(历史记录约定),并避免与现有 27 个 path 重复。
|
**注意:** `path` 必须与页面内 `useHistory('xxx')` 的 key 一致(历史记录约定),并避免与现有工具的 path 重复。
|
||||||
|
|
||||||
### 3.2 历史记录约定
|
### 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/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(池大小) 分四档 |
|
| 密码生成 | `views/password-gen` | `crypto.getRandomValues` + 拒绝采样 | 每种选中字符集至少出现一次 + Fisher-Yates 洗牌;t-slider 数值用外部 `<span>` 显示(label 不能传函数);强度 = 长度×log2(池大小) 分四档 |
|
||||||
| HTTP 状态码 | `views/http-status` | 内置静态数据(56 条,1xx-5xx) | 搜索(code/短语/中文)+ 分类筛选;历史存搜索词 |
|
| 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 特殊说明(重要)
|
## 5. OCR 特殊说明(重要)
|
||||||
|
|
||||||
@ -177,4 +185,4 @@ pnpm preview # 预览 dist
|
|||||||
- **PWA / 离线**:可接入 `vite-plugin-pwa`,配合第 5 节 OCR 离线方案实现完全离线工具站。
|
- **PWA / 离线**:可接入 `vite-plugin-pwa`,配合第 5 节 OCR 离线方案实现完全离线工具站。
|
||||||
- **国际化**:文案目前硬编码中文,若需 i18n 建议 vue-i18n,工具注册表的 `name/desc` 改为 key。
|
- **国际化**:文案目前硬编码中文,若需 i18n 建议 vue-i18n,工具注册表的 `name/desc` 改为 key。
|
||||||
- **体积优化**:TDesign 目前全量注册(约 1.4MB gzip 373KB),若追求极致可改按需引入(unplugin-vue-components + TDesignResolver)。
|
- **体积优化**: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,33 +5,38 @@
|
|||||||
<span>首页</span>
|
<span>首页</span>
|
||||||
</router-link>
|
</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
|
<router-link
|
||||||
v-for="tool in tools"
|
v-for="tool in group.items"
|
||||||
:key="tool.path"
|
:key="tool.path"
|
||||||
:to="`/${tool.path}`"
|
:to="`/${tool.path}`"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: route.path === `/${tool.path}` }"
|
:class="{ active: route.path === `/${tool.path}` }"
|
||||||
@click="emit('navigate')"
|
@click="emit('navigate')"
|
||||||
>
|
>
|
||||||
<component :is="tool.icon" class="nav-icon" />
|
<component :is="tool.icon" class="nav-icon" />
|
||||||
<span>{{ tool.name }}</span>
|
<span>{{ tool.name }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { HomeIcon } from 'tdesign-icons-vue-next'
|
import { HomeIcon } from 'tdesign-icons-vue-next'
|
||||||
import { tools } from '@/router/tools'
|
import { groupedTools } from '@/router/tools'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 侧边导航:数据源为工具注册表 tools.ts。
|
* 侧边导航:数据源为工具注册表 tools.ts,按分类分组渲染。
|
||||||
* PC 端渲染在左侧固定栏,移动端渲染在抽屉内(点击后由父组件关闭抽屉)。
|
* PC 端渲染在左侧固定栏,移动端渲染在抽屉内(点击后由父组件关闭抽屉)。
|
||||||
*/
|
*/
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
/** 分类分组(新增分类自动出现,无需改动本组件) */
|
||||||
|
const groups = groupedTools()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
/** 点击菜单项后通知父组件(移动端用于关闭抽屉) */
|
/** 点击菜单项后通知父组件(移动端用于关闭抽屉) */
|
||||||
navigate: []
|
navigate: []
|
||||||
|
|||||||
@ -24,6 +24,11 @@ import {
|
|||||||
MoneyIcon,
|
MoneyIcon,
|
||||||
InternetIcon,
|
InternetIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
|
HeartIcon,
|
||||||
|
WalletIcon,
|
||||||
|
UsergroupIcon,
|
||||||
|
GiftIcon,
|
||||||
|
AlarmIcon,
|
||||||
} from 'tdesign-icons-vue-next'
|
} from 'tdesign-icons-vue-next'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,9 +37,28 @@ import {
|
|||||||
*
|
*
|
||||||
* ⚙️ 新增工具只需三步:
|
* ⚙️ 新增工具只需三步:
|
||||||
* 1. 在 src/views/<工具名>/ 下创建页面组件(可参考现有工具页面);
|
* 1. 在 src/views/<工具名>/ 下创建页面组件(可参考现有工具页面);
|
||||||
* 2. 在本文件的 tools 数组中追加一条配置(path/name/desc/icon/component);
|
* 2. 在本文件的 tools 数组中追加一条配置(path/name/desc/icon/category/component);
|
||||||
* 3. 无需修改路由文件、首页与侧边栏,它们会自动渲染新工具。
|
* 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 {
|
export interface ToolItem {
|
||||||
/** 路由路径(相对根路径,不含前导斜杠) */
|
/** 路由路径(相对根路径,不含前导斜杠) */
|
||||||
path: string
|
path: string
|
||||||
@ -44,37 +68,28 @@ export interface ToolItem {
|
|||||||
desc: string
|
desc: string
|
||||||
/** 菜单/卡片图标(TDesign 图标组件) */
|
/** 菜单/卡片图标(TDesign 图标组件) */
|
||||||
icon: Component
|
icon: Component
|
||||||
|
/** 所属分类 key(必须在 toolCategories 中存在),缺省归入第一个分类 */
|
||||||
|
category?: string
|
||||||
/** 页面组件(懒加载,减小首屏体积) */
|
/** 页面组件(懒加载,减小首屏体积) */
|
||||||
component: () => Promise<Component>
|
component: () => Promise<Component>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tools: ToolItem[] = [
|
export const tools: ToolItem[] = [
|
||||||
|
/* ==================== 工作效率 ==================== */
|
||||||
{
|
{
|
||||||
path: 'image-compress',
|
path: 'image-compress',
|
||||||
name: '图片压缩',
|
name: '图片压缩',
|
||||||
desc: '本地压缩 JPG/PNG/WebP,可调质量,压缩后直接下载',
|
desc: '本地压缩 JPG/PNG/WebP,可调质量,压缩后直接下载',
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/image-compress/ImageCompressView.vue'),
|
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',
|
path: 'json-format',
|
||||||
name: 'JSON 格式化',
|
name: 'JSON 格式化',
|
||||||
desc: 'JSON 格式化、压缩、校验,一键复制结果',
|
desc: 'JSON 格式化、压缩、校验,一键复制结果',
|
||||||
icon: CodeIcon,
|
icon: CodeIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/json-format/JsonFormatView.vue'),
|
component: () => import('@/views/json-format/JsonFormatView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -82,6 +97,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: '时间戳转换',
|
name: '时间戳转换',
|
||||||
desc: '时间戳与北京时间互转,支持秒/毫秒与批量转换',
|
desc: '时间戳与北京时间互转,支持秒/毫秒与批量转换',
|
||||||
icon: TimeIcon,
|
icon: TimeIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/timestamp/TimestampView.vue'),
|
component: () => import('@/views/timestamp/TimestampView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -89,6 +105,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: 'Base64 编解码',
|
name: 'Base64 编解码',
|
||||||
desc: '文本 Base64 编解码,图片文件与 Base64 互转',
|
desc: '文本 Base64 编解码,图片文件与 Base64 互转',
|
||||||
icon: FileIcon,
|
icon: FileIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/base64/Base64View.vue'),
|
component: () => import('@/views/base64/Base64View.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -96,6 +113,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: 'JWT 解析',
|
name: 'JWT 解析',
|
||||||
desc: '解析 JWT 的 Header 与 Payload,展示过期时间',
|
desc: '解析 JWT 的 Header 与 Payload,展示过期时间',
|
||||||
icon: LockOnIcon,
|
icon: LockOnIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/jwt-parse/JwtParseView.vue'),
|
component: () => import('@/views/jwt-parse/JwtParseView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -103,6 +121,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: 'Cron 表达式',
|
name: 'Cron 表达式',
|
||||||
desc: '解析 Cron 下次执行时间,可视化生成表达式',
|
desc: '解析 Cron 下次执行时间,可视化生成表达式',
|
||||||
icon: CalendarIcon,
|
icon: CalendarIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/cron/CronView.vue'),
|
component: () => import('@/views/cron/CronView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -110,6 +129,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: '进制转换',
|
name: '进制转换',
|
||||||
desc: '二进制、八进制、十进制、十六进制互转',
|
desc: '二进制、八进制、十进制、十六进制互转',
|
||||||
icon: CalculatorIcon,
|
icon: CalculatorIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/radix-convert/RadixConvertView.vue'),
|
component: () => import('@/views/radix-convert/RadixConvertView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -117,27 +137,15 @@ export const tools: ToolItem[] = [
|
|||||||
name: '哈希计算',
|
name: '哈希计算',
|
||||||
desc: '文本/文件计算 MD5、SHA1、SHA256、SHA512',
|
desc: '文本/文件计算 MD5、SHA1、SHA256、SHA512',
|
||||||
icon: FingerprintIcon,
|
icon: FingerprintIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/hash/HashView.vue'),
|
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',
|
path: 'url-parse',
|
||||||
name: 'URL 解析',
|
name: 'URL 解析',
|
||||||
desc: '解析协议/域名/路径/参数,也支持构造 URL',
|
desc: '解析协议/域名/路径/参数,也支持构造 URL',
|
||||||
icon: LinkIcon,
|
icon: LinkIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/url-parse/UrlParseView.vue'),
|
component: () => import('@/views/url-parse/UrlParseView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -145,6 +153,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: '翻译',
|
name: '翻译',
|
||||||
desc: '中文、英文、韩文、日文互译(免费在线接口)',
|
desc: '中文、英文、韩文、日文互译(免费在线接口)',
|
||||||
icon: TranslateIcon,
|
icon: TranslateIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/translate/TranslateView.vue'),
|
component: () => import('@/views/translate/TranslateView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -152,6 +161,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: '图片 OCR 识别',
|
name: '图片 OCR 识别',
|
||||||
desc: '本地识别图片文字,支持中英文(WASM)',
|
desc: '本地识别图片文字,支持中英文(WASM)',
|
||||||
icon: BrowseIcon,
|
icon: BrowseIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/ocr/OcrView.vue'),
|
component: () => import('@/views/ocr/OcrView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -159,6 +169,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: 'UUID 生成',
|
name: 'UUID 生成',
|
||||||
desc: '批量生成 UUID v4,支持大小写与去连字符',
|
desc: '批量生成 UUID v4,支持大小写与去连字符',
|
||||||
icon: RefreshIcon,
|
icon: RefreshIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/uuid/UuidView.vue'),
|
component: () => import('@/views/uuid/UuidView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -166,6 +177,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: '正则测试',
|
name: '正则测试',
|
||||||
desc: '实时测试正则匹配、分组与替换,匹配高亮显示',
|
desc: '实时测试正则匹配、分组与替换,匹配高亮显示',
|
||||||
icon: ControlPlatformIcon,
|
icon: ControlPlatformIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/regex-test/RegexTestView.vue'),
|
component: () => import('@/views/regex-test/RegexTestView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -173,6 +185,7 @@ export const tools: ToolItem[] = [
|
|||||||
name: '命名转换',
|
name: '命名转换',
|
||||||
desc: '驼峰、蛇形、短横线等命名风格一键互转',
|
desc: '驼峰、蛇形、短横线等命名风格一键互转',
|
||||||
icon: CodeIcon,
|
icon: CodeIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/case-convert/CaseConvertView.vue'),
|
component: () => import('@/views/case-convert/CaseConvertView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -180,20 +193,15 @@ export const tools: ToolItem[] = [
|
|||||||
name: '色值转换',
|
name: '色值转换',
|
||||||
desc: 'HEX、RGB、HSL、HSV 互转,取色器 + 预设色板',
|
desc: 'HEX、RGB、HSL、HSV 互转,取色器 + 预设色板',
|
||||||
icon: Palette1Icon,
|
icon: Palette1Icon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/color-convert/ColorConvertView.vue'),
|
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',
|
path: 'yaml-json',
|
||||||
name: 'YAML ↔ JSON',
|
name: 'YAML ↔ JSON',
|
||||||
desc: 'YAML 与 JSON 双向转换,报错定位清晰',
|
desc: 'YAML 与 JSON 双向转换,报错定位清晰',
|
||||||
icon: FileCodeIcon,
|
icon: FileCodeIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/yaml-json/YamlJsonView.vue'),
|
component: () => import('@/views/yaml-json/YamlJsonView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -201,27 +209,73 @@ export const tools: ToolItem[] = [
|
|||||||
name: '字数统计',
|
name: '字数统计',
|
||||||
desc: '统计字符、汉字、单词、段落与阅读时长',
|
desc: '统计字符、汉字、单词、段落与阅读时长',
|
||||||
icon: ChartIcon,
|
icon: ChartIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/word-count/WordCountView.vue'),
|
component: () => import('@/views/word-count/WordCountView.vue'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'rmb-upper',
|
|
||||||
name: '人民币大写',
|
|
||||||
desc: '数字金额转人民币大写,处理角分与负数',
|
|
||||||
icon: MoneyIcon,
|
|
||||||
component: () => import('@/views/rmb-upper/RmbUpperView.vue'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'zh-convert',
|
path: 'zh-convert',
|
||||||
name: '简繁转换',
|
name: '简繁转换',
|
||||||
desc: '简体与繁体中文互转(opencc 词库)',
|
desc: '简体与繁体中文互转(opencc 词库)',
|
||||||
icon: TranslateIcon,
|
icon: TranslateIcon,
|
||||||
|
category: 'work',
|
||||||
component: () => import('@/views/zh-convert/ZhConvertView.vue'),
|
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',
|
path: 'date-calc',
|
||||||
name: '日期计算',
|
name: '日期计算',
|
||||||
desc: '日期差值、推算与年龄生肖星座计算',
|
desc: '日期差值、推算与年龄生肖星座计算',
|
||||||
icon: CalendarIcon,
|
icon: CalendarIcon,
|
||||||
|
category: 'life',
|
||||||
component: () => import('@/views/date-calc/DateCalcView.vue'),
|
component: () => import('@/views/date-calc/DateCalcView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -229,13 +283,63 @@ export const tools: ToolItem[] = [
|
|||||||
name: '密码生成',
|
name: '密码生成',
|
||||||
desc: '随机强密码批量生成,含强度评估',
|
desc: '随机强密码批量生成,含强度评估',
|
||||||
icon: KeyIcon,
|
icon: KeyIcon,
|
||||||
|
category: 'life',
|
||||||
component: () => import('@/views/password-gen/PasswordGenView.vue'),
|
component: () => import('@/views/password-gen/PasswordGenView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'http-status',
|
path: 'bmi',
|
||||||
name: 'HTTP 状态码',
|
name: 'BMI 计算器',
|
||||||
desc: '常用 HTTP 状态码速查,支持搜索与分类',
|
desc: '计算身体质量指数,中国标准分级与健康体重范围',
|
||||||
icon: InternetIcon,
|
icon: HeartIcon,
|
||||||
component: () => import('@/views/http-status/HttpStatusView.vue'),
|
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">
|
<div class="hero">
|
||||||
<h1 class="hero-title">在线工具箱</h1>
|
<h1 class="hero-title">在线工具箱</h1>
|
||||||
<p class="hero-desc">
|
<p class="hero-desc">
|
||||||
14 个常用工具,全部在浏览器本地完成计算与文件处理,不上传任何数据到服务器。
|
{{ tools.length }} 个常用工具,全部在浏览器本地完成计算与文件处理,不上传任何数据到服务器。
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-badges">
|
<div class="hero-badges">
|
||||||
<t-tag theme="success" variant="light">纯前端</t-tag>
|
<t-tag theme="success" variant="light">纯前端</t-tag>
|
||||||
@ -13,30 +13,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 工具卡片导航 -->
|
<!-- 按分类分区的工具卡片导航 -->
|
||||||
<div class="tool-grid">
|
<section v-for="group in groups" :key="group.category.key" class="category-section">
|
||||||
<router-link
|
<div class="category-head">
|
||||||
v-for="tool in tools"
|
<h2 class="category-title">{{ group.category.label }}</h2>
|
||||||
:key="tool.path"
|
<span class="category-desc">{{ group.category.desc }}</span>
|
||||||
:to="`/${tool.path}`"
|
<span class="category-count">{{ group.items.length }} 个工具</span>
|
||||||
class="tool-card"
|
</div>
|
||||||
>
|
<div class="tool-grid">
|
||||||
<span class="tool-icon">
|
<router-link
|
||||||
<component :is="tool.icon" />
|
v-for="tool in group.items"
|
||||||
</span>
|
:key="tool.path"
|
||||||
<span class="tool-name">{{ tool.name }}</span>
|
:to="`/${tool.path}`"
|
||||||
<span class="tool-desc">{{ tool.desc }}</span>
|
class="tool-card"
|
||||||
<span class="tool-arrow">
|
>
|
||||||
<ChevronRightIcon />
|
<span class="tool-icon">
|
||||||
</span>
|
<component :is="tool.icon" />
|
||||||
</router-link>
|
</span>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronRightIcon } from 'tdesign-icons-vue-next'
|
import { ChevronRightIcon } from 'tdesign-icons-vue-next'
|
||||||
import { tools } from '@/router/tools'
|
import { tools, groupedTools } from '@/router/tools'
|
||||||
|
|
||||||
|
/** 分类分组(新增分类自动出现,无需改动本页面) */
|
||||||
|
const groups = groupedTools()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -63,6 +73,32 @@ import { tools } from '@/router/tools'
|
|||||||
flex-wrap: wrap;
|
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 {
|
.tool-grid {
|
||||||
display: 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