
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>汇率一览</title>
<style>
:root{
color-scheme:light;
--bg:rgba(247,249,253,.97);
--surface:#ffffff;
--surface-soft:#f3f6fb;
--surface-hover:#eef3fa;
--text:#1d293d;
--muted:#8290a5;
--border:#dce4ef;
--accent:#ff4f91;
--accent-soft:#fff0f6;
--accent-border:#ffabd0;
--value:#f03e7c;
--success:#24b98b;
--danger:#d44a55;
--danger-soft:#fff0f1;
--shadow:0 12px 32px rgba(34,51,84,.14);
}
@media (prefers-color-scheme:dark){
:root{
color-scheme:dark;
--bg:rgba(15,25,43,.97);
--surface:#16233a;
--surface-soft:#1b2942;
--surface-hover:#22324e;
--text:#eef4ff;
--muted:#9caac0;
--border:#30415f;
--accent:#ff62a1;
--accent-soft:#34203a;
--accent-border:#9b4670;
--value:#ff639e;
--success:#5edbb6;
--danger:#ff929b;
--danger-soft:#3d222b;
--shadow:0 12px 32px rgba(0,0,0,.28);
}
}
*{
box-sizing:border-box;
}
html,body{
width:100%;
height:100%;
margin:0;
overflow:hidden;
background:transparent;
font-family:"Microsoft YaHei",sans-serif;
color:var(--text);
}
button,input,select{
font:inherit;
}
button{
color:inherit;
}
.app{
width:100%;
height:100%;
min-width:250px;
min-height:260px;
display:flex;
flex-direction:column;
overflow:hidden;
border-radius:16px;
background:var(--bg);
box-shadow:var(--shadow);
}
.header{
flex:0 0 auto;
padding:14px 14px 10px;
}
.title-row{
display:flex;
align-items:center;
gap:9px;
min-height:34px;
}
.title-dot{
width:12px;
height:12px;
flex:0 0 auto;
border-radius:50%;
background:var(--accent);
box-shadow:0 0 12px rgba(255,79,145,.58);
}
.title-block{
min-width:0;
flex:1;
}
.title{
margin:0;
color:var(--accent);
font-size:18px;
line-height:1.25;
font-weight:800;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.subtitle{
margin-top:2px;
color:var(--muted);
font-size:11px;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.header-actions{
display:flex;
align-items:center;
gap:6px;
}
.text-button{
height:31px;
padding:0 10px;
border:1px solid var(--border);
border-radius:10px;
background:var(--surface-soft);
cursor:pointer;
font-size:12px;
font-weight:650;
transition:background .15s,border-color .15s,transform .08s,opacity .15s;
}
.text-button:hover{
background:var(--surface-hover);
border-color:var(--accent-border);
}
.text-button:active{
transform:scale(.96);
}
.text-button:focus-visible{
outline:2px solid var(--accent);
outline-offset:2px;
}
.text-button:disabled{
opacity:.46;
cursor:not-allowed;
transform:none;
}
.refresh-button.loading{
color:var(--accent);
}
.control-row{
display:grid;
grid-template-columns:minmax(112px,1.15fr) minmax(90px,1fr);
gap:9px;
margin-top:11px;
}
.control{
position:relative;
min-width:0;
}
.control select,
.control input{
width:100%;
height:48px;
border:1px solid var(--border);
border-radius:13px;
outline:none;
background:var(--surface);
color:var(--text);
transition:border-color .15s,background .15s,box-shadow .15s;
}
.control select:hover,
.control input:hover{
background:var(--surface-hover);
}
.control select:focus,
.control input:focus{
border-color:var(--accent);
box-shadow:0 0 0 3px rgba(255,79,145,.13);
}
.control select:disabled,
.control input:disabled{
opacity:.56;
cursor:not-allowed;
}
.source-select{
padding:0 30px 0 44px;
appearance:none;
cursor:pointer;
font-size:15px;
font-weight:750;
}
.source-flag{
position:absolute;
left:11px;
top:50%;
transform:translateY(-50%);
width:27px;
height:27px;
display:flex;
align-items:center;
justify-content:center;
border-radius:8px;
background:var(--surface-soft);
font-size:19px;
pointer-events:none;
}
.select-arrow{
position:absolute;
right:11px;
top:50%;
transform:translateY(-50%);
color:var(--muted);
font-size:12px;
pointer-events:none;
}
.amount-input{
padding:0 14px;
text-align:right;
font-size:21px;
font-weight:800;
}
.amount-input::-webkit-inner-spin-button,
.amount-input::-webkit-outer-spin-button{
appearance:none;
margin:0;
}
.status-row{
min-height:31px;
display:flex;
align-items:center;
gap:7px;
padding:8px 2px 0;
color:var(--muted);
font-size:11px;
}
.status-dot{
width:9px;
height:9px;
flex:0 0 auto;
border-radius:50%;
background:var(--success);
box-shadow:0 0 9px rgba(36,185,139,.5);
}
.status-dot.loading{
background:var(--accent);
box-shadow:0 0 9px rgba(255,79,145,.5);
animation:pulse 1s ease-in-out infinite;
}
.status-dot.error{
background:var(--danger);
box-shadow:none;
}
@keyframes pulse{
50%{opacity:.4}
}
.status-text{
min-width:0;
flex:1;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.item-count{
flex:0 0 auto;
}
.error-bar{
display:none;
align-items:center;
gap:8px;
margin-top:8px;
padding:7px 8px 7px 10px;
border-radius:9px;
background:var(--danger-soft);
color:var(--danger);
font-size:11px;
}
.error-bar.show{
display:flex;
}
.error-message{
min-width:0;
flex:1;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.retry-button{
flex:0 0 auto;
padding:3px 7px;
border:0;
border-radius:6px;
background:transparent;
color:inherit;
font-weight:750;
cursor:pointer;
}
.retry-button:hover{
background:rgba(255,255,255,.22);
}
.retry-button:focus-visible{
outline:2px solid currentColor;
}
.list{
min-height:0;
flex:1;
overflow:auto;
padding:0 14px 14px;
scrollbar-width:thin;
scrollbar-color:var(--border) transparent;
}
.list::-webkit-scrollbar{
width:6px;
}
.list::-webkit-scrollbar-thumb{
border-radius:8px;
background:var(--border);
}
.rate-card{
position:relative;
display:grid;
grid-template-columns:22px 39px minmax(0,1fr) auto 28px;
align-items:center;
gap:9px;
min-height:68px;
margin-bottom:9px;
padding:9px 8px;
border:1px solid var(--border);
border-radius:14px;
background:var(--surface);
transition:background .15s,border-color .15s,transform .15s,box-shadow .15s;
}
.rate-card:last-child{
margin-bottom:0;
}
.rate-card:hover{
background:var(--surface-hover);
border-color:var(--accent-border);
transform:translateY(-1px);
box-shadow:0 7px 18px rgba(38,53,80,.08);
}
.rate-card.base{
border-color:var(--accent-border);
background:var(--accent-soft);
}
.rate-card.dragging{
opacity:.45;
}
.rate-card.drag-over{
border-color:var(--accent);
transform:translateY(2px);
}
.drag-handle{
width:22px;
height:34px;
display:flex;
align-items:center;
justify-content:center;
border:0;
padding:0;
background:transparent;
color:var(--muted);
cursor:grab;
}
.drag-handle:active{
cursor:grabbing;
}
.drag-handle svg{
width:17px;
height:17px;
fill:none;
stroke:currentColor;
stroke-width:1.6;
stroke-linecap:round;
}
.flag{
width:38px;
height:38px;
display:flex;
align-items:center;
justify-content:center;
border-radius:10px;
background:var(--surface-soft);
font-size:25px;
overflow:hidden;
}
.currency-info{
min-width:0;
}
.currency-top{
display:flex;
align-items:baseline;
gap:7px;
min-width:0;
}
.region-code{
flex:0 0 auto;
font-size:13px;
font-weight:800;
}
.currency-name{
min-width:0;
color:#f1a62d;
font-size:14px;
font-weight:800;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.rate-card.base .currency-name{
color:var(--accent);
}
.currency-pair{
margin-top:3px;
color:var(--muted);
font-size:10px;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.rate-value{
min-width:68px;
max-width:145px;
color:var(--value);
font-size:17px;
font-weight:850;
text-align:right;
overflow-wrap:anywhere;
}
.rate-card.base .rate-value{
color:var(--text);
}
.copy-button{
width:27px;
height:27px;
display:flex;
align-items:center;
justify-content:center;
padding:0;
border:0;
border-radius:8px;
background:transparent;
color:var(--muted);
cursor:pointer;
opacity:.62;
transition:background .15s,color .15s,opacity .15s,transform .08s;
}
.rate-card:hover .copy-button{
opacity:1;
}
.copy-button:hover{
background:var(--surface-soft);
color:var(--accent);
}
.copy-button:active{
transform:scale(.9);
}
.copy-button:focus-visible{
outline:2px solid var(--accent);
outline-offset:1px;
}
.copy-button:disabled{
opacity:.25;
cursor:not-allowed;
}
.copy-button svg{
width:15px;
height:15px;
fill:none;
stroke:currentColor;
stroke-width:1.8;
stroke-linecap:round;
stroke-linejoin:round;
}
.empty-state{
min-height:110px;
display:flex;
align-items:center;
justify-content:center;
color:var(--muted);
font-size:12px;
text-align:center;
}
.toast{
position:absolute;
left:50%;
bottom:15px;
z-index:20;
max-width:calc(100% - 28px);
transform:translate(-50%,10px);
padding:7px 11px;
border-radius:9px;
background:rgba(20,29,45,.92);
color:#fff;
font-size:11px;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
opacity:0;
pointer-events:none;
transition:opacity .16s,transform .16s;
}
.toast.show{
opacity:1;
transform:translate(-50%,0);
}
@media (max-width:330px){
.header{
padding-left:10px;
padding-right:10px;
}
.list{
padding-left:10px;
padding-right:10px;
}
.title{
font-size:16px;
}
.subtitle{
display:none;
}
.text-button{
padding:0 8px;
}
.rate-card{
grid-template-columns:18px 35px minmax(0,1fr) auto 25px;
gap:6px;
padding-left:6px;
padding-right:6px;
}
.flag{
width:34px;
height:34px;
font-size:22px;
}
.currency-name{
font-size:13px;
}
.region-code{
font-size:12px;
}
.rate-value{
min-width:56px;
font-size:15px;
}
}
@media (max-width:275px){
.title-row{
gap:6px;
}
.title-dot{
width:9px;
height:9px;
}
.reset-button{
display:none;
}
.control-row{
grid-template-columns:1fr;
gap:7px;
}
.control select,
.control input{
height:40px;
}
.amount-input{
font-size:18px;
}
.rate-card{
grid-template-columns:32px minmax(0,1fr) auto;
}
.drag-handle,
.copy-button{
display:none;
}
.flag{
width:32px;
height:32px;
}
}
@media (max-height:430px){
.header{
padding-top:9px;
padding-bottom:7px;
}
.subtitle{
display:none;
}
.control-row{
margin-top:7px;
}
.control select,
.control input{
height:41px;
}
.status-row{
min-height:26px;
padding-top:5px;
}
.rate-card{
min-height:57px;
margin-bottom:6px;
padding-top:6px;
padding-bottom:6px;
}
.flag{
width:34px;
height:34px;
font-size:22px;
}
}
</style>
</head>
<body>
<div class="app" id="app">
<header class="header">
<div class="title-row">
<span class="title-dot" aria-hidden="true"></span>
<div class="title-block">
<h1 class="title">汇率一览</h1>
<div class="subtitle" id="subtitle">常用货币同时换算</div>
</div>
<div class="header-actions">
<button class="text-button reset-button" id="resetButton" type="button">重置</button>
<button class="text-button refresh-button" id="refreshButton" type="button">刷新</button>
</div>
</div>
<div class="control-row">
<div class="control">
<span class="source-flag" id="sourceFlag" aria-hidden="true">🇨🇳</span>
<select class="source-select" id="sourceSelect" aria-label="选择源货币"></select>
<span class="select-arrow" aria-hidden="true">⌄</span>
</div>
<div class="control">
<input class="amount-input" id="amountInput" type="number" min="0" step="any" inputmode="decimal" autocomplete="off" aria-label="输入换算金额">
</div>
</div>
<div class="status-row">
<span class="status-dot" id="statusDot" aria-hidden="true"></span>
<span class="status-text" id="statusText" role="status" aria-live="polite">正在初始化</span>
<span class="item-count" id="itemCount">7 项</span>
</div>
<div class="error-bar" id="errorBar" role="alert">
<span class="error-message" id="errorMessage"></span>
<button class="retry-button" id="retryButton" type="button">重试</button>
</div>
</header>
<main class="list" id="rateList" aria-label="汇率换算列表"></main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</div>
<script>
const host = window.widgetHost;
const app = document.getElementById("app");
const sourceSelect = document.getElementById("sourceSelect");
const sourceFlag = document.getElementById("sourceFlag");
const amountInput = document.getElementById("amountInput");
const resetButton = document.getElementById("resetButton");
const refreshButton = document.getElementById("refreshButton");
const retryButton = document.getElementById("retryButton");
const statusDot = document.getElementById("statusDot");
const statusText = document.getElementById("statusText");
const itemCount = document.getElementById("itemCount");
const rateList = document.getElementById("rateList");
const errorBar = document.getElementById("errorBar");
const errorMessage = document.getElementById("errorMessage");
const toast = document.getElementById("toast");
const subtitle = document.getElementById("subtitle");
const AUTO_REFRESH_MS = 5 * 60 * 1000;
const DEFAULT_BASE = "CNY";
const DEFAULT_AMOUNT = "1";
const DEFAULT_TARGETS = ["CNY","USD","JPY","EUR","GBP","AUD","SGD"];
const CURRENCIES = [
{code:"CNY",region:"CN",name:"人民币",flag:"🇨🇳"},
{code:"USD",region:"US",name:"美元",flag:"🇺🇸"},
{code:"JPY",region:"JP",name:"日元",flag:"🇯🇵"},
{code:"EUR",region:"EU",name:"欧元",flag:"🇪🇺"},
{code:"GBP",region:"GB",name:"英镑",flag:"🇬🇧"},
{code:"AUD",region:"AU",name:"澳元",flag:"🇦🇺"},
{code:"SGD",region:"SG",name:"新元",flag:"🇸🇬"},
{code:"HKD",region:"HK",name:"港币",flag:"🇭🇰"},
{code:"CAD",region:"CA",name:"加拿大元",flag:"🇨🇦"},
{code:"CHF",region:"CH",name:"瑞士法郎",flag:"🇨🇭"},
{code:"KRW",region:"KR",name:"韩元",flag:"🇰🇷"},
{code:"NZD",region:"NZ",name:"新西兰元",flag:"🇳🇿"},
{code:"SEK",region:"SE",name:"瑞典克朗",flag:"🇸🇪"},
{code:"NOK",region:"NO",name:"挪威克朗",flag:"🇳🇴"},
{code:"DKK",region:"DK",name:"丹麦克朗",flag:"🇩🇰"},
{code:"INR",region:"IN",name:"印度卢比",flag:"🇮🇳"},
{code:"THB",region:"TH",name:"泰铢",flag:"🇹🇭"},
{code:"MYR",region:"MY",name:"马来西亚林吉特",flag:"🇲🇾"},
{code:"PHP",region:"PH",name:"菲律宾比索",flag:"🇵🇭"},
{code:"IDR",region:"ID",name:"印尼盾",flag:"🇮🇩"},
{code:"TRY",region:"TR",name:"土耳其里拉",flag:"🇹🇷"},
{code:"ZAR",region:"ZA",name:"南非兰特",flag:"🇿🇦"},
{code:"MXN",region:"MX",name:"墨西哥比索",flag:"🇲🇽"},
{code:"BRL",region:"BR",name:"巴西雷亚尔",flag:"🇧🇷"},
{code:"PLN",region:"PL",name:"波兰兹罗提",flag:"🇵🇱"},
{code:"CZK",region:"CZ",name:"捷克克朗",flag:"🇨🇿"},
{code:"HUF",region:"HU",name:"匈牙利福林",flag:"🇭🇺"},
{code:"RON",region:"RO",name:"罗马尼亚列伊",flag:"🇷🇴"},
{code:"BGN",region:"BG",name:"保加利亚列弗",flag:"🇧🇬"},
{code:"ILS",region:"IL",name:"以色列新谢克尔",flag:"🇮🇱"},
{code:"ISK",region:"IS",name:"冰岛克朗",flag:"🇮🇸"}
];
const CURRENCY_MAP = new Map(CURRENCIES.map(currency => [currency.code, currency]));
let targetCurrencies = [...DEFAULT_TARGETS];
let rates = {};
let rateDate = "";
let fetchedAt = "";
let loading = false;
let autoRefreshTimer = null;
let toastTimer = null;
let draggedCode = "";
function getErrorText(error){
return String(error?.message || error);
}
function getCurrency(code){
return CURRENCY_MAP.get(code) || {
code,
region:code.slice(0,2),
name:code,
flag:"💱"
};
}
function isSupportedCurrency(code){
return CURRENCY_MAP.has(code);
}
function normalizeTargets(value){
if(!Array.isArray(value)){
return [...DEFAULT_TARGETS];
}
const unique = [];
for(const item of value){
const code = String(item || "").toUpperCase();
if(isSupportedCurrency(code) && !unique.includes(code)){
unique.push(code);
}
}
return unique.length ? unique.slice(0,12) : [...DEFAULT_TARGETS];
}
function populateSourceOptions(){
const fragment = document.createDocumentFragment();
for(const currency of CURRENCIES){
const option = document.createElement("option");
option.value = currency.code;
option.textContent = `${currency.code} · ${currency.name}`;
fragment.appendChild(option);
}
sourceSelect.replaceChildren(fragment);
}
function setLoading(value){
loading = value;
app.setAttribute("aria-busy", value ? "true" : "false");
sourceSelect.disabled = value;
refreshButton.disabled = value;
resetButton.disabled = value;
retryButton.disabled = value;
refreshButton.classList.toggle("loading", value);
refreshButton.textContent = value ? "更新中" : "刷新";
if(value){
statusDot.className = "status-dot loading";
statusText.textContent = "正在获取最新汇率";
}
}
function clearError(){
errorBar.classList.remove("show");
errorBar.title = "";
errorMessage.title = "";
}
function showError(message,error){
const detail = getErrorText(error);
errorMessage.textContent = message;
errorMessage.title = detail;
errorBar.title = detail;
errorBar.classList.add("show");
statusDot.className = "status-dot error";
statusText.textContent = "汇率更新失败";
}
function showToast(message){
if(toastTimer !== null){
clearTimeout(toastTimer);
toastTimer = null;
}
toast.textContent = message;
toast.classList.add("show");
toastTimer = setTimeout(() => {
toast.classList.remove("show");
toastTimer = null;
},1400);
}
async function readState(key,defaultValue){
try{
if(!host?.state?.read){
throw new Error("宿主状态读取接口不可用");
}
return await host.state.read(key,defaultValue);
}catch(error){
showError("状态读取失败",error);
return defaultValue;
}
}
async function writeState(key,value){
try{
if(!host?.state?.write){
throw new Error("宿主状态写入接口不可用");
}
await host.state.write(key,value);
}catch(error){
showError("状态保存失败",error);
}
}
async function writeClipboard(text){
try{
if(!host?.clipboard?.write){
throw new Error("宿主剪贴板接口不可用");
}
await host.clipboard.write(text);
showToast("换算结果已复制");
}catch(error){
showError("复制失败",error);
}
}
function parseAmount(){
const value = Number(amountInput.value);
return Number.isFinite(value) && value >= 0 ? value : null;
}
function formatNumber(value){
if(!Number.isFinite(value)){
return "--";
}
const absolute = Math.abs(value);
if(absolute !== 0 && absolute < 0.000001){
return value.toExponential(6);
}
let digits = 4;
if(absolute >= 1000000){
digits = 0;
}else if(absolute >= 10000){
digits = 2;
}else if(absolute >= 100){
digits = 3;
}else if(absolute < 1){
digits = 6;
}
return new Intl.NumberFormat("zh-CN",{
minimumFractionDigits:0,
maximumFractionDigits:digits
}).format(value);
}
function formatTime(value){
if(!value){
return "";
}
const date = new Date(value);
if(Number.isNaN(date.getTime())){
return "";
}
return new Intl.DateTimeFormat("zh-CN",{
month:"2-digit",
day:"2-digit",
hour:"2-digit",
minute:"2-digit",
second:"2-digit",
hour12:false
}).format(date);
}
function updateSourcePresentation(){
const currency = getCurrency(sourceSelect.value);
sourceFlag.textContent = currency.flag;
subtitle.textContent = `${currency.code} 兑换常用货币`;
}
function updateStatus(fromCache){
if(loading){
return;
}
const formattedTime = formatTime(fetchedAt);
statusDot.className = "status-dot";
statusText.textContent = formattedTime
? `${fromCache ? "缓存更新于" : "更新于"} ${formattedTime}`
: "汇率已就绪";
itemCount.textContent = `${targetCurrencies.length} 项`;
}
function createDragIcon(){
const svg = document.createElementNS("http://www.w3.org/2000/svg","svg");
svg.setAttribute("viewBox","0 0 24 24");
svg.setAttribute("aria-hidden","true");
const path1 = document.createElementNS("http://www.w3.org/2000/svg","path");
path1.setAttribute("d","M6 8h12");
const path2 = document.createElementNS("http://www.w3.org/2000/svg","path");
path2.setAttribute("d","M6 12h12");
const path3 = document.createElementNS("http://www.w3.org/2000/svg","path");
path3.setAttribute("d","M6 16h12");
svg.append(path1,path2,path3);
return svg;
}
function createCopyIcon(){
const svg = document.createElementNS("http://www.w3.org/2000/svg","svg");
svg.setAttribute("viewBox","0 0 24 24");
svg.setAttribute("aria-hidden","true");
const rect = document.createElementNS("http://www.w3.org/2000/svg","rect");
rect.setAttribute("x","8");
rect.setAttribute("y","8");
rect.setAttribute("width","11");
rect.setAttribute("height","11");
rect.setAttribute("rx","2");
const path = document.createElementNS("http://www.w3.org/2000/svg","path");
path.setAttribute("d","M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2");
svg.append(rect,path);
return svg;
}
function renderRates(){
const amount = parseAmount();
const base = sourceSelect.value;
const fragment = document.createDocumentFragment();
if(targetCurrencies.length === 0){
const empty = document.createElement("div");
empty.className = "empty-state";
empty.textContent = "没有需要显示的货币";
rateList.replaceChildren(empty);
return;
}
for(const code of targetCurrencies){
const currency = getCurrency(code);
const rate = code === base ? 1 : Number(rates[code]);
const converted = amount !== null && Number.isFinite(rate) ? amount * rate : null;
const card = document.createElement("article");
card.className = `rate-card${code === base ? " base" : ""}`;
card.dataset.code = code;
card.draggable = true;
const dragHandle = document.createElement("button");
dragHandle.className = "drag-handle";
dragHandle.type = "button";
dragHandle.title = "拖动调整顺序";
dragHandle.setAttribute("aria-label",`拖动 ${currency.name} 调整顺序`);
dragHandle.appendChild(createDragIcon());
const flag = document.createElement("div");
flag.className = "flag";
flag.textContent = currency.flag;
flag.setAttribute("aria-hidden","true");
const info = document.createElement("div");
info.className = "currency-info";
const top = document.createElement("div");
top.className = "currency-top";
const region = document.createElement("span");
region.className = "region-code";
region.textContent = currency.region;
const name = document.createElement("span");
name.className = "currency-name";
name.textContent = `${currency.name} (${currency.code})`;
top.append(region,name);
const pair = document.createElement("div");
pair.className = "currency-pair";
pair.textContent = `${base} 兑 ${currency.code}`;
info.append(top,pair);
const value = document.createElement("div");
value.className = "rate-value";
value.textContent = converted === null ? "--" : formatNumber(converted);
value.title = converted === null ? "暂无汇率" : `${amount} ${base} = ${formatNumber(converted)} ${code}`;
const copyButton = document.createElement("button");
copyButton.className = "copy-button";
copyButton.type = "button";
copyButton.title = `复制 ${currency.code} 换算结果`;
copyButton.setAttribute("aria-label",`复制 ${currency.name} 换算结果`);
copyButton.disabled = converted === null;
copyButton.appendChild(createCopyIcon());
copyButton.addEventListener("click",event => {
event.stopPropagation();
if(converted === null){
return;
}
const text = `${formatNumber(amount)} ${base} = ${formatNumber(converted)} ${code}`;
void writeClipboard(text);
});
card.addEventListener("dblclick",() => {
if(code === sourceSelect.value || loading){
return;
}
sourceSelect.value = code;
updateSourcePresentation();
void writeState("baseCurrency",code);
void refreshRates();
});
card.addEventListener("dragstart",event => {
draggedCode = code;
card.classList.add("dragging");
if(event.dataTransfer){
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain",code);
}
});
card.addEventListener("dragend",() => {
draggedCode = "";
card.classList.remove("dragging");
for(const item of rateList.querySelectorAll(".rate-card")){
item.classList.remove("drag-over");
}
});
card.addEventListener("dragover",event => {
event.preventDefault();
if(draggedCode && draggedCode !== code){
card.classList.add("drag-over");
}
if(event.dataTransfer){
event.dataTransfer.dropEffect = "move";
}
});
card.addEventListener("dragleave",() => {
card.classList.remove("drag-over");
});
card.addEventListener("drop",event => {
event.preventDefault();
card.classList.remove("drag-over");
if(!draggedCode || draggedCode === code){
return;
}
const oldIndex = targetCurrencies.indexOf(draggedCode);
const newIndex = targetCurrencies.indexOf(code);
if(oldIndex < 0 || newIndex < 0){
return;
}
const nextTargets = [...targetCurrencies];
nextTargets.splice(oldIndex,1);
nextTargets.splice(newIndex,0,draggedCode);
targetCurrencies = nextTargets;
renderRates();
void writeState("targetCurrencies",targetCurrencies);
});
card.append(dragHandle,flag,info,value,copyButton);
fragment.appendChild(card);
}
rateList.replaceChildren(fragment);
itemCount.textContent = `${targetCurrencies.length} 项`;
}
async function loadCache(){
const cache = await readState("rateCache",null);
if(!cache || typeof cache !== "object"){
return false;
}
if(cache.base !== sourceSelect.value){
return false;
}
if(!cache.rates || typeof cache.rates !== "object"){
return false;
}
rates = {...cache.rates};
rateDate = String(cache.date || "");
fetchedAt = String(cache.fetchedAt || "");
renderRates();
updateStatus(true);
return true;
}
async function refreshRates(){
if(loading){
return;
}
clearError();
setLoading(true);
const base = sourceSelect.value;
const remoteTargets = targetCurrencies.filter(code => code !== base);
try{
if(remoteTargets.length === 0){
rates = {[base]:1};
rateDate = "";
fetchedAt = new Date().toISOString();
await writeState("rateCache",{
base,
rates,
date:rateDate,
fetchedAt
});
renderRates();
return;
}
if(!host?.http?.get){
throw new Error("宿主网络接口不可用");
}
const url = `https://api.frankfurter.app/latest?from=${encodeURIComponent(base)}&to=${encodeURIComponent(remoteTargets.join(","))}`;
let response;
try{
response = await host.http.get({
url,
timeoutMs:12000,
maxBytes:131072
});
}catch(error){
throw new Error(`网络请求失败:${getErrorText(error)}`);
}
if(!response){
throw new Error("没有收到汇率响应");
}
if(response.truncated){
throw new Error("汇率响应内容不完整");
}
if(!response.ok){
throw new Error(`HTTP ${response.status} ${response.statusText || ""}`.trim());
}
let data;
try{
data = JSON.parse(response.text);
}catch(error){
throw new Error(`汇率数据解析失败:${getErrorText(error)}`);
}
if(!data?.rates || typeof data.rates !== "object"){
throw new Error("响应中没有有效的汇率数据");
}
const nextRates = {[base]:1};
for(const code of remoteTargets){
const value = Number(data.rates[code]);
if(Number.isFinite(value) && value > 0){
nextRates[code] = value;
}
}
const missing = remoteTargets.filter(code => !Number.isFinite(nextRates[code]));
if(missing.length === remoteTargets.length){
throw new Error("没有获得任何目标货币汇率");
}
rates = nextRates;
rateDate = typeof data.date === "string" ? data.date : "";
fetchedAt = new Date().toISOString();
await writeState("rateCache",{
base,
rates,
date:rateDate,
fetchedAt
});
renderRates();
if(missing.length){
throw new Error(`缺少 ${missing.join("、")} 的汇率`);
}
}catch(error){
renderRates();
if(Object.keys(rates).length){
showError("刷新失败,已保留上次汇率",error);
}else{
showError("汇率获取失败",error);
}
}finally{
setLoading(false);
if(!errorBar.classList.contains("show")){
updateStatus(false);
}
}
}
async function resetWidget(){
if(loading){
return;
}
clearError();
sourceSelect.value = DEFAULT_BASE;
amountInput.value = DEFAULT_AMOUNT;
targetCurrencies = [...DEFAULT_TARGETS];
rates = {};
rateDate = "";
fetchedAt = "";
updateSourcePresentation();
renderRates();
await writeState("baseCurrency",DEFAULT_BASE);
await writeState("amount",DEFAULT_AMOUNT);
await writeState("targetCurrencies",targetCurrencies);
await refreshRates();
}
function configureAutoRefresh(){
if(autoRefreshTimer !== null){
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
autoRefreshTimer = setInterval(() => {
void refreshRates();
},AUTO_REFRESH_MS);
}
amountInput.addEventListener("input",() => {
void writeState("amount",amountInput.value);
renderRates();
});
sourceSelect.addEventListener("change",async () => {
updateSourcePresentation();
rates = {};
rateDate = "";
fetchedAt = "";
await writeState("baseCurrency",sourceSelect.value);
await loadCache();
await refreshRates();
});
resetButton.addEventListener("click",() => {
void resetWidget();
});
refreshButton.addEventListener("click",() => {
void refreshRates();
});
retryButton.addEventListener("click",() => {
void refreshRates();
});
window.addEventListener("beforeunload",() => {
if(autoRefreshTimer !== null){
clearInterval(autoRefreshTimer);
}
if(toastTimer !== null){
clearTimeout(toastTimer);
}
});
async function initialize(){
populateSourceOptions();
const savedBase = String(await readState("baseCurrency",DEFAULT_BASE) || DEFAULT_BASE).toUpperCase();
const savedAmount = await readState("amount",DEFAULT_AMOUNT);
const savedTargets = await readState("targetCurrencies",DEFAULT_TARGETS);
sourceSelect.value = isSupportedCurrency(savedBase) ? savedBase : DEFAULT_BASE;
amountInput.value = String(savedAmount ?? DEFAULT_AMOUNT);
targetCurrencies = normalizeTargets(savedTargets);
updateSourcePresentation();
renderRates();
configureAutoRefresh();
await loadCache();
await refreshRates();
}
void initialize().catch(error => {
setLoading(false);
renderRates();
showError("组件初始化失败",error);
});
</script>
</body>
</html>