授权体系落地:30 天试用 + LIC- 在线 / LICOFF- 离线激活 + /pricing 定价页
借鉴 Yaak 授权模型 + 复用自家 OpsXShell 生产级授权协议(opsx-licence 服务端): - Rust services/license:30 天试用自首次启动自动开始;LIC- 在线激活/周检/解绑 (双节点容灾 + 响应 Ed25519 签名校验 + 时钟回拨检测 + 30 天离线容忍); LICOFF- 离线证本地自验(产品码 opsxapi + 设备绑定 + ≤1 年期限) - 前端 src/license.ts + 活动栏授权徽章(试用 N 天/已授权/未授权)+ 设置→授权页(状态横幅/激活表单/解绑/定价链接带 ?intent= 追踪) - /pricing 独立定价页(三站同结构):30 天试用条款、在线/离线激活说明三卡、 年付↔终身切换、授权 FAQ、独立开发者声明;导航全部接入 - opsxDB 个人 ¥69/年·¥199 终身 / 团队 ¥69 / 私有化 ¥6000(与 API 定位错开) - GUI 实测:试用 30 天徽章渲染正常;浏览器态授权页正确显示仅桌面端提示
This commit is contained in:
1 parent
d7a9503042
commit
c304b9f251
19 files changed
+888
-7
No files matched your search
@@ -6,6 +6,8 @@ import { useWorkspaceLifecycle } from "./features/workspace/useWorkspaceLifecycl
|
||||
*/
|
||||
import { findNode } from "./core/index";
|
||||
import { useWorkspace } from "./workspaceStore";
|
||||
import { useLicense } from "./license";
|
||||
import { useEffect } from "react";
|
||||
import { ActivityRail } from "./components/ActivityRail";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { TestingView } from "./features/testing/TestingView";
|
||||
@@ -22,6 +24,8 @@ import { EmptyState } from "./features/workspace/EmptyState";
|
||||
|
||||
export function OpsxWorkspace() {
|
||||
useWorkspaceLifecycle();
|
||||
// 授权状态(桌面端):30 天试用倒计时 / 激活状态;浏览器环境自动跳过
|
||||
useEffect(() => { void useLicense.getState().load(); }, []);
|
||||
const inited = useWorkspace((s) => s.collections.length > 0 || s.environments.length > 0);
|
||||
const collections = useWorkspace((s) => s.collections);
|
||||
const activeTabId = useWorkspace((s) => s.activeTabId);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useWorkspace } from "../workspaceStore";
|
||||
import { useLicense } from "../license";
|
||||
import { openExternal } from "../openExternal";
|
||||
import { ContextMenu, cx, type MenuItem } from "./common";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
@@ -197,6 +198,8 @@ export function ActivityRail() {
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
|
||||
<LicenseBadge />
|
||||
|
||||
<button className="rail-item" title="设置" onClick={() => setDialog({ type: "settings" })}>
|
||||
<Icon name="settings" size={16} />
|
||||
<span className="text-[11px]">设置</span>
|
||||
@@ -245,3 +248,47 @@ export function ActivityRail() {
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** 授权徽章:试用倒计时 / 已授权 / 试用结束(仅桌面端显示;点击进设置→授权) */
|
||||
function LicenseBadge() {
|
||||
const status = useLicense((s) => s.status);
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
if (!status) return null; // 浏览器环境或未加载
|
||||
if (status.phase === "active") {
|
||||
const until = status.expiresAt
|
||||
? new Date(status.expiresAt * 1000).toLocaleDateString("zh-CN")
|
||||
: "永久";
|
||||
return (
|
||||
<button
|
||||
className="rail-item rail-license active"
|
||||
title={`已授权${status.planType ? ` · ${status.planType}` : ""} · 到期 ${until}(点击查看)`}
|
||||
onClick={() => setDialog({ type: "settings", tab: "license" })}
|
||||
>
|
||||
<Icon name="shield-check" size={16} />
|
||||
<span className="text-[10px]">已授权</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (status.phase === "trialing") {
|
||||
return (
|
||||
<button
|
||||
className="rail-item rail-license trial"
|
||||
title={`30 天全功能试用剩 ${status.daysLeft} 天 · 商用需授权(点击查看)`}
|
||||
onClick={() => setDialog({ type: "settings", tab: "license" })}
|
||||
>
|
||||
<Icon name="clock" size={16} />
|
||||
<span className="text-[10px]">试用 {status.daysLeft}天</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className="rail-item rail-license ended"
|
||||
title="试用已结束 · 个人非商业可继续使用,商用请授权(点击查看)"
|
||||
onClick={() => setDialog({ type: "settings", tab: "license" })}
|
||||
>
|
||||
<Icon name="shield-check" size={16} />
|
||||
<span className="text-[10px]">未授权</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { Icon, type IconName } from "../icons";
|
||||
import { KindIcon } from "../KindIcon";
|
||||
import { Logo } from "../Logo";
|
||||
import { WECHAT_GROUP_URL } from "../../links";
|
||||
import { useLicense } from "../../license";
|
||||
import { openExternal } from "../../openExternal";
|
||||
|
||||
const KIND_MENU: { kind: RequestKind | "folder"; icon: IconName; desc: string }[] = [
|
||||
@@ -380,7 +381,7 @@ export function EnvironmentsDialog() {
|
||||
|
||||
/* ---------------- 设置弹窗(左侧导航 + 分区) ---------------- */
|
||||
|
||||
type SettingsTab = "appearance" | "network" | "mock" | "mockdata" | "ai" | "data" | "about";
|
||||
type SettingsTab = "appearance" | "network" | "mock" | "mockdata" | "ai" | "data" | "license" | "about";
|
||||
|
||||
const SETTINGS_TABS: { id: SettingsTab; label: string; desc: string; icon: IconName }[] = [
|
||||
{ id: "appearance", label: "外观", desc: "主题与界面", icon: "palette" },
|
||||
@@ -388,6 +389,7 @@ const SETTINGS_TABS: { id: SettingsTab; label: string; desc: string; icon: IconN
|
||||
{ id: "mock", label: "Mock 服务", desc: "服务开关与访问地址", icon: "flask-conical" },
|
||||
{ id: "ai", label: "大模型", desc: "供应商与默认模型", icon: "bot" },
|
||||
{ id: "data", label: "数据管理", desc: "工作区与备份", icon: "database" },
|
||||
{ id: "license", label: "授权", desc: "试用与许可证", icon: "shield-check" },
|
||||
{ id: "about", label: "关于", desc: "版本与链接", icon: "info" },
|
||||
];
|
||||
|
||||
@@ -782,6 +784,7 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{active === "license" && <LicensePane />}
|
||||
{active === "about" && (
|
||||
<>
|
||||
<p className="ops-faint px-1 text-[11px]">
|
||||
@@ -825,3 +828,125 @@ export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 授权:试用状态 + 在线/离线激活 ---------------- */
|
||||
|
||||
function LicensePane() {
|
||||
const status = useLicense((s) => s.status);
|
||||
const busy = useLicense((s) => s.busy);
|
||||
const error = useLicense((s) => s.error);
|
||||
const activate = useLicense((s) => s.activate);
|
||||
const deactivate = useLicense((s) => s.deactivate);
|
||||
const load = useLicense((s) => s.load);
|
||||
const [key, setKey] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [ok, setOk] = useState(false);
|
||||
|
||||
const doActivate = async () => {
|
||||
if (!key.trim()) return;
|
||||
setOk(false);
|
||||
const done = await activate(key, email);
|
||||
if (done) {
|
||||
setKey("");
|
||||
setOk(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="ops-hint">
|
||||
<Icon name="info" size={14} />
|
||||
<span>授权状态仅在桌面端可用;网页预览环境不涉及许可证。</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const until = status.expiresAt ? new Date(status.expiresAt * 1000).toLocaleDateString("zh-CN") : "永久";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{status.phase === "active" ? (
|
||||
<div className="set-banner" style={{ borderColor: "var(--success)" }}>
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold" style={{ color: "var(--success)" }}>
|
||||
<Icon name="shield-check" size={15} /> 已授权
|
||||
</div>
|
||||
<div className="ops-faint mt-1 text-[12px] leading-6">
|
||||
{status.planType ? `计划:${status.planType} · ` : ""}
|
||||
{status.maxDevices ? `设备数:${status.maxDevices} · ` : ""}
|
||||
到期:{until}
|
||||
{status.email ? ` · ${status.email}` : ""}
|
||||
</div>
|
||||
<div className="ops-faint mt-1 text-[11px]">设备 ID:{status.deviceId}</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button className="ops-btn ops-btn-secondary !py-1 text-[12px]" disabled={busy} onClick={() => void deactivate()}>
|
||||
解绑本机
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-ghost !py-1 text-[12px]" onClick={() => void load()}>
|
||||
刷新状态
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : status.phase === "trialing" ? (
|
||||
<div className="set-banner" style={{ borderColor: "var(--accent)" }}>
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold" style={{ color: "var(--accent-strong)" }}>
|
||||
<Icon name="clock" size={15} /> 试用中 · 剩 {status.daysLeft} 天
|
||||
</div>
|
||||
<div className="ops-faint mt-1 text-[12px] leading-6">
|
||||
30 天全功能试用自首次启动自动开始。试用期结束后个人非商业用途可继续使用;工作中商用需购买授权。
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="set-banner" style={{ borderColor: "var(--danger)" }}>
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold ops-danger">
|
||||
<Icon name="shield-check" size={15} /> 试用已结束
|
||||
</div>
|
||||
<div className="ops-faint mt-1 text-[12px] leading-6">
|
||||
个人非商业用途可继续使用;在工作中商用请购买授权。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.phase !== "active" && (
|
||||
<div className="set-group">
|
||||
<div className="set-group-title">激活许可证</div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className="ops-input font-mono"
|
||||
placeholder="LIC- 在线授权码 或 LICOFF- 离线许可证"
|
||||
value={key}
|
||||
onChange={(e) => { setKey(e.target.value); setOk(false); }}
|
||||
onKeyDown={(e) => e.key === "Enter" && void doActivate()}
|
||||
/>
|
||||
<input
|
||||
className="ops-input"
|
||||
placeholder="邮箱(可选,用于授权关联)"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="ops-btn ops-btn-primary" disabled={busy || !key.trim()} onClick={() => void doActivate()}>
|
||||
{busy ? "激活中…" : "激活"}
|
||||
</button>
|
||||
<span className="ops-faint text-[11px]">
|
||||
LIC- 联网激活、可自助解绑;LICOFF- 完全离线激活(绑定本机)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ok && <div className="ops-success text-[12px]">激活成功,已授权 ✓</div>}
|
||||
{error && <div className="ops-danger text-[12px]">{error}</div>}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="ops-faint text-[11px]">定价与购买</span>
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary !py-1 text-[12px]"
|
||||
onClick={() => void openExternal(`/pricing?intent=app.license.purchase.${status.phase}`)}
|
||||
>
|
||||
<Icon name="external-link" size={13} /> 查看定价
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 授权状态(桌面端):30 天全功能试用 + LIC- 在线授权 / LICOFF- 离线许可证。
|
||||
* 状态来源为 Rust 侧 license 服务(device.id / trial.opsx / license.opsx),
|
||||
* 浏览器(无 Tauri)环境下保持 unknown,界面自动隐藏授权徽章。
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
|
||||
export type LicensePhase = "unknown" | "trialing" | "trial_ended" | "active";
|
||||
|
||||
export interface LicenseStatus {
|
||||
phase: Exclude<LicensePhase, "unknown">;
|
||||
daysLeft: number;
|
||||
deviceId: string;
|
||||
activated: boolean;
|
||||
expiresAt?: number | null;
|
||||
email?: string | null;
|
||||
planType?: string | null;
|
||||
maxDevices?: number | null;
|
||||
trialEnd: number;
|
||||
}
|
||||
|
||||
const LAST_VERIFY_KEY = "opsxapi.license.lastVerifyAt";
|
||||
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
||||
|
||||
function isDesktop(): boolean {
|
||||
return Boolean((window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__);
|
||||
}
|
||||
|
||||
async function invokeLicense<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return invoke<T>(command, args);
|
||||
}
|
||||
|
||||
interface LicenseStore {
|
||||
status: LicenseStatus | null;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
load: () => Promise<void>;
|
||||
activate: (key: string, email?: string) => Promise<boolean>;
|
||||
deactivate: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useLicense = create<LicenseStore>((set, get) => ({
|
||||
status: null,
|
||||
busy: false,
|
||||
error: null,
|
||||
|
||||
load: async () => {
|
||||
if (!isDesktop()) return;
|
||||
try {
|
||||
const status = await invokeLicense<LicenseStatus>("license_status");
|
||||
set({ status, error: null });
|
||||
// 已激活:每周静默联网验证一次(离线容忍 30 天由 Rust 侧判定)
|
||||
const last = Number(localStorage.getItem(LAST_VERIFY_KEY) ?? 0);
|
||||
if (status.phase === "active" && Date.now() - last > WEEK_MS) {
|
||||
try {
|
||||
const fresh = await invokeLicense<LicenseStatus>("license_verify");
|
||||
localStorage.setItem(LAST_VERIFY_KEY, String(Date.now()));
|
||||
set({ status: fresh });
|
||||
} catch {
|
||||
/* 网络不可达:容忍期内保留缓存 */
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
set({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
|
||||
activate: async (key, email) => {
|
||||
set({ busy: true, error: null });
|
||||
try {
|
||||
const status = await invokeLicense<LicenseStatus>("license_activate", {
|
||||
licenseKey: key.trim(),
|
||||
email: email?.trim() || null,
|
||||
});
|
||||
set({ status, busy: false });
|
||||
return true;
|
||||
} catch (e) {
|
||||
set({ busy: false, error: e instanceof Error ? e.message : String(e) });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
deactivate: async () => {
|
||||
set({ busy: true, error: null });
|
||||
try {
|
||||
const status = await invokeLicense<LicenseStatus>("license_deactivate");
|
||||
set({ status, busy: false });
|
||||
} catch (e) {
|
||||
set({ busy: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export function licensePhase(): LicensePhase {
|
||||
return useLicense.getState().status?.phase ?? "unknown";
|
||||
}
|
||||
@@ -521,6 +521,13 @@
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.set-banner {
|
||||
border: 1px solid var(--border);
|
||||
border-left-width: 3px;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.set-group-title {
|
||||
@apply px-4 pt-3.5 pb-1.5 text-[12px] font-semibold tracking-[0.02em];
|
||||
color: var(--text-dim);
|
||||
@@ -706,6 +713,9 @@
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--panel);
|
||||
}
|
||||
.rail-license.trial { color: var(--accent-strong); }
|
||||
.rail-license.active { color: var(--success); }
|
||||
.rail-license.ended { color: var(--danger); }
|
||||
.rail-ws-badge.local { background: #22c55e; }
|
||||
.rail-ws-badge.team { background: #3b82f6; }
|
||||
.rail-ws-badge.cloud { background: #60a5fa; }
|
||||
|
||||
@@ -97,7 +97,7 @@ export type Dialog =
|
||||
| { type: "import" }
|
||||
| { type: "export"; collectionId: string }
|
||||
| { type: "environments" }
|
||||
| { type: "settings"; tab?: "appearance" | "network" | "mock" | "mockdata" | "ai" | "data" | "about" }
|
||||
| { type: "settings"; tab?: "appearance" | "network" | "mock" | "mockdata" | "ai" | "data" | "license" | "about" }
|
||||
| { type: "rename"; id: string }
|
||||
| { type: "confirm"; title: string; message: string; action: () => void }
|
||||
| { type: "auth" }
|
||||
|
||||
Reference in new issue
Block a user