Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beff12a90e | ||
|
|
63761e763c | ||
|
|
1fecb161a9 |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kimiswitch",
|
"name": "kimiswitch",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.7",
|
"version": "0.6.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+63986
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.6.7"
|
version = "0.6.8"
|
||||||
description = "Kimi Switch - model config manager"
|
description = "Kimi Switch - model config manager"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -530,6 +530,15 @@ fn models_dev_cost_index() -> &'static HashMap<String, ModelsDevCost> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Warm the compiled-in models.dev price index off the first dashboard open:
|
||||||
|
/// parsing the ~1.3 MB snapshot costs tens of milliseconds, so build it on a
|
||||||
|
/// background thread at startup instead of lazily on the first get_summary.
|
||||||
|
pub fn warm_price_index() {
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
let _ = models_dev_cost_index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Official providers take precedence over resellers when the same model id
|
/// Official providers take precedence over resellers when the same model id
|
||||||
/// ships under multiple providers and no provider prefix disambiguates.
|
/// ships under multiple providers and no provider prefix disambiguates.
|
||||||
const OFFICIAL_PROVIDERS: &[&str] = &[
|
const OFFICIAL_PROVIDERS: &[&str] = &[
|
||||||
@@ -743,6 +752,45 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scan cache
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Short-TTL cache for the expensive `scan_usage` walk (reads every
|
||||||
|
/// wire.jsonl under the sessions root and prices every record). Tab switches
|
||||||
|
/// call get_summary repeatedly; re-walking the whole tree on every switch is
|
||||||
|
/// the main dashboard lag. Keyed by home, expires after 8s; `refresh=true`
|
||||||
|
/// bypasses it.
|
||||||
|
struct SummaryCacheEntry {
|
||||||
|
home: String,
|
||||||
|
scanned_at: std::time::Instant,
|
||||||
|
records: Vec<UsageRecord>,
|
||||||
|
meta: ScanMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
static SUMMARY_CACHE: Mutex<Option<SummaryCacheEntry>> = Mutex::new(None);
|
||||||
|
const SUMMARY_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(8);
|
||||||
|
|
||||||
|
fn scan_usage_cached(home: &Path, refresh: bool) -> (Vec<UsageRecord>, ScanMeta) {
|
||||||
|
let home_s = home.to_string_lossy().to_string();
|
||||||
|
let mut cache = SUMMARY_CACHE.lock().unwrap();
|
||||||
|
let hit = cache
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|e| !refresh && e.home == home_s && e.scanned_at.elapsed() < SUMMARY_CACHE_TTL);
|
||||||
|
if hit {
|
||||||
|
let e = cache.as_ref().unwrap();
|
||||||
|
return (e.records.clone(), e.meta.clone());
|
||||||
|
}
|
||||||
|
let (records, meta) = scan_usage(home);
|
||||||
|
*cache = Some(SummaryCacheEntry {
|
||||||
|
home: home_s,
|
||||||
|
scanned_at: std::time::Instant::now(),
|
||||||
|
records: records.clone(),
|
||||||
|
meta: meta.clone(),
|
||||||
|
});
|
||||||
|
(records, meta)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Aggregate
|
// Aggregate
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1489,13 +1537,13 @@ pub fn get_prices() -> PricesResult {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh: Option<bool>) -> SummaryResult {
|
pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh: Option<bool>) -> SummaryResult {
|
||||||
let _refresh = refresh.unwrap_or(false);
|
let refresh = refresh.unwrap_or(false);
|
||||||
let home = resolve_kimi_home(home_override);
|
let home = resolve_kimi_home(home_override);
|
||||||
let now_ms = std::time::SystemTime::now()
|
let now_ms = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
|
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
|
||||||
let r = range.unwrap_or_else(|| "30d".into());
|
let r = range.unwrap_or_else(|| "30d".into());
|
||||||
|
|
||||||
let (records, meta) = scan_usage(&home);
|
let (records, meta) = scan_usage_cached(&home, refresh);
|
||||||
let stats = aggregate(&records, &r, now_ms);
|
let stats = aggregate(&records, &r, now_ms);
|
||||||
let all_stats = aggregate(&records, "all", now_ms);
|
let all_stats = aggregate(&records, "all", now_ms);
|
||||||
let heatmap = build_heatmap(&records, now_ms);
|
let heatmap = build_heatmap(&records, now_ms);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
println!("[Tauri] Setup started");
|
println!("[Tauri] Setup started");
|
||||||
|
// Build the models.dev price index off the first dashboard query.
|
||||||
|
dashboard::warm_price_index();
|
||||||
let window = app.get_webview_window("main").unwrap();
|
let window = app.get_webview_window("main").unwrap();
|
||||||
println!("[Tauri] Window label: {}", window.label());
|
println!("[Tauri] Window label: {}", window.label());
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Kimi Switch",
|
"productName": "Kimi Switch",
|
||||||
"version": "0.6.7",
|
"version": "0.6.8",
|
||||||
"identifier": "com.kimiswitch.app",
|
"identifier": "com.kimiswitch.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -26,7 +26,10 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["msi", "nsis"],
|
"targets": [
|
||||||
|
"msi",
|
||||||
|
"nsis"
|
||||||
|
],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
+10
-2
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { useConfig } from "./hooks/useConfig";
|
import { useConfig } from "./hooks/useConfig";
|
||||||
@@ -12,7 +12,7 @@ import { UsageConfigModal } from "./components/UsageConfigModal";
|
|||||||
import { PresetPickerModal } from "./components/PresetPickerModal";
|
import { PresetPickerModal } from "./components/PresetPickerModal";
|
||||||
import { useTranslation } from "./i18n";
|
import { useTranslation } from "./i18n";
|
||||||
import { getDefaultMaxContextSize } from "./lib/model-defaults";
|
import { getDefaultMaxContextSize } from "./lib/model-defaults";
|
||||||
import { getModelRef } from "./lib/models-dev";
|
import { getModelRef, modelsDevReady, onModelsDevReady } from "./lib/models-dev";
|
||||||
import {
|
import {
|
||||||
presetToProviderAndModels,
|
presetToProviderAndModels,
|
||||||
type ProviderPreset,
|
type ProviderPreset,
|
||||||
@@ -121,6 +121,14 @@ export default function App() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [loading, config, agent]);
|
}, [loading, config, agent]);
|
||||||
|
|
||||||
|
// Load the models.dev index in the background (not in the main bundle)
|
||||||
|
// and re-render once it lands so context/capability columns fill in.
|
||||||
|
const [, forceModelsDevReady] = useReducer((x: number) => x + 1, 0);
|
||||||
|
useEffect(() => {
|
||||||
|
onModelsDevReady(forceModelsDevReady);
|
||||||
|
void modelsDevReady();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const providers = useMemo(
|
const providers = useMemo(
|
||||||
() => (config ? Object.values(config.providers) : []),
|
() => (config ? Object.values(config.providers) : []),
|
||||||
[config]
|
[config]
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function DashboardPage() {
|
|||||||
<div className="text-red-400 mb-2">{t("dashLoadFailed")}</div>
|
<div className="text-red-400 mb-2">{t("dashLoadFailed")}</div>
|
||||||
<div className="text-sm text-content-muted">{error}</div>
|
<div className="text-sm text-content-muted">{error}</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => refresh()}
|
onClick={() => refresh(true)}
|
||||||
className="mt-4 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm"
|
className="mt-4 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm"
|
||||||
>
|
>
|
||||||
{t("retry")}
|
{t("retry")}
|
||||||
@@ -145,7 +145,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => refresh()}
|
onClick={() => refresh(true)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 text-content-muted hover:text-content-primary disabled:opacity-50"
|
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 text-content-muted hover:text-content-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ export function useDashboard() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async (force = false) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await invoke<SummaryResult>("get_summary", { range });
|
const result = await invoke<SummaryResult>("get_summary", {
|
||||||
|
range,
|
||||||
|
refresh: force,
|
||||||
|
});
|
||||||
setData(result);
|
setData(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
+57
-15
@@ -1,9 +1,15 @@
|
|||||||
import snapshot from "./models-dev.json";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact per-model reference data from models.dev
|
* Compact per-model reference data from models.dev
|
||||||
* (see scripts/fetch-models-dev.mjs).
|
* (see scripts/fetch-models-dev.mjs).
|
||||||
|
*
|
||||||
|
* The snapshot (~1.3 MB JSON) is NOT bundled into the main chunk anymore:
|
||||||
|
* parsing a 1.3 MB JSON literal at startup blocks first paint. It is served
|
||||||
|
* as a static asset (`/models-dev.json`) and loaded once in the background.
|
||||||
|
* `getModelRef` stays synchronous and returns undefined until the index is
|
||||||
|
* ready — callers already fall back to defaults — and `modelsDevReady()`
|
||||||
|
* lets the app re-render once the index arrives.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ModelCost {
|
export interface ModelCost {
|
||||||
input?: number;
|
input?: number;
|
||||||
output?: number;
|
output?: number;
|
||||||
@@ -23,22 +29,58 @@ export interface ModelRef {
|
|||||||
cost?: ModelCost;
|
cost?: ModelCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = snapshot as Record<string, unknown>;
|
let byLowerKey: Record<string, ModelRef> | null = null;
|
||||||
|
let readyListeners: Array<() => void> = [];
|
||||||
|
|
||||||
// Drop the "last_updated" metadata key; the rest is "<provider>/<model>".
|
function buildIndex(raw: Record<string, unknown>) {
|
||||||
const data: Record<string, ModelRef> = {};
|
const data: Record<string, ModelRef> = {};
|
||||||
for (const [key, value] of Object.entries(raw)) {
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
if (key !== "last_updated" && value && typeof value === "object") {
|
if (key !== "last_updated" && value && typeof value === "object") {
|
||||||
data[key] = value as ModelRef;
|
data[key] = value as ModelRef;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// models.dev keys are "<lab>/<model>" and may be mixed-case (e.g.
|
||||||
|
// "minimax/MiniMax-M3"), while provider APIs return bare model ids
|
||||||
|
// (e.g. "kimi-k2.5"). Index lowercase for tolerant matching.
|
||||||
|
const lower: Record<string, ModelRef> = {};
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
lower[key.toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
return lower;
|
||||||
}
|
}
|
||||||
|
|
||||||
// models.dev keys are "<lab>/<model>" and may be mixed-case (e.g.
|
let loadPromise: Promise<void> | null = null;
|
||||||
// "minimax/MiniMax-M3"), while provider APIs return bare model ids
|
|
||||||
// (e.g. "kimi-k2.5"). Index lowercase for tolerant matching.
|
export function modelsDevReady(): Promise<void> {
|
||||||
const byLowerKey: Record<string, ModelRef> = {};
|
if (!loadPromise) {
|
||||||
for (const [key, value] of Object.entries(data)) {
|
loadPromise = fetch(`${import.meta.env.BASE_URL}models-dev.json`)
|
||||||
byLowerKey[key.toLowerCase()] = value;
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`models-dev.json HTTP ${r.status}`);
|
||||||
|
return r.json() as Promise<Record<string, unknown>>;
|
||||||
|
})
|
||||||
|
.then((raw) => {
|
||||||
|
byLowerKey = buildIndex(raw);
|
||||||
|
const listeners = readyListeners;
|
||||||
|
readyListeners = [];
|
||||||
|
for (const cb of listeners) cb();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// A failed load is permanent for this session: fall back to defaults.
|
||||||
|
byLowerKey = {};
|
||||||
|
loadPromise = null; // allow one retry next time
|
||||||
|
console.warn("models-dev.json load failed:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a callback invoked once the models.dev index is ready. */
|
||||||
|
export function onModelsDevReady(cb: () => void): void {
|
||||||
|
if (byLowerKey) {
|
||||||
|
cb();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
readyListeners.push(cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +91,7 @@ for (const [key, value] of Object.entries(data)) {
|
|||||||
*/
|
*/
|
||||||
export function getModelRef(modelId: string): ModelRef | undefined {
|
export function getModelRef(modelId: string): ModelRef | undefined {
|
||||||
const id = modelId.trim().toLowerCase();
|
const id = modelId.trim().toLowerCase();
|
||||||
if (!id) return undefined;
|
if (!id || !byLowerKey) return undefined;
|
||||||
const exact = byLowerKey[id];
|
const exact = byLowerKey[id];
|
||||||
if (exact) return exact;
|
if (exact) return exact;
|
||||||
const suffix = `/${id}`;
|
const suffix = `/${id}`;
|
||||||
|
|||||||
@@ -241,9 +241,9 @@ const zh = {
|
|||||||
githubBtn: "GitHub 下载",
|
githubBtn: "GitHub 下载",
|
||||||
mirrorBtn: "镜像下载",
|
mirrorBtn: "镜像下载",
|
||||||
items: [
|
items: [
|
||||||
{ id: "windows", name: "Windows", ready: true, note: ".msi 与 .exe 安装包,系统托盘常驻" },
|
{ id: "windows", name: "Windows", ready: true, note: ".msi 安装包,系统托盘常驻" },
|
||||||
{ id: "macos", name: "macOS", ready: false, note: "GitHub Actions 已能构建 .dmg,待官方适配" },
|
{ id: "macos", name: "macOS", ready: true, note: ".dmg 安装包(Apple Silicon),未签名需右键打开" },
|
||||||
{ id: "linux", name: "Linux", ready: false, note: ".AppImage / .deb / .rpm 构建中" },
|
{ id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm 三格式" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
cta: {
|
cta: {
|
||||||
@@ -505,9 +505,9 @@ const en: Dict = {
|
|||||||
githubBtn: "Download from GitHub",
|
githubBtn: "Download from GitHub",
|
||||||
mirrorBtn: "China mirror",
|
mirrorBtn: "China mirror",
|
||||||
items: [
|
items: [
|
||||||
{ id: "windows", name: "Windows", ready: true, note: ".msi and .exe installers, lives in the system tray" },
|
{ id: "windows", name: "Windows", ready: true, note: ".msi installer, lives in the system tray" },
|
||||||
{ id: "macos", name: "macOS", ready: false, note: "GitHub Actions already builds .dmg, official support pending" },
|
{ id: "macos", name: "macOS", ready: true, note: ".dmg installer (Apple Silicon), unsigned - right-click to open" },
|
||||||
{ id: "linux", name: "Linux", ready: false, note: ".AppImage / .deb / .rpm builds in progress" },
|
{ id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
cta: {
|
cta: {
|
||||||
|
|||||||
@@ -8,8 +8,27 @@ const icons: Record<string, Icon> = {
|
|||||||
linux: LinuxLogo,
|
linux: LinuxLogo,
|
||||||
};
|
};
|
||||||
|
|
||||||
const GITHUB_RELEASES = "https://github.com/billowliu2/KimiSwitch/releases";
|
const VERSION = "0.6.8";
|
||||||
|
const GITHUB = "https://github.com/billowliu2/KimiSwitch";
|
||||||
const MIRROR_RELEASES = "https://git.codingplan.site/admin/KimiCodeSwitch/releases";
|
const MIRROR_RELEASES = "https://git.codingplan.site/admin/KimiCodeSwitch/releases";
|
||||||
|
const dl = (file: string) => `${GITHUB}/releases/download/v${VERSION}/${file}`;
|
||||||
|
|
||||||
|
/** Per-platform download assets for the current release (names match the CI
|
||||||
|
* release workflow, e.g. Kimi.Switch_0.6.8_x64_en-US.msi). */
|
||||||
|
const assets: Record<string, { label: string; href: string }[]> = {
|
||||||
|
windows: [
|
||||||
|
{ label: "MSI", href: dl("Kimi.Switch_0.6.8_x64_en-US.msi") },
|
||||||
|
{ label: "镜像", href: MIRROR_RELEASES },
|
||||||
|
],
|
||||||
|
macos: [
|
||||||
|
{ label: "Apple Silicon (.dmg)", href: dl("Kimi.Switch_0.6.8_aarch64.dmg") },
|
||||||
|
],
|
||||||
|
linux: [
|
||||||
|
{ label: ".deb", href: dl("Kimi.Switch_0.6.8_amd64.deb") },
|
||||||
|
{ label: ".AppImage", href: dl("Kimi.Switch_0.6.8_amd64.AppImage") },
|
||||||
|
{ label: ".rpm", href: dl("Kimi.Switch-0.6.8-1.x86_64.rpm") },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export default function Download() {
|
export default function Download() {
|
||||||
const { t } = useLang();
|
const { t } = useLang();
|
||||||
@@ -22,44 +41,32 @@ export default function Download() {
|
|||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
{t.download.items.map((p) => {
|
{t.download.items.map((p) => {
|
||||||
const Icon = icons[p.id] ?? WindowsLogo;
|
const Icon = icons[p.id] ?? WindowsLogo;
|
||||||
|
const links = assets[p.id] ?? [];
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className="card flex flex-col">
|
<div key={p.id} className="card flex flex-col">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-primary/25 bg-primary/10 text-primary">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-primary/25 bg-primary/10 text-primary">
|
||||||
<Icon className="h-5 w-5" weight="duotone" />
|
<Icon className="h-5 w-5" weight="duotone" />
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span className="rounded-full border border-primary/25 bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
|
||||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
{t.download.ready}
|
||||||
p.ready
|
|
||||||
? "bg-primary/10 text-primary border border-primary/25"
|
|
||||||
: "bg-muted text-muted-foreground border border-border"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p.ready ? t.download.ready : t.download.wip}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mb-2 text-lg font-semibold">{p.name}</h3>
|
<h3 className="mb-2 text-lg font-semibold">{p.name}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{p.note}</p>
|
<p className="text-sm text-muted-foreground">{p.note}</p>
|
||||||
{p.ready && (
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
{links.map((l) => (
|
||||||
<a
|
<a
|
||||||
href={GITHUB_RELEASES}
|
key={l.label}
|
||||||
|
href={l.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="btn-primary text-xs"
|
className={l.label === "镜像" ? "btn-ghost text-xs" : "btn-primary text-xs"}
|
||||||
>
|
>
|
||||||
{t.download.githubBtn}
|
{l.label}
|
||||||
</a>
|
</a>
|
||||||
<a
|
))}
|
||||||
href={MIRROR_RELEASES}
|
</div>
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="btn-ghost text-xs"
|
|
||||||
>
|
|
||||||
{t.download.mirrorBtn}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in new issue
Block a user