仪表盘和订阅详情的操作按钮改 text-button,公网 IP 带旗帜
检测公网 IP、重启内核、订阅详情右上三个按钮都去掉边框,hover 出浅底。 检测结果由国家代码换成旗帜 emoji 加 IP;Windows 的系统字体没有旗帜 字形,浏览器会退回显示两个字母,和原来的效果一致。
This commit is contained in:
+14
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'bun:test'
|
import { describe, expect, it } from 'bun:test'
|
||||||
import { formatAgo, formatBytes } from './format.ts'
|
import { flagEmoji, formatAgo, formatBytes } from './format.ts'
|
||||||
|
|
||||||
describe('流量数字', () => {
|
describe('流量数字', () => {
|
||||||
it('零和不满 1KB 的按字节显示,不带小数', () => {
|
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('相对时间', () => {
|
describe('相对时间', () => {
|
||||||
const now = new Date('2026-08-12T12:00:00Z')
|
const now = new Date('2026-08-12T12:00:00Z')
|
||||||
const ago = (ms: number) => new Date(now.getTime() - ms)
|
const ago = (ms: number) => new Date(now.getTime() - ms)
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ export function formatAgo(at: Date, now: Date): string {
|
|||||||
return `${String(at.getFullYear())}-${p(at.getMonth() + 1)}-${p(at.getDate())}`
|
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 {
|
export function formatUptime(seconds: number): string {
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) return '—'
|
if (!Number.isFinite(seconds) || seconds < 0) return '—'
|
||||||
|
|||||||
+31
-15
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts'
|
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 { ACTIVE_BYTES, ROUTE_WINDOW_MS, bucketRates, lastRate, windowBytes } from '@shared/telemetry.ts'
|
||||||
import type { RouteSample } from '@shared/telemetry.ts'
|
import type { RouteSample } from '@shared/telemetry.ts'
|
||||||
import type { HostInfo } from '@shared/host-info.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 IconKernel from '~icons/ri/terminal-box-line'
|
||||||
import IconOverview from '~icons/ri/layout-grid-line'
|
import IconOverview from '~icons/ri/layout-grid-line'
|
||||||
import IconGo from '~icons/ri/arrow-right-s-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 }>()
|
const props = defineProps<{ data: ConfigObject }>()
|
||||||
|
|
||||||
@@ -135,7 +137,7 @@ function exitCell(probeable: boolean, port: number | null): ExitCell {
|
|||||||
return {
|
return {
|
||||||
kind: 'ok',
|
kind: 'ok',
|
||||||
port,
|
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,点击重测`,
|
title: `${result.country ?? ''} 往返 ${String(result.ms ?? '?')} ms,点击重测`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,7 +433,7 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
:disabled="restarting"
|
:disabled="restarting"
|
||||||
@click="confirmRestart = true"
|
@click="confirmRestart = true"
|
||||||
>
|
>
|
||||||
{{ restarting ? '重启中' : '重启内核' }}
|
<IconRestart /> {{ restarting ? '重启中' : '重启内核' }}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="sect-b">
|
<div class="sect-b">
|
||||||
@@ -490,9 +492,10 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
v-if="row.exit.kind === 'idle'"
|
v-if="row.exit.kind === 'idle'"
|
||||||
type="button"
|
type="button"
|
||||||
class="probe"
|
class="probe"
|
||||||
|
title="检测公网 IP"
|
||||||
@click.stop="void probeExit(row.exit.port)"
|
@click.stop="void probeExit(row.exit.port)"
|
||||||
>
|
>
|
||||||
检测公网 IP
|
<IconProbe /> 检测
|
||||||
</button>
|
</button>
|
||||||
<span v-else-if="row.exit.kind === 'loading'" class="probe-wait">检测中</span>
|
<span v-else-if="row.exit.kind === 'loading'" class="probe-wait">检测中</span>
|
||||||
<button
|
<button
|
||||||
@@ -850,23 +853,30 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
|
|
||||||
/* ── 内核 ─────────────────────────────────────── */
|
/* ── 内核 ─────────────────────────────────────── */
|
||||||
.restart {
|
.restart {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 2px var(--s-3);
|
padding: 2px var(--s-2);
|
||||||
border: 1px solid var(--c-border-strong);
|
border: none;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
background: var(--c-surface);
|
background: none;
|
||||||
color: var(--c-text-soft);
|
color: var(--c-text-soft);
|
||||||
font-size: var(--t-xs);
|
font-size: var(--t-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.restart:hover:not(:disabled) {
|
.restart:hover:not(:disabled) {
|
||||||
border-color: color-mix(in srgb, var(--c-danger) 45%, var(--c-border));
|
background: var(--c-surface-sunken);
|
||||||
color: var(--c-danger);
|
color: var(--c-danger);
|
||||||
}
|
}
|
||||||
.restart:disabled {
|
.restart:disabled {
|
||||||
color: var(--c-text-faint);
|
color: var(--c-text-faint);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
.restart svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 路由 ─────────────────────────────────────── */
|
/* ── 路由 ─────────────────────────────────────── */
|
||||||
.routes {
|
.routes {
|
||||||
@@ -940,27 +950,33 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
|
|||||||
|
|
||||||
.probe,
|
.probe,
|
||||||
.exit {
|
.exit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
padding: 1px var(--s-2);
|
padding: 1px var(--s-2);
|
||||||
border: 1px solid var(--c-border-strong);
|
border: none;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
background: var(--c-surface);
|
background: none;
|
||||||
|
color: var(--c-text-soft);
|
||||||
font-size: var(--t-xs);
|
font-size: var(--t-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.probe:hover,
|
.probe:hover,
|
||||||
.exit:hover {
|
.exit:hover {
|
||||||
border-color: var(--c-accent);
|
background: var(--c-surface-sunken);
|
||||||
color: var(--c-accent-text);
|
color: var(--c-accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.exit {
|
.probe svg {
|
||||||
border-color: transparent;
|
width: 13px;
|
||||||
color: var(--c-text-soft);
|
height: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.probe-fail {
|
.probe-fail {
|
||||||
color: var(--c-danger);
|
color: var(--c-danger);
|
||||||
border-color: color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
}
|
||||||
|
.probe-fail:hover {
|
||||||
|
color: var(--c-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.probe-wait,
|
.probe-wait,
|
||||||
|
|||||||
@@ -149,22 +149,22 @@ function delayClass(ms: number | null): string {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s-1);
|
gap: var(--s-1);
|
||||||
padding: var(--s-1) var(--s-3);
|
padding: var(--s-1) var(--s-2);
|
||||||
border: 1px solid var(--c-border-strong);
|
border: none;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
background: var(--c-surface);
|
background: none;
|
||||||
|
color: var(--c-text-soft);
|
||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.acts button:hover:not(:disabled) {
|
.acts button:hover:not(:disabled) {
|
||||||
border-color: var(--c-accent);
|
background: var(--c-surface-sunken);
|
||||||
color: var(--c-accent-text);
|
color: var(--c-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.acts button.danger:hover:not(:disabled) {
|
.acts button.danger:hover:not(:disabled) {
|
||||||
border-color: var(--c-danger);
|
|
||||||
color: var(--c-danger);
|
color: var(--c-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user