汇率一栏

经验创意 · 151 次浏览
困困君 创建于 2026-07-18 20:27

<!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>

困困君 最后更新于 2026/7/18

回复内容
暂无回复
回复主贴