授权体系落地: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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user