diff --git a/shared/format.test.ts b/shared/format.test.ts index 266b0f3..51f4847 100644 --- a/shared/format.test.ts +++ b/shared/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test' -import { formatAgo, formatBytes } from './format.ts' +import { flagEmoji, formatAgo, formatBytes } from './format.ts' describe('流量数字', () => { it('零和不满 1KB 的按字节显示,不带小数', () => { @@ -29,6 +29,19 @@ describe('流量数字', () => { }) }) +describe('旗帜 emoji', () => { + it('两位国家代码转成区域指示符对,大小写都认', () => { + expect(flagEmoji('JP')).toBe('🇯🇵') + expect(flagEmoji('us')).toBe('🇺🇸') + }) + + it('不像国家代码的输入原样返回', () => { + expect(flagEmoji('')).toBe('') + expect(flagEmoji('USA')).toBe('USA') + expect(flagEmoji('1X')).toBe('1X') + }) +}) + describe('相对时间', () => { const now = new Date('2026-08-12T12:00:00Z') const ago = (ms: number) => new Date(now.getTime() - ms) diff --git a/shared/format.ts b/shared/format.ts index c57dee7..f0b2370 100644 --- a/shared/format.ts +++ b/shared/format.ts @@ -33,6 +33,17 @@ export function formatAgo(at: Date, now: Date): string { return `${String(at.getFullYear())}-${p(at.getMonth() + 1)}-${p(at.getDate())}` } +/** + * 两位国家代码转成旗帜 emoji(区域指示符对)。不像国家代码的输入原样返回, + * 显示端至少还有字母可看。注意 Windows 的系统字体没有旗帜字形,浏览器会 + * 把区域指示符退回显示成两个字母。 + */ +export function flagEmoji(countryCode: string): string { + const code = countryCode.trim().toUpperCase() + if (!/^[A-Z]{2}$/.test(code)) return countryCode + return String.fromCodePoint(...[...code].map((ch) => 0x1f1e6 + (ch.codePointAt(0) ?? 65) - 65)) +} + /** 运行时长。一小时内说分钟,一天内说时分,再长说天和小时;秒级抖动没有信息量。 */ export function formatUptime(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return '—' diff --git a/web/views/DashboardPage.vue b/web/views/DashboardPage.vue index 10d9c09..3fd6c59 100644 --- a/web/views/DashboardPage.vue +++ b/web/views/DashboardPage.vue @@ -10,7 +10,7 @@ */ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts' -import { formatBytes, formatUptime } from '@shared/format.ts' +import { flagEmoji, formatBytes, formatUptime } from '@shared/format.ts' import { ACTIVE_BYTES, ROUTE_WINDOW_MS, bucketRates, lastRate, windowBytes } from '@shared/telemetry.ts' import type { RouteSample } from '@shared/telemetry.ts' import type { HostInfo } from '@shared/host-info.ts' @@ -41,6 +41,8 @@ import IconLan from '~icons/ri/router-line' import IconKernel from '~icons/ri/terminal-box-line' import IconOverview from '~icons/ri/layout-grid-line' import IconGo from '~icons/ri/arrow-right-s-line' +import IconProbe from '~icons/ri/global-line' +import IconRestart from '~icons/ri/restart-line' const props = defineProps<{ data: ConfigObject }>() @@ -135,7 +137,7 @@ function exitCell(probeable: boolean, port: number | null): ExitCell { return { kind: 'ok', port, - text: [result.countryCode, result.ip].filter(Boolean).join(' ') || '未知地址', + text: [flagEmoji(result.countryCode ?? ''), result.ip].filter(Boolean).join(' ') || '未知地址', title: `${result.country ?? ''} 往返 ${String(result.ms ?? '?')} ms,点击重测`, } } @@ -431,7 +433,7 @@ const ports = computed(() => kernelPorts(configs.value ?? {})) :disabled="restarting" @click="confirmRestart = true" > - {{ restarting ? '重启中' : '重启内核' }} + {{ restarting ? '重启中' : '重启内核' }}
@@ -490,9 +492,10 @@ const ports = computed(() => kernelPorts(configs.value ?? {})) v-if="row.exit.kind === 'idle'" type="button" class="probe" + title="检测公网 IP" @click.stop="void probeExit(row.exit.port)" > - 检测公网 IP + 检测 检测中