Appearance
实现 科学计算器 工具
分类:retirement | 标签:计算器、科学、函数 支持函数、阶乘、进制转换的科学计算器
2.1 功能说明
支持函数、阶乘、进制转换的科学计算器
2.1.1 使用指南
功能说明
支持四则运算、括号优先级、幂与开方、阶乘、三角函数(角度/弧度可切换)、对数、进制互转,带历史记录与键盘输入。
表达式输入完成后按 = 才计算;完整表达式始终显示在第一行且可直接编辑(点击表达式框修改后再次按 = 即可重算),结果单独显示在下方。点击左侧历史记录可把当时的表达式载入输入框。
使用场景
日常计算、工程运算、进制换算、表达式求值。
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
trackCaret()currentPos()spliceAt()onExprInput()ins()clear()back()factorial()callFunc()tokenize()evaluate()peek()next()exprP()term()factor()unary()postfix()primary()equals()formatNum()loadHist()clearHist()doBase()onKey()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, nextTick, onMounted, onBeforeUnmount } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'
const angle = ref('deg')
const expr = ref('') // 完整的输入表达式(始终可见、可直接编辑)
const exprInput = ref(null)
const caret = ref(0) // 输入框光标位置(失焦时仍保留最后光标,供按钮插入使用)
const err = ref('')
const result = ref('') // 计算结果展示(= ...)
const history = ref([])
const baseVal = ref(''), baseFrom = ref(10), baseOut = ref(null)
// 记录光标位置:点击/键盘/鼠标抬起/选中/聚焦都同步
function trackCaret(e) {
const el = e && e.target
if (el && typeof el.selectionStart === 'number') caret.value = el.selectionStart
}
// 当前应插入的位置:焦点在输入框用真实光标,否则用最后记录的光标
function currentPos() {
const el = exprInput.value
if (el && document.activeElement === el && typeof el.selectionStart === 'number') return el.selectionStart
return Math.min(caret.value, expr.value.length)
}
// 在指定位置插入并把光标移到插入内容之后,再重新聚焦输入框
function spliceAt(pos, text) {
const before = expr.value.slice(0, pos)
const after = expr.value.slice(pos)
expr.value = before + text + after
const newPos = pos + text.length
nextTick(() => {
const el = exprInput.value
if (el) { el.focus(); el.setSelectionRange(newPos, newPos); caret.value = newPos }
})
}
// 任意修改表达式都让旧结果失效;按钮追加与键盘原生输入都走这里
function onExprInput() { result.value = ''; err.value = '' }
function ins(tok) {
err.value = ''
result.value = ''
spliceAt(currentPos(), tok)
}
function clear() { expr.value = ''; result.value = ''; err.value = ''; caret.value = 0 }
function back() {
err.value = ''; result.value = ''
const pos = currentPos()
if (pos <= 0) return
// 删除光标前的一个字符:保留 [0,pos-1) 与 [pos,末尾)
expr.value = expr.value.slice(0, pos - 1) + expr.value.slice(pos)
const newPos = pos - 1
nextTick(() => {
const el = exprInput.value
if (el) { el.focus(); el.setSelectionRange(newPos, newPos); caret.value = newPos }
})
}
function factorial(n) {
if (n < 0 || !Number.isInteger(n)) throw new Error('阶乘仅支持非负整数')
if (n > 170) throw new Error('数值过大')
let r = 1; for (let i = 2; i <= n; i++) r *= i; return r
}
function callFunc(name, x) {
const deg = angle.value === 'deg'
const toRad = v => deg ? v * Math.PI / 180 : v
const fromRad = v => deg ? v * 180 / Math.PI : v
const map = {
sin: v => Math.sin(toRad(v)), cos: v => Math.cos(toRad(v)), tan: v => Math.tan(toRad(v)),
asin: v => fromRad(Math.asin(v)), acos: v => fromRad(Math.acos(v)), atan: v => fromRad(Math.atan(v)),
sinh: Math.sinh, cosh: Math.cosh, tanh: Math.tanh,
ln: Math.log, log: v => Math.log10(v), lg: v => Math.log10(v),
sqrt: Math.sqrt, cbrt: Math.cbrt, exp: Math.exp, abs: Math.abs,
floor: Math.floor, ceil: Math.ceil, round: Math.round, sign: Math.sign
}
if (!(name in map)) throw new Error('未知函数: ' + name)
return map[name](x)
}
function tokenize(s) {
const toks = []; let i = 0
const isDig = c => c >= '0' && c <= '9'
while (i < s.length) {
const c = s[i]
if (c === ' ' || c === '\t') { i++; continue }
if (isDig(c) || c === '.') {
let j = i + 1
while (j < s.length && (isDig(s[j]) || s[j] === '.')) j++
toks.push({ t: 'num', v: s.slice(i, j) }); i = j; continue
}
if (/[a-zA-Zπ]/.test(c)) {
let j = i + 1
while (j < s.length && /[a-zA-Z]/.test(s[j])) j++
toks.push({ t: 'id', v: s.slice(i, j) }); i = j; continue
}
if ('+-*/%^()!'.includes(c)) { toks.push({ t: 'op', v: c }); i++; continue }
if (c === '×') { toks.push({ t: 'op', v: '*' }); i++; continue }
if (c === '÷') { toks.push({ t: 'op', v: '/' }); i++; continue }
throw new Error('非法字符: ' + c)
}
return toks
}
function evaluate(s) {
const tokens = tokenize(s)
if (!tokens.length) throw new Error('表达式为空')
let pos = 0
const peek = () => tokens[pos]
const next = () => tokens[pos++]
function exprP() {
let v = term()
while (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
const op = next().v; const r = term(); v = op === '+' ? v + r : v - r
}
return v
}
function term() {
let v = factor()
while (peek() && peek().t === 'op' && (peek().v === '*' || peek().v === '/' || peek().v === '%')) {
const op = next().v; const r = factor()
v = op === '*' ? v * r : op === '/' ? v / r : v % r
}
return v
}
function factor() {
let v = unary()
if (peek() && peek().t === 'op' && peek().v === '^') { next(); v = Math.pow(v, factor()) }
return v
}
function unary() {
if (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
const op = next().v; const r = unary(); return op === '-' ? -r : r
}
return postfix()
}
function postfix() {
let v = primary()
while (peek() && peek().t === 'op' && peek().v === '!') { next(); v = factorial(v) }
return v
}
function primary() {
const tk = peek()
if (!tk) throw new Error('表达式不完整')
if (tk.t === 'num') { next(); const n = parseFloat(tk.v); if (Number.isNaN(n)) throw new Error('数字无效'); return n }
if (tk.t === 'op' && tk.v === '(') {
next(); const v = exprP()
if (!peek() || peek().v !== ')') throw new Error('缺少右括号')
next(); return v
}
if (tk.t === 'id') {
next()
const low = tk.v.toLowerCase()
if (low === 'pi') return Math.PI
if (low === 'e') return Math.E
if (peek() && peek().t === 'op' && peek().v === '(') {
next(); const arg = exprP()
if (!peek() || peek().v !== ')') throw new Error('函数缺少右括号')
next(); return callFunc(low, arg)
}
throw new Error('未知标识符: ' + tk.v)
}
throw new Error('无法解析: ' + JSON.stringify(tk.v))
}
const v = exprP()
if (pos < tokens.length) throw new Error('表达式存在多余符号')
return v
}
function equals() {
err.value = ''
const s = expr.value.trim()
if (!s) { err.value = '请输入表达式'; return }
try {
const val = evaluate(s)
if (!Number.isFinite(val)) throw new Error('计算结果非有限值')
const txt = formatNum(val)
// 保留完整表达式可见,结果单独成行;不覆盖输入框
result.value = '= ' + txt
history.value.unshift({ expr: s, val: txt })
if (history.value.length > 50) history.value.pop()
} catch (e) { err.value = e.message; result.value = '' }
}
function formatNum(v) {
if (Number.isInteger(v)) return String(v)
return String(parseFloat(v.toPrecision(12)))
}
function loadHist(h) {
// 点击历史:载入当时的表达式,用户可继续编辑后重算
expr.value = h.expr
result.value = ''
err.value = ''
caret.value = h.expr.length
nextTick(() => {
const el = exprInput.value
if (el) { el.focus(); el.setSelectionRange(el.value.length, el.value.length) }
})
}
function clearHist() { history.value = [] }
function doBase() {
err.value = ''
const base = baseFrom.value
const valid = base === 16 ? /^[0-9a-fA-F]+$/ : base === 8 ? /^[0-7]+$/ : base === 2 ? /^[01]+$/ : /^-?\d+$/
if (!baseVal.value.trim()) { err.value = '请输入数值'; return }
if (!valid.test(baseVal.value.trim())) { err.value = `输入含非 ${base} 进制字符`; return }
const dec = parseInt(baseVal.value.trim(), base)
if (Number.isNaN(dec)) { err.value = '进制解析失败'; return }
baseOut.value = {
dec: String(dec),
hex: '0x' + dec.toString(16).toUpperCase(),
oct: '0o' + dec.toString(8),
bin: dec.toString(2)
}
}
function onKey(e) {
const tag = (e.target.tagName || '').toLowerCase()
// 焦点在输入框(含表达式框/进制数值框)时,交给原生输入处理
if (tag === 'input' || tag === 'textarea') return
const k = e.key
if (/[0-9]/.test(k)) ins(k)
else if (k === '+' || k === '-' || k === '*' || k === '/' || k === '%' || k === '(' || k === ')' || k === '.' || k === '^') ins(k)
else if (k === 'Enter') equals()
else if (k === 'Backspace') back()
else if (k === 'Escape') clear()
}
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))2.3 效果截图
