From 6649680296007bc774e9c71c6c1483eb29486ef5 Mon Sep 17 00:00:00 2001 From: xuejiahao Date: Tue, 21 Oct 2025 15:19:43 +0800 Subject: [PATCH] add license check for desktop/mobile --- README.md | 3 +- src/components/Config.vue | 65 +++++++ src/css/license.css | 297 +++++++++++++++++++++++++++++++ src/i18n/lang/en.ts | 28 +++ src/i18n/lang/ja.ts | 36 +++- src/js/desktop.ts | 96 +++++----- src/js/mobile.ts | 98 ++++++----- src/manifest.json | 2 + src/plugins/kintoneClient.ts | 9 +- src/services/licenseService.ts | 308 +++++++++++++++++++++++++++++++++ src/types/index.d.ts | 1 + src/types/license.d.ts | 45 +++++ src/utils/licenseStorage.ts | 65 +++++++ src/utils/permissions.ts | 51 ++++++ 14 files changed, 1008 insertions(+), 96 deletions(-) create mode 100644 src/css/license.css create mode 100644 src/services/licenseService.ts create mode 100644 src/types/license.d.ts create mode 100644 src/utils/licenseStorage.ts create mode 100644 src/utils/permissions.ts diff --git a/README.md b/README.md index 290a9e3..e82aff0 100644 --- a/README.md +++ b/README.md @@ -214,9 +214,8 @@ desktop/mobile 的 js 会被 `vite` 使用 `lib` 模式打包,从而将所有 在 desktop/mobile,直接使用: ```ts -import { KintoneRestAPIClient } from '@kintone/rest-api-client'; +import client from '@/plugins/kintoneClient.ts' -const client = new KintoneRestAPIClient(); client.app... ``` diff --git a/src/components/Config.vue b/src/components/Config.vue index 5705568..d33589c 100644 --- a/src/components/Config.vue +++ b/src/components/Config.vue @@ -19,7 +19,10 @@ import type { Setting } from '@/types'; import { useI18n } from 'vue-i18n'; import { useKintoneClient } from '@/composables/useKintoneClient'; +import { useLicenseManager } from '@/composables/useLicenseManager'; import type { Spinner } from 'kintone-ui-component'; +import LicenseModal from '@/components/LicenseModal.vue'; +import LicenseNotification from '@/components/LicenseNotification.vue'; import { nextTick, onMounted, reactive, ref, shallowRef, watch } from 'vue'; @@ -41,10 +44,21 @@ const loading = ref(false); const mainArea = shallowRef(null); const spinner = shallowRef(null); +const licenseManager = useLicenseManager(); + onMounted(async () => { // 等待页面完全渲染后再显示加载状态,实现更平滑的用户体验 nextTick(async () => { loading.value = true; + + // 检查许可证 + if (!licenseManager.canAccessFeatures()) { + // 没有权限或许可证无效,显示弹框或错误提示 + showLicenseError(); + loading.value = false; + return; + } + // 获取已保存的插件配置 const savedSetting = kintone.plugin.app.getConfig(props.pluginId); setting.buttonName = savedSetting?.buttonName || $t('config.button.default'); @@ -57,6 +71,9 @@ onMounted(async () => { // 模拟加载时间,展示 spinner 效果 await new Promise((resolve) => setTimeout(resolve, 500)); loading.value = false; + + // 检查许可证状态,显示相关信息 + checkLicenseStatus(); }); }); @@ -98,6 +115,54 @@ async function save() { }); } +/** + * 显示许可证错误 + */ +function showLicenseError() { + // 添加许可证状态显示区域 + const licenseStatusElement = document.createElement('div'); + licenseStatusElement.className = 'license-status'; + licenseStatusElement.innerHTML = ` +
+

许可证无效

+

您的插件许可证已过期或无效,请联系管理员或购买许可证。

+ +
+ `; + + // 插入到页面顶部 + const mainArea = document.getElementById('main-area'); + if (mainArea && mainArea.parentNode) { + mainArea.parentNode.insertBefore(licenseStatusElement, mainArea); + } +} + +/** + * 检查许可证状态并显示相关信息 + */ +function checkLicenseStatus() { + const licenseInfo = licenseManager.getLicenseDisplayInfo(); + + if (licenseInfo) { + // 添加许可证状态显示 + const statusElement = document.createElement('div'); + statusElement.className = 'license-status-info'; + statusElement.innerHTML = ` +
+

许可证状态

+

到期时间: ${licenseInfo.expiryDate}

+

状态: ${licenseInfo.isExpired ? '已过期' : licenseInfo.remainingDays === Infinity ? '永久' : `剩余 ${licenseInfo.remainingDays} 天`}

+
+ `; + + // 插入到设置区域上方 + const mainArea = document.getElementById('main-area'); + if (mainArea && mainArea.parentNode) { + mainArea.parentNode.insertBefore(statusElement, mainArea); + } + } +} + /** * 取消操作并返回插件列表 * 重定向到插件管理页面 diff --git a/src/css/license.css b/src/css/license.css new file mode 100644 index 0000000..b0bb6b2 --- /dev/null +++ b/src/css/license.css @@ -0,0 +1,297 @@ +/* 许可证弹框样式 */ +.license-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.license-modal-content { + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 400px; + max-width: 600px; +} + +.license-modal-content .modal-header { + padding: 20px 24px 0 24px; + border-bottom: 1px solid #e5e5e5; + margin-bottom: 20px; +} + +.license-modal-content .modal-title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #333; +} + +.license-modal-content .modal-body { + padding: 0 24px; +} + +.license-modal-content .modal-footer { + padding: 20px 24px; + border-top: 1px solid #e5e5e5; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.license-modal-content .btn { + padding: 8px 16px; + border: 1px solid #ddd; + border-radius: 4px; + background: white; + color: #666; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.license-modal-content .btn-primary { + background: #007acc; + color: white; + border-color: #007acc; +} + +.license-modal-content .btn-primary:hover { + background: #005aa3; + border-color: #005aa3; +} + +.license-modal-content .btn-secondary:hover { + background: #f5f5f5; +} + +.license-modal-content .expiry-modal { + /* 样式已在content中定义 */ +} + +.license-modal-content .expiry-info .expiry-message { + margin: 0 0 16px 0; + color: #666; + line-height: 1.5; +} + +.license-modal-content .info-item { + display: flex; + margin-bottom: 8px; + font-size: 14px; +} + +.license-modal-content .info-item .label { + font-weight: 600; + color: #333; + min-width: 100px; + margin-right: 8px; +} + +.license-modal-content .info-item .value { + color: #666; +} + +.license-modal-content .purchase-link { + display: block; + font-size: 12px; + color: #999; + margin-top: 4px; +} + +/* 许可证通知样式 */ +.license-notification { + position: fixed; + right: 20px; + z-index: 9999; + min-width: 320px; + max-width: 450px; +} + +.license-notification .notification-content { + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid #e5e5e5; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.license-notification .notification-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px 8px 16px; + border-bottom: 1px solid #f0f0f0; +} + +.license-notification .notification-title { + font-weight: 600; + font-size: 14px; + color: #333; + display: flex; + align-items: center; + gap: 8px; +} + +.license-notification .icon-warning { + font-size: 16px; +} + +.license-notification .close-btn { + background: none; + border: none; + font-size: 20px; + color: #999; + cursor: pointer; + padding: 0; + line-height: 1; + margin-left: 8px; +} + +.license-notification .close-btn:hover { + color: #666; +} + +.license-notification .notification-message { + padding: 12px 16px; + font-size: 14px; + line-height: 1.5; + color: #666; +} + +.license-notification .notification-actions { + padding: 8px 16px 16px 16px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.license-notification .dont-show-again { + display: flex; + align-items: center; + font-size: 12px; + color: #999; + margin: 0; +} + +.license-notification .dont-show-again input { + margin-right: 6px; +} + +.license-notification .action-buttons { + display: flex; + gap: 8px; +} + +.license-notification .btn { + padding: 6px 12px; + border: 1px solid #ddd; + border-radius: 4px; + background: white; + color: #666; + cursor: pointer; + font-size: 12px; + transition: all 0.2s; +} + +.license-notification .btn-primary { + background: #007acc; + color: white; + border-color: #007acc; +} + +.license-notification .btn-primary:hover { + background: #005aa3; + border-color: #005aa3; +} + +.license-notification .btn-secondary:hover { + background: #f5f5f5; +} + +/* 许可证状态显示样式 */ +.license-status-error, +.license-status { + margin-bottom: 20px; + padding: 16px; + border-radius: 6px; + border: 1px solid #e5e5e5; +} + +.license-status-error { + background-color: #fff5f5; + border-color: #feb2b2; + color: #c53030; +} + +.license-status .license-error { + text-align: center; +} + +.license-status .license-error h3 { + margin: 0 0 8px 0; + color: #c53030; + font-size: 16px; +} + +.license-status .license-error p { + margin: 0 0 12px 0; + line-height: 1.5; +} + +.license-status .license-refresh-btn { + background: #e53e3e; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} + +.license-status .license-refresh-btn:hover { + background: #c53030; +} + +.license-status-info { + margin-bottom: 20px; + padding: 16px; + border-radius: 6px; + border: 1px solid #e5e5e5; + background-color: #f7fafc; +} + +.license-status-info .license-info h4 { + margin: 0 0 12px 0; + color: #2d3748; + font-size: 16px; + font-weight: 600; +} + +.license-status-info .license-info p { + margin: 0 0 8px 0; + font-size: 14px; + color: #4a5568; + line-height: 1.4; +} + +.license-status-info .license-info p:last-child { + margin-bottom: 0; +} diff --git a/src/i18n/lang/en.ts b/src/i18n/lang/en.ts index de3b946..c97631b 100644 --- a/src/i18n/lang/en.ts +++ b/src/i18n/lang/en.ts @@ -14,4 +14,32 @@ export default { default: '', }, }, + license: { + expiry: { + title: 'License Expired - {domain} - {plugin}', + message: 'Your plugin license has expired. To continue using this feature, please purchase a new license.', + domain: 'Domain', + plugin: 'Plugin', + expiryDate: 'Expiry Date' + }, + button: { + purchase: 'Purchase License', + checkLicense: 'Check Again', + close: 'Close' + }, + notification: { + dontShowAgain: 'Don\'t show again' + }, + permission: { + noAccess: 'You do not have permission to access this feature', + adminRequired: 'Administrator privileges required' + }, + status: { + checking: 'Checking license...', + valid: 'License is valid', + expired: 'License has expired', + expiringSoon: 'License expires soon', + permanent: 'Permanent license' + } + }, }; diff --git a/src/i18n/lang/ja.ts b/src/i18n/lang/ja.ts index 311cb46..48449fa 100644 --- a/src/i18n/lang/ja.ts +++ b/src/i18n/lang/ja.ts @@ -1,17 +1,45 @@ // 日语语言包配置 // 包含配置页面相关的翻译文本 export default { - hello: 'こんにちは!', + hello: "こんにちは!", config: { - title: 'kintone Vue テンプレート', - desc: 'kintoneプラグイン作成用 Vue テンプレート', + title: 'kintone Vue template', + desc: 'kintone Vue template for creating plugin', button: { label: 'ボタン名', default: 'ボタン', }, message: { label: 'メッセージ', - default: '', + default: 'こんにちは、世界', }, }, + license: { + expiry: { + title: 'ライセンスの有効期限が切れました - {domain} - {plugin}', + message: 'プラグインのライセンス有効期限が切れています。この機能を引き続き使用するには、新しいライセンスを購入してください。', + domain: 'ドメイン', + plugin: 'プラグイン', + expiryDate: '有効期限' + }, + button: { + purchase: 'ライセンスを購入', + checkLicense: '再確認', + close: '閉じる' + }, + notification: { + dontShowAgain: '今後表示しない' + }, + permission: { + noAccess: 'この機能にアクセスする権限がありません', + adminRequired: '管理者権限が必要です' + }, + status: { + checking: 'ライセンスを確認しています...', + valid: 'ライセンスは有効です', + expired: 'ライセンスの有効期限が切れました', + expiringSoon: 'ライセンスの有効期限が近づいています', + permanent: '永久ライセンス' + } + }, }; diff --git a/src/js/desktop.ts b/src/js/desktop.ts index 1a97a07..a7abe2a 100644 --- a/src/js/desktop.ts +++ b/src/js/desktop.ts @@ -1,57 +1,67 @@ import i18n from '@/i18n'; import type { Setting } from '@/types'; - -import { KintoneRestAPIClient } from '@kintone/rest-api-client'; +import { LicenseService } from '@/services/licenseService'; +import client from '@/plugins/kintoneClient.ts' import { Button } from 'kintone-ui-component/lib/button'; (function (PLUGIN_ID) { - kintone.events.on('app.record.index.show', () => { - // 获取当前应用ID - const appIdNum = kintone.app.getId(); - if (!appIdNum) { - return; - }; - const appId = appIdNum.toString(); + kintone.events.on('app.record.index.show', async () => { + // 授权了才能使用 + LicenseService.loadPluginIfAuthorized(PLUGIN_ID, + async () => { + // 获取当前应用ID + const appIdNum = kintone.app.getId(); + if (!appIdNum) { + return; + } + const appId = appIdNum.toString(); - // 从插件配置中读取设置信息 - const setting: Setting = kintone.plugin.app.getConfig(PLUGIN_ID); + // 从插件配置中读取设置信息 + const setting: Setting = kintone.plugin.app.getConfig(PLUGIN_ID); - // 检查按钮是否已存在,防止翻页时重复添加 - const btnId = 'template-btn-id'; - if (document.getElementById(btnId)) { - return; - }; + // 检查按钮是否已存在,防止翻页时重复添加 + const btnId = 'template-btn-id'; + if (document.getElementById(btnId)) { + return; + } - // 获取 Header 容器元素 - const headerSpace = kintone.app.getHeaderMenuSpaceElement(); - if (!headerSpace) { - throw new Error('このページではヘッダー要素が利用できません。'); - } + // 获取 Header 容器元素 + const headerSpace = kintone.app.getHeaderMenuSpaceElement(); + if (!headerSpace) { + throw new Error('このページではヘッダー要素が利用できません。'); + } - // 测试 i18n - const { t } = i18n.global; + // 测试 i18n + const { t } = i18n.global; - // 创建按钮 - const button = new Button({ - text: setting.buttonName, - type: 'submit', - id: btnId, - }); - button.addEventListener('click', async () => { - try { - // 测试 KintoneRestAPIClient,显示所有已启用的插件名 - const client = new KintoneRestAPIClient(); - const { plugins } = await client.app.getPlugins({ - app: appId, + // 创建按钮 + const button = new Button({ + text: setting.buttonName, + type: 'submit', + id: btnId, }); - const pluginsInfo = plugins.map((p) => p.name).join('、'); + button.addEventListener('click', async () => { + try { + // 测试 KintoneRestAPIClient,显示所有已启用的插件名 + const { plugins } = await client.app.getPlugins({ + app: appId, + }); + const pluginsInfo = plugins.map((p) => p.name).join('、'); - const message = t('hello') + "\n" + setting.message + '\n--------\n【Plugins】 ' + pluginsInfo; - alert(message); - } catch (error) { - console.error('Failed to fetch plugins:', error); - } - }); - headerSpace.appendChild(button); + const message = t('hello') + '\n' + setting.message + '\n--------\n【Plugins】 ' + pluginsInfo; + alert(message); + } catch (error) { + console.error('Failed to fetch plugins:', error); + } + }); + headerSpace.appendChild(button); + }, + { + expiryDialogTitle: '自定义插件到期提示', + expiryDialogMessage: '您的自定义插件许可证已过期,请联系管理员购买新的许可证。', + warningDialogTitle: '自定义插件即将到期', + warningDialogMessage: '您的自定义插件许可证还有3天就到期了,请及时续费。', + }, + ); }); })(kintone.$PLUGIN_ID); diff --git a/src/js/mobile.ts b/src/js/mobile.ts index 5f7e20f..6a604dc 100644 --- a/src/js/mobile.ts +++ b/src/js/mobile.ts @@ -1,57 +1,67 @@ import i18n from '@/i18n'; import type { Setting } from '@/types'; - -import { KintoneRestAPIClient } from '@kintone/rest-api-client'; +import { LicenseService } from '@/services/licenseService'; +import client from '@/plugins/kintoneClient.ts' import { MobileButton } from 'kintone-ui-component/lib/mobile/button'; (function (PLUGIN_ID) { kintone.events.on('mobile.app.record.index.show', () => { - // 获取当前应用ID - const appIdNum = kintone.mobile.app.getId(); - if (!appIdNum) { - return; - }; - const appId = appIdNum.toString(); + // 授权了才能使用 + LicenseService.loadPluginIfAuthorized(PLUGIN_ID, + async () => { + // 获取当前应用ID + const appIdNum = kintone.mobile.app.getId(); + if (!appIdNum) { + return; + }; + const appId = appIdNum.toString(); - // 从插件配置中读取设置信息 - const setting: Setting = kintone.plugin.app.getConfig(PLUGIN_ID); + // 从插件配置中读取设置信息 + const setting: Setting = kintone.plugin.app.getConfig(PLUGIN_ID); - // 检查按钮是否已存在,防止翻页时重复添加 - const btnId = 'template-btn-id'; - if (document.getElementById(btnId)) { - return; - }; + // 检查按钮是否已存在,防止翻页时重复添加 + const btnId = 'template-btn-id'; + if (document.getElementById(btnId)) { + return; + }; - // 获取 Header 容器元素 - const headerSpace = kintone.mobile.app.getHeaderSpaceElement(); - if (!headerSpace) { - throw new Error('このページではヘッダー要素が利用できません。'); - } + // 获取 Header 容器元素 + const headerSpace = kintone.mobile.app.getHeaderSpaceElement(); + if (!headerSpace) { + throw new Error('このページではヘッダー要素が利用できません。'); + } - // 测试 i18n - const { t } = i18n.global; + // 测试 i18n + const { t } = i18n.global; - // 创建按钮 - const button = new MobileButton({ - text: setting.buttonName, - type: 'submit', - id: btnId, - }); - button.addEventListener('click', async () => { - try { - // 测试 KintoneRestAPIClient,显示所有已启用的插件名 - const client = new KintoneRestAPIClient(); - const { plugins } = await client.app.getPlugins({ - app: appId, - }); - const pluginsInfo = plugins.map((p) => p.name).join('、'); + // 创建按钮 + const button = new MobileButton({ + text: setting.buttonName, + type: 'submit', + id: btnId, + }); + button.addEventListener('click', async () => { + try { + // 测试 KintoneRestAPIClient,显示所有已启用的插件名 + const { plugins } = await client.app.getPlugins({ + app: appId, + }); + const pluginsInfo = plugins.map((p) => p.name).join('、'); - const message = t('hello') + "\n" + setting.message + '\n--------\n【Plugins】 ' + pluginsInfo; - alert(message); - } catch (error) { - console.error('Failed to fetch plugins:', error); - } - }); - headerSpace.appendChild(button); + const message = t('hello') + "\n" + setting.message + '\n--------\n【Plugins】 ' + pluginsInfo; + alert(message); + } catch (error) { + console.error('Failed to fetch plugins:', error); + } + }); + headerSpace.appendChild(button); + }, + { + expiryDialogTitle: '自定义插件到期提示', + expiryDialogMessage: '您的自定义插件许可证已过期,请联系管理员购买新的许可证。', + warningDialogTitle: '自定义插件即将到期', + warningDialogMessage: '您的自定义插件许可证还有3天就到期了,请及时续费。', + }, + ); }); -})(kintone.$PLUGIN_ID); +})(kintone.$PLUGIN_ID); \ No newline at end of file diff --git a/src/manifest.json b/src/manifest.json index e16863a..375627b 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -8,6 +8,7 @@ "js/desktop.js" ], "css": [ + "css/license.css", "css/desktop.css" ] }, @@ -19,6 +20,7 @@ ], "css": [ "css/51-modern-default.css", + "css/license.css", "css/config.css" ], "required_params": [ diff --git a/src/plugins/kintoneClient.ts b/src/plugins/kintoneClient.ts index 60c8e6c..3b4fb0a 100644 --- a/src/plugins/kintoneClient.ts +++ b/src/plugins/kintoneClient.ts @@ -1,16 +1,19 @@ import { type App } from 'vue'; import { KintoneRestAPIClient } from '@kintone/rest-api-client'; +// 创建客户端实例(单例) +const client = new KintoneRestAPIClient(); + // 创建注入的 Symbol(用于类型安全) export const KintoneClientInjectionKey = Symbol('kintone-client'); // 插件安装函数 export const kintoneClientPlugin = { install(app: App) { - // 创建客户端实例(不需要参数) - const client = new KintoneRestAPIClient(); - // 提供给整个应用使用 app.provide(KintoneClientInjectionKey, client); }, }; + +// 导出客户端实例作为默认 +export default client; diff --git a/src/services/licenseService.ts b/src/services/licenseService.ts new file mode 100644 index 0000000..b518bf6 --- /dev/null +++ b/src/services/licenseService.ts @@ -0,0 +1,308 @@ +import type { LicenseInfo, LicenseCheckResult } from '@/types/license'; +import { LicenseStorage } from '@/utils/licenseStorage'; +import { PermissionService } from '@/utils/permissions'; +import { Notification } from 'kintone-ui-component/lib/notification'; +import { createApp } from 'vue'; +import i18n from '@/i18n'; + +export class LicenseService { + // 常量定义 + private static readonly WARNING_DAYS_BEFORE_EXPIRY = 7; + private static PLUGIN_ID: string = ''; + // ============ 基础工具函数 ============ + + /** + * 获取域名 + */ + static getDomain(): string { + return window.location.hostname; + } + + /** + * 获取插件ID + */ + static getPluginId(): string { + return this.PLUGIN_ID; + } + + /** + * 检查许可证是否有效 + * 已付费/当日获取是 true + */ + static checkLicenseAvailable(license: LicenseInfo) { + const domain = this.getDomain() + const pluginId = this.getPluginId() + + // TODO jwt null + + // 检查域名和插件ID是否与当前环境一致 + if (license.domain !== domain || license.pluginId !== pluginId) { + return false; + } + + // 检查是否付费 + if (license.isPaid) { + return true; + } + + const today = new Date(); + // 检查存储是否过期(是否同一天) + const fetchDate = new Date(license.fetchTime).toDateString(); + const todayStr = today.toDateString(); + if (fetchDate !== todayStr) { + // 不是同一天,已过期 + return false; + } + + // 检查试用是否到期 + const expiredTime = new Date(license.expiredTime); + if (expiredTime < today) { + return false; + } + + return true + } + + /** + * 许可证验证 + */ + static async checkLicense(): Promise { + const localLicense = this.getLocalLicenseInfo() || undefined + if (localLicense) { + return { + isLicenseValid: true, + license: localLicense, + }; + } + return await this.checkLicenseRemote() + } + + /** + * 远程许可证验证(模拟) + */ + static async checkLicenseRemote(): Promise { + try { + // 这里应该是实际的API调用,暂时模拟创建试用许可证 + const response = await this.mockRemoteCheck(this.getDomain(), this.getPluginId()); + + const license = response.license!; + LicenseStorage.saveLicense(license); + + return { + isLicenseValid: this.checkLicenseAvailable(license), + license, + isRemote: true, + }; + + } catch (error) { + console.error('Remote license check failed:', error); + return { + isLicenseValid: true, + }; + } + } + + // ============ 数据处理函数 ============ + + /** + * 检查是否快要到期 + */ + static isExpiringSoon(expiryTimestamp: number): boolean { + const now = Date.now(); + const warningTime = expiryTimestamp - (this.WARNING_DAYS_BEFORE_EXPIRY * 24 * 60 * 60 * 1000); + + return now >= warningTime && expiryTimestamp > now; + } + + /** + * 格式化到期时间 + */ + static formatExpiryDate(timestamp: number): string { + if (timestamp === -1) return '永久'; + return new Date(timestamp).toLocaleDateString('zh-CN'); + } + + /** + * 计算剩余天数 + */ + static getDaysRemaining(timestamp: number): number { + if (timestamp === -1) return Infinity; + const now = new Date().getTime(); + const remainingMs = timestamp - now; + return Math.ceil(remainingMs / (24 * 60 * 60 * 1000)); + } + + /** + * 获取许可证显示信息 + */ + static getLicenseDisplayInfo(license: any) { + if (!license) return null; + + return { + domain: this.getDomain(), + plugin: this.getPluginId(), + expiryDate: this.formatExpiryDate(license.expiredTime) + }; + } + + // ============ UI显示函数 ============ + + /** + * 显示到期弹框 + */ + static showExpiryModal(licenseInfo: any, options?: { + expiryDialogTitle?: string; + expiryDialogMessage?: string; + }) { + return + } + + /** + * 显示到期警告通知 + */ + static showExpiryWarning(license: any, options?: { + warningDialogTitle?: string; + warningDialogMessage?: string; + }) { + const remainingDays = this.getDaysRemaining(license.expiredTime); + const defaultMessage = `您的插件许可证将在 ${remainingDays} 天后到期,请及时续期以避免服务中断。`; + + // 使用自定义消息或默认消息 + const message = options?.warningDialogMessage || defaultMessage; + + // 检查是否已设置不再提醒 + const dontShowKey = `license_notification_dont_show_again_${this.getPluginId()}`; + const dontShowAgain = localStorage.getItem(dontShowKey) === 'true'; + if (dontShowAgain) return; + + // 使用KUC Notification + const notification = new Notification({ + text: message, + type: 'info', + duration: -1 // 不自动关闭 + }); + + // 添加到页面 + const container = document.body; + container.appendChild(notification); + + // 监听关闭事件 + notification.addEventListener('close', () => { + // 这里可以添加不再提醒的逻辑 + }); + } + + // ============ 主要入口函数 ============ + + /** + * 检查插件功能访问权限并加载插件(如果获得授权) + */ + static async loadPluginIfAuthorized( + pluginId: string, + callback: () => void | Promise, + options?: { + expiryDialogTitle?: string; + expiryDialogMessage?: string; + warningDialogTitle?: string; + warningDialogMessage?: string; + } + ) { + this.PLUGIN_ID = pluginId; + try { + // 检查许可证(内部已经包含本地检查无效时自动获取远程的逻辑) + const licenseCheck = await this.checkLicense(); + + // 检查权限 + const permissions = await PermissionService.checkPermissions(); + const isManager = permissions.canManagePlugins; + + // 许可证无效的情况 + if (!licenseCheck.isLicenseValid) { + if (!isManager) { + // 普通用户静默输出 + console.warn('License check failed, plugin functionality disabled'); + } else { + // 管理员可以看到过期弹框 + alert('许可证无效') + // this.showExpiryModal(this.getLicenseDisplayInfo(license.license), { + // expiryDialogTitle: options?.expiryDialogTitle, + // expiryDialogMessage: options?.expiryDialogMessage + // }); + } + return; + } + + // 许可证有效,如果快要到期,管理员可以看到警告 + if (isManager && licenseCheck.license && !licenseCheck.license.isPaid && this.isExpiringSoon(licenseCheck.license.expiredTime)) { + // 管理员可以看到过期弹框 + alert('即将过期') + // this.showExpiryWarning(licenseCheck.license, { + // warningDialogTitle: options?.warningDialogTitle, + // warningDialogMessage: options?.warningDialogMessage + // }); + } + + // 许可证有效,可以加载插件功能 + await callback(); + + } catch (error) { + console.error('License check failed, plugin functionality disabled:', error); + } + } + + // ============ 许可证信息管理 ============ + + /** + * 获取许可证信息(用于显示) + */ + static getLocalLicenseInfo(): LicenseInfo | null { + return LicenseStorage.getLicense(this.getPluginId()); + } + + /** + * 强制刷新许可证(清除本地缓存重新检查) + */ + static async forceRefreshLicense(): Promise { + // 清除本地缓存 + LicenseStorage.clearLicense(this.getPluginId()); + return await this.checkLicenseRemote(); + } + + // ============ 模拟/测试函数 ============ + + /** + * 创建试用许可证 + */ + private static mockCreateTrialLicense(): LicenseInfo { + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + 30); + + return { + expiredTime: expiryDate.getTime(), + isPaid: false, + domain: this.getDomain(), + pluginId: this.getPluginId(), + fetchTime: new Date().getTime(), + }; + } + + /** + * 模拟远程验证(生产环境中会被真实API替换) + */ + private static async mockRemoteCheck(domain: string, pluginId: string): Promise<{ success: boolean; license?: LicenseInfo }> { + // 模拟API调用,这里总是返回试用许可证 + // 生产环境这里会调用后端API: POST /api/license/check + + const license = this.mockCreateTrialLicense(); + + // 模拟有时创建失败的情况(1%概率) + if (Math.random() < 0.01) { + return { success: false }; + } + + return { + success: true, + license, + }; + } +} diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 325b69d..5fa516e 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,3 +1,4 @@ // 导出所有类型定义 // 主要导出应用设置相关的类型接口 export * from './model'; +export * from './license'; diff --git a/src/types/license.d.ts b/src/types/license.d.ts new file mode 100644 index 0000000..bcbd5c4 --- /dev/null +++ b/src/types/license.d.ts @@ -0,0 +1,45 @@ +// 许可证相关类型定义 + +export interface LicenseInfo { + // 到期时间戳,后端需要是最后一天的 23:59:59 如果是永久许可证则为 -1 + expiredTime: number; + // 是否是永久许可证 + isPaid: boolean; + // 域名 + domain: string; + // 插件ID + pluginId: string; + // 获取许可证的时间戳 + fetchTime: number; +} + +export interface LicenseCheckResult { + // 是否有效(许可证真正有效可用) + isLicenseValid: boolean; + // 许可证信息 + license?: LicenseInfo; + // 是否来自远程检查(从 checkLicenseRemote 返回时为 true) + isRemote?: boolean; +} + +export interface NotificationConfig { + // 显示通知 + show: boolean; + // 通知类型 + type: 'expiry' | 'warning' | 'error'; + // 通知标题 + title: string; + // 通知内容 + message: string; + // 是否有购买按钮 + showPurchaseButton: boolean; + // 是否有检查按钮 + showCheckButton: boolean; + // 不再提醒选项 + showDontShowAgain: boolean; +} + +export interface PermissionConfig { + // 是否可以管理插件(应用管理员权限) + canManagePlugins: boolean; +} diff --git a/src/utils/licenseStorage.ts b/src/utils/licenseStorage.ts new file mode 100644 index 0000000..61a7826 --- /dev/null +++ b/src/utils/licenseStorage.ts @@ -0,0 +1,65 @@ +// 本地存储加密工具类 +import { LicenseService } from '@/services/licenseService'; +import type { LicenseInfo } from '@/types/license'; + +export class LicenseStorage { + private static readonly STORAGE_KEY_PREFIX = 'alicorns_plugin_'; + + /** + * 生成存储key + */ + private static generateStorageKey(pluginId: string): string { + return `${this.STORAGE_KEY_PREFIX}${pluginId}`; + } + + /** + * 保存许可证信息到本地存储 + */ + static saveLicense(licenseInfo: LicenseInfo): void { + try { + // 直接存储LicenseInfo对象(其中已包含fetchTime) + const key = this.generateStorageKey(licenseInfo.pluginId); + localStorage.setItem(key, JSON.stringify(licenseInfo)); + } catch (error) { + console.error('Failed to save license:', error); + throw error; + } + } + + /** + * 从本地存储获取许可证信息 + */ + static getLicense(pluginId: string): LicenseInfo | null { + try { + const key = this.generateStorageKey(pluginId); + const storedData = localStorage.getItem(key); + + if (!storedData) return null; + + const parsedData: LicenseInfo = JSON.parse(storedData); + + const isValid = LicenseService.checkLicenseAvailable(parsedData) + if (!isValid) { + // 获取许可证信息失败,清理存储 + this.clearLicense(pluginId); + return null; + } + + return parsedData; + } catch (error) { + console.error('Failed to get license:', error); + // 如果获取失败,清空本地存储 + this.clearLicense(pluginId); + return null; + } + } + + /** + * 清除许可证信息 + */ + static clearLicense(pluginId: string): void { + const key = this.generateStorageKey(pluginId); + localStorage.removeItem(key); + } + +} diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts new file mode 100644 index 0000000..e4f3d34 --- /dev/null +++ b/src/utils/permissions.ts @@ -0,0 +1,51 @@ +// 权限检查工具类 +import type { PermissionConfig } from '@/types/license'; +import client from '@/plugins/kintoneClient.ts' + +export class PermissionService { + /** + * 检查用户是否有管理插件的权限 + * 基于kintone的权限系统判断用户是否可以管理应用插件 + */ + static async checkPermissions(): Promise { + try { + // 获取应用信息 + const appId = kintone.app.getId(); + if (!appId) { + return this.getDefaultPermissions(); + } + + // 检查应用的管理权限 + const hasAdminRights = await this.checkAppAdminRights(appId); + + return { + canManagePlugins: hasAdminRights, + }; + } catch (error) { + console.error('Failed to check permissions:', error); + return this.getDefaultPermissions(); + } + } + + /** + * 检查应用管理员权限 + */ + private static async checkAppAdminRights(appId: number): Promise { + try { + await client.app.getAppAcl({ app: appId }); + return true; + } catch (error) { + // 如果API调用失败,默认认为没有权限 + return false; + } + } + + /** + * 获取默认权限配置(无权限) + */ + private static getDefaultPermissions(): PermissionConfig { + return { + canManagePlugins: false, + }; + } +}