feat(node): add stable/test release channels for install and upgrade (#175)

## Summary
- classify release tags by channel: pure numeric tags are treated as
stable, while tags containing alpha/beta/rc (or other non-numeric
formats) are treated as test releases
- add channel-aware backend APIs for install/upgrade/release listing and
pin install commands to the selected tag via `VERSION=<tag>`
- update node page UI and API clients to let users pick stable vs test
channel for install and upgrade workflows

## Verification
- `go test ./internal/http/handler/...`
- `npm run build`
This commit is contained in:
sagit
2026-02-21 13:23:20 +08:00
committed by GitHub
6 changed files with 347 additions and 111 deletions
+21 -4
View File
@@ -355,11 +355,28 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求失败")) response.WriteJSON(w, response.ErrDefault("请求失败"))
return return
} }
id := idFromBody(r, w)
if id <= 0 { var req struct {
ID int64 `json:"id"`
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return return
} }
secret, err := h.repo.GetNodeSecret(id) if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
version, err := resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
secret, err := h.repo.GetNodeSecret(req.ID)
if err != nil { if err != nil {
response.WriteJSON(w, response.ErrDefault("节点不存在")) response.WriteJSON(w, response.ErrDefault("节点不存在"))
return return
@@ -373,7 +390,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
} }
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret) cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
response.WriteJSON(w, response.OK(cmd)) response.WriteJSON(w, response.OK(cmd))
} }
+130 -79
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -19,8 +20,104 @@ const (
githubHTMLBase = "https://github.com" githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute upgradeTimeout = 5 * time.Minute
batchWorkers = 5 batchWorkers = 5
releaseChannelStable = "stable"
releaseChannelDev = "dev"
) )
var (
stableVersionPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`)
testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`)
)
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
func normalizeReleaseChannel(channel string) string {
switch strings.ToLower(strings.TrimSpace(channel)) {
case releaseChannelDev:
return releaseChannelDev
default:
return releaseChannelStable
}
}
func releaseChannelFromTag(tag string) string {
normalized := strings.ToLower(strings.TrimSpace(tag))
if normalized == "" {
return releaseChannelDev
}
if testKeywordPattern.MatchString(normalized) {
return releaseChannelDev
}
if stableVersionPattern.MatchString(normalized) {
return releaseChannelStable
}
return releaseChannelDev
}
func releaseChannelLabel(channel string) string {
if normalizeReleaseChannel(channel) == releaseChannelDev {
return "测试版"
}
return "正式版"
}
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
if perPage <= 0 {
perPage = 20
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(fmt.Sprintf("%s/repos/%s/releases?per_page=%d", githubAPIBase, githubRepo, perPage))
if err != nil {
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var releases []githubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
}
return releases, nil
}
func resolveLatestReleaseByChannel(channel string) (string, error) {
normalizedChannel := normalizeReleaseChannel(channel)
releases, err := fetchGitHubReleases(50)
if err != nil {
return "", err
}
for _, r := range releases {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
if releaseChannelFromTag(tag) == normalizedChannel {
return tag, nil
}
}
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
}
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) { func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败")) response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -30,6 +127,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Version string `json:"version"` Version string `json:"version"`
Channel string `json:"channel"`
} }
if err := decodeJSON(r.Body, &req); err != nil { if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误")) response.WriteJSON(w, response.ErrDefault("请求参数错误"))
@@ -40,12 +138,13 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
return return
} }
channel := normalizeReleaseChannel(req.Channel)
version := strings.TrimSpace(req.Version) version := strings.TrimSpace(req.Version)
if version == "" { if version == "" {
var err error var err error
version, err = resolveLatestRelease() version, err = resolveLatestReleaseByChannel(channel)
if err != nil { if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err))) response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return return
} }
} }
@@ -75,61 +174,11 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
} }
func resolveLatestRelease() (string, error) { func resolveLatestRelease() (string, error) {
client := &http.Client{ return resolveLatestReleaseByChannel(releaseChannelStable)
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 10 * time.Second,
}
resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest")
if err != nil {
return "", fmt.Errorf("请求GitHub失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
return resolveLatestReleaseAPI()
}
location := resp.Header.Get("Location")
if location == "" {
return resolveLatestReleaseAPI()
}
parts := strings.Split(location, "/")
tag := parts[len(parts)-1]
if tag == "" || tag == "latest" {
return resolveLatestReleaseAPI()
}
return tag, nil
} }
func resolveLatestReleaseAPI() (string, error) { func resolveLatestReleaseAPI() (string, error) {
client := &http.Client{Timeout: 10 * time.Second} return resolveLatestReleaseByChannel(releaseChannelStable)
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases/latest")
if err != nil {
return "", fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var release struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
}
if strings.TrimSpace(release.TagName) == "" {
return "", fmt.Errorf("无法从GitHub获取最新版本号")
}
return release.TagName, nil
} }
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) { func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
@@ -141,6 +190,7 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
IDs []int64 `json:"ids"` IDs []int64 `json:"ids"`
Version string `json:"version"` Version string `json:"version"`
Channel string `json:"channel"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误")) response.WriteJSON(w, response.ErrDefault("请求参数错误"))
@@ -151,12 +201,13 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
return return
} }
channel := normalizeReleaseChannel(req.Channel)
version := strings.TrimSpace(req.Version) version := strings.TrimSpace(req.Version)
if version == "" { if version == "" {
var err error var err error
version, err = resolveLatestRelease() version, err = resolveLatestReleaseByChannel(channel)
if err != nil { if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err))) response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return return
} }
} }
@@ -212,37 +263,28 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
return return
} }
client := &http.Client{Timeout: 15 * time.Second} var req struct {
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases?per_page=20") Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
releases, err := fetchGitHubReleases(50)
if err != nil { if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err))) response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
return return
} }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: GitHub API返回 %d: %s", resp.StatusCode, string(body))))
return
}
var releases []struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err)))
return
}
type releaseItem struct { type releaseItem struct {
Version string `json:"version"` Version string `json:"version"`
Name string `json:"name"` Name string `json:"name"`
PublishedAt string `json:"publishedAt"` PublishedAt string `json:"publishedAt"`
Prerelease bool `json:"prerelease"` Prerelease bool `json:"prerelease"`
Channel string `json:"channel"`
} }
items := make([]releaseItem, 0, len(releases)) items := make([]releaseItem, 0, len(releases))
@@ -250,11 +292,20 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
if r.Draft { if r.Draft {
continue continue
} }
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
itemChannel := releaseChannelFromTag(tag)
if itemChannel != channel {
continue
}
items = append(items, releaseItem{ items = append(items, releaseItem{
Version: r.TagName, Version: tag,
Name: r.Name, Name: r.Name,
PublishedAt: r.PublishedAt, PublishedAt: r.PublishedAt,
Prerelease: r.Prerelease, Prerelease: itemChannel == releaseChannelDev,
Channel: itemChannel,
}) })
} }
@@ -0,0 +1,46 @@
package handler
import "testing"
func TestReleaseChannelFromTag(t *testing.T) {
tests := []struct {
name string
tag string
expects string
}{
{name: "stable semantic version", tag: "2.1.4", expects: releaseChannelStable},
{name: "v prefix should be dev", tag: "v2.1.4", expects: releaseChannelDev},
{name: "rc release", tag: "2.1.4-rc2", expects: releaseChannelDev},
{name: "beta release", tag: "2.1.4-beta.1", expects: releaseChannelDev},
{name: "alpha release", tag: "2.1.4-alpha", expects: releaseChannelDev},
{name: "non numeric tag", tag: "nightly", expects: releaseChannelDev},
{name: "empty tag", tag: "", expects: releaseChannelDev},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := releaseChannelFromTag(tc.tag); got != tc.expects {
t.Fatalf("releaseChannelFromTag(%q) = %q, want %q", tc.tag, got, tc.expects)
}
})
}
}
func TestNormalizeReleaseChannel(t *testing.T) {
tests := []struct {
input string
expects string
}{
{input: "", expects: releaseChannelStable},
{input: "stable", expects: releaseChannelStable},
{input: "dev", expects: releaseChannelDev},
{input: "DEV", expects: releaseChannelDev},
{input: "preview", expects: releaseChannelStable},
}
for _, tc := range tests {
if got := normalizeReleaseChannel(tc.input); got != tc.expects {
t.Fatalf("normalizeReleaseChannel(%q) = %q, want %q", tc.input, got, tc.expects)
}
}
}
+20 -8
View File
@@ -31,6 +31,8 @@ import axios from "axios";
import Network from "./network"; import Network from "./network";
export type ReleaseChannel = "stable" | "dev";
// 登陆相关接口 // 登陆相关接口
export interface LoginData { export interface LoginData {
username: string; username: string;
@@ -66,8 +68,10 @@ export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
export const updateNode = (data: NodeMutationPayload) => export const updateNode = (data: NodeMutationPayload) =>
Network.post("/node/update", data); Network.post("/node/update", data);
export const deleteNode = (id: number) => Network.post("/node/delete", { id }); export const deleteNode = (id: number) => Network.post("/node/delete", { id });
export const getNodeInstallCommand = (id: number) => export const getNodeInstallCommand = (
Network.post<string>("/node/install", { id }); id: number,
channel: ReleaseChannel = "stable",
) => Network.post<string>("/node/install", { id, channel });
export const updateNodeOrder = (data: { export const updateNodeOrder = (data: {
nodes: Array<{ id: number; inx: number }>; nodes: Array<{ id: number; inx: number }>;
}) => Network.post("/node/update-order", data); }) => Network.post("/node/update-order", data);
@@ -77,20 +81,28 @@ export const checkNodeStatus = (nodeId?: number) => {
return Network.post("/node/check-status", params); return Network.post("/node/check-status", params);
}; };
export const upgradeNode = (id: number, version?: string) => export const upgradeNode = (
id: number,
version?: string,
channel: ReleaseChannel = "stable",
) =>
Network.post( Network.post(
"/node/upgrade", "/node/upgrade",
{ id, version: version || "" }, { id, version: version || "", channel },
{ timeout: 5 * 60 * 1000 }, { timeout: 5 * 60 * 1000 },
); );
export const batchUpgradeNodes = (ids: number[], version?: string) => export const batchUpgradeNodes = (
ids: number[],
version?: string,
channel: ReleaseChannel = "stable",
) =>
Network.post( Network.post(
"/node/batch-upgrade", "/node/batch-upgrade",
{ ids, version: version || "" }, { ids, version: version || "", channel },
{ timeout: 15 * 60 * 1000 }, { timeout: 15 * 60 * 1000 },
); );
export const getNodeReleases = () => export const getNodeReleases = (channel: ReleaseChannel = "stable") =>
Network.post<NodeReleaseApiItem[]>("/node/releases"); Network.post<NodeReleaseApiItem[]>("/node/releases", { channel });
export const rollbackNode = (id: number) => export const rollbackNode = (id: number) =>
Network.post("/node/rollback", { id }); Network.post("/node/rollback", { id });
+1
View File
@@ -172,6 +172,7 @@ export interface NodeReleaseApiItem {
name: string; name: string;
publishedAt: string; publishedAt: string;
prerelease: boolean; prerelease: boolean;
channel: "stable" | "dev";
} }
export interface UserPackageInfoApiData { export interface UserPackageInfoApiData {
+129 -20
View File
@@ -49,6 +49,7 @@ import {
batchUpgradeNodes, batchUpgradeNodes,
getNodeReleases, getNodeReleases,
rollbackNode, rollbackNode,
type ReleaseChannel,
} from "@/api"; } from "@/api";
import { PageEmptyState, PageLoadingState } from "@/components/page-state"; import { PageEmptyState, PageLoadingState } from "@/components/page-state";
import { import {
@@ -183,6 +184,10 @@ export default function NodePage() {
const [installCommandModal, setInstallCommandModal] = useState(false); const [installCommandModal, setInstallCommandModal] = useState(false);
const [installCommand, setInstallCommand] = useState(""); const [installCommand, setInstallCommand] = useState("");
const [currentNodeName, setCurrentNodeName] = useState(""); const [currentNodeName, setCurrentNodeName] = useState("");
const [installSelectorOpen, setInstallSelectorOpen] = useState(false);
const [installTargetNode, setInstallTargetNode] = useState<Node | null>(null);
const [installChannel, setInstallChannel] =
useState<ReleaseChannel>("stable");
// 升级相关状态 // 升级相关状态
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
@@ -198,9 +203,12 @@ export default function NodePage() {
name: string; name: string;
publishedAt: string; publishedAt: string;
prerelease: boolean; prerelease: boolean;
channel: ReleaseChannel;
}> }>
>([]); >([]);
const [releasesLoading, setReleasesLoading] = useState(false); const [releasesLoading, setReleasesLoading] = useState(false);
const [releaseChannel, setReleaseChannel] =
useState<ReleaseChannel>("stable");
const [selectedVersion, setSelectedVersion] = useState(""); const [selectedVersion, setSelectedVersion] = useState("");
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false); const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
const [upgradeProgress, setUpgradeProgress] = useState< const [upgradeProgress, setUpgradeProgress] = useState<
@@ -609,20 +617,31 @@ export default function NodePage() {
} }
}; };
const openInstallSelector = (node: Node) => {
setInstallTargetNode(node);
setInstallChannel("stable");
setInstallSelectorOpen(true);
};
// 复制安装命令 // 复制安装命令
const handleCopyInstallCommand = async (node: Node) => { const handleCopyInstallCommand = async (
node: Node,
channel: ReleaseChannel,
) => {
setNodeList((prev) => setNodeList((prev) =>
prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)), prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),
); );
try { try {
const res = await getNodeInstallCommand(node.id); const res = await getNodeInstallCommand(node.id, channel);
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
const copied = await tryCopyInstallCommand(res.data); const copied = await tryCopyInstallCommand(res.data);
if (copied) { if (copied) {
toast.success("安装命令已复制到剪贴板"); toast.success(
`${channel === "stable" ? "正式版" : "测试版"}安装命令已复制到剪贴板`,
);
} else { } else {
setInstallCommand(res.data); setInstallCommand(res.data);
setCurrentNodeName(node.name); setCurrentNodeName(node.name);
@@ -640,6 +659,13 @@ export default function NodePage() {
} }
}; };
const handleConfirmInstallCommand = async () => {
if (!installTargetNode) return;
setInstallSelectorOpen(false);
await handleCopyInstallCommand(installTargetNode, installChannel);
};
// 手动复制安装命令 // 手动复制安装命令
const handleManualCopy = async () => { const handleManualCopy = async () => {
try { try {
@@ -651,18 +677,10 @@ export default function NodePage() {
} }
}; };
// 打开版本选择弹窗 const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {
const openUpgradeModal = async (
target: "single" | "batch",
nodeId?: number,
) => {
setUpgradeTarget(target);
setUpgradeTargetNodeId(nodeId || null);
setSelectedVersion("");
setUpgradeModalOpen(true);
setReleasesLoading(true); setReleasesLoading(true);
try { try {
const res = await getNodeReleases(); const res = await getNodeReleases(channel);
if (res.code === 0 && Array.isArray(res.data)) { if (res.code === 0 && Array.isArray(res.data)) {
setReleases(res.data); setReleases(res.data);
@@ -674,6 +692,21 @@ export default function NodePage() {
} finally { } finally {
setReleasesLoading(false); setReleasesLoading(false);
} }
}, []);
// 打开版本选择弹窗
const openUpgradeModal = async (
target: "single" | "batch",
nodeId?: number,
) => {
const defaultChannel: ReleaseChannel = "stable";
setUpgradeTarget(target);
setUpgradeTargetNodeId(nodeId || null);
setReleaseChannel(defaultChannel);
setSelectedVersion("");
setUpgradeModalOpen(true);
await loadReleasesByChannel(defaultChannel);
}; };
// 确认升级(从版本弹窗) // 确认升级(从版本弹窗)
@@ -692,7 +725,11 @@ export default function NodePage() {
), ),
); );
try { try {
const res = await upgradeNode(upgradeTargetNodeId, version); const res = await upgradeNode(
upgradeTargetNodeId,
version,
releaseChannel,
);
if (res.code === 0) { if (res.code === 0) {
toast.success(`节点升级命令已发送,节点将自动重启`); toast.success(`节点升级命令已发送,节点将自动重启`);
@@ -712,7 +749,11 @@ export default function NodePage() {
setBatchUpgradeLoading(true); setBatchUpgradeLoading(true);
setUpgradeModalOpen(false); setUpgradeModalOpen(false);
try { try {
const res = await batchUpgradeNodes(Array.from(selectedIds), version); const res = await batchUpgradeNodes(
Array.from(selectedIds),
version,
releaseChannel,
);
if (res.code === 0) { if (res.code === 0) {
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`); toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
@@ -1367,7 +1408,7 @@ export default function NodePage() {
isLoading={node.copyLoading} isLoading={node.copyLoading}
size="sm" size="sm"
variant="flat" variant="flat"
onPress={() => handleCopyInstallCommand(node)} onPress={() => openInstallSelector(node)}
> >
安装 安装
</Button> </Button>
@@ -1811,6 +1852,55 @@ export default function NodePage() {
</ModalContent> </ModalContent>
</Modal> </Modal>
<Modal
backdrop="blur"
isOpen={installSelectorOpen}
placement="center"
size="md"
onOpenChange={setInstallSelectorOpen}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">
选择安装通道
{installTargetNode ? ` - ${installTargetNode.name}` : ""}
</h2>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<Select
label="版本通道"
selectedKeys={[installChannel]}
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as ReleaseChannel;
setInstallChannel(selected || "stable");
}}
>
<SelectItem key="stable" textValue="stable">
正式版(纯数字版本,如 2.1.4)
</SelectItem>
<SelectItem key="dev" textValue="dev">
测试版(含 alpha / beta / rc)
</SelectItem>
</Select>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button color="primary" onPress={handleConfirmInstallCommand}>
生成命令
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 安装命令模态框 */} {/* 安装命令模态框 */}
<Modal <Modal
backdrop="blur" backdrop="blur"
@@ -1891,9 +1981,28 @@ export default function NodePage() {
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<Select
label="版本通道"
selectedKeys={[releaseChannel]}
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as ReleaseChannel) || "stable";
setReleaseChannel(selected);
setSelectedVersion("");
void loadReleasesByChannel(selected);
}}
>
<SelectItem key="stable" textValue="stable">
正式版(纯数字版本,如 2.1.4)
</SelectItem>
<SelectItem key="dev" textValue="dev">
测试版(含 alpha / beta / rc)
</SelectItem>
</Select>
<Select <Select
label="选择版本" label="选择版本"
placeholder="留空则使用最新版本" placeholder="留空则使用当前通道最新版本"
selectedKeys={selectedVersion ? [selectedVersion] : []} selectedKeys={selectedVersion ? [selectedVersion] : []}
onSelectionChange={(keys) => { onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as string; const selected = Array.from(keys)[0] as string;
@@ -1909,14 +2018,14 @@ export default function NodePage() {
{r.publishedAt {r.publishedAt
? new Date(r.publishedAt).toLocaleDateString() ? new Date(r.publishedAt).toLocaleDateString()
: ""} : ""}
{r.prerelease && ( {r.channel === "dev" && (
<Chip <Chip
className="ml-1" className="ml-1"
color="warning" color="warning"
size="sm" size="sm"
variant="flat" variant="flat"
> >
预览 测试
</Chip> </Chip>
)} )}
</span> </span>
@@ -1927,7 +2036,7 @@ export default function NodePage() {
<p className="text-sm text-default-500"> <p className="text-sm text-default-500">
{selectedVersion {selectedVersion
? `将升级到版本 ${selectedVersion}` ? `将升级到版本 ${selectedVersion}`
: "未选择版本,将自动使用最新稳定版"} : `未选择版本,将自动使用最新${releaseChannel === "stable" ? "正式" : "测试"}版`}
</p> </p>
</div> </div>
)} )}