授权体系落地: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

+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>
);
}