授权体系落地: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:
logan committed 2026-10-16 03:27:45 +08:00
1 parent d7a9503042
commit c304b9f251
19 files changed
+888 -7

No files matched your search

+47
View File
@@ -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>
);
}
+126 -1
View File
@@ -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>
);
}