Compare commits

...

8 Commits

Author SHA1 Message Date
a4ae06ea52 update 2025-10-24 15:09:32 +08:00
767af75aad fix 2025-10-24 12:57:24 +08:00
e86afd8487 add jwt 2025-10-24 12:35:35 +08:00
3961285b32 fix 2025-10-23 19:12:21 +08:00
e531b50a0d fix i18n 2025-10-23 18:03:02 +08:00
8565b9513d improve UIUX 2025-10-23 15:30:57 +08:00
7a3eb40aa9 update license check 2025-10-21 17:33:15 +08:00
6649680296 add license check for desktop/mobile 2025-10-21 15:21:53 +08:00
21 changed files with 1135 additions and 102 deletions

View File

@@ -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...
```
@@ -423,4 +422,20 @@ watch(loading, (load) => {
load ? spinner.value?.open() : spinner.value?.close();
});
</script>
```
```
### 关于 tooltip
kuc 没有实现插槽,所以应该当成一个普通组件使用:
```vue
<template>
<button ref="buyButton">购买</button>
<kuc-tooltip title="点击购买" :container="buyButton"></kuc-tooltip>
</template>
<script setup lang="ts">
const buyButton = shallowRef<HTMLButtonElement | null>(null);
</script>
```

1
components.d.ts vendored
View File

@@ -8,6 +8,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
Config: typeof import('./src/components/Config.vue')['default']
LicenseStatus: typeof import('./src/components/LicenseStatus.vue')['default']
PluginInput: typeof import('./src/components/basic/PluginInput.vue')['default']
PluginLabel: typeof import('./src/components/basic/PluginLabel.vue')['default']
}

View File

@@ -13,6 +13,8 @@
},
"dependencies": {
"@kintone/rest-api-client": "^5.7.5",
"jsrsasign": "^11.1.0",
"jsrsasign-util": "^1.0.5",
"kintone-ui-component": "1.22.0",
"rollup-plugin-css-only": "^4.5.2",
"vue": "^3.5.13",
@@ -23,6 +25,7 @@
"@kintone/dts-gen": "^8.1.1",
"@kintone/plugin-packer": "^8.1.3",
"@kintone/plugin-uploader": "^9.1.2",
"@types/jsrsasign": "^10.5.15",
"@types/node-rsa": "^1.1.4",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",

28
rsa_private.pem Normal file
View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDOAqDRabJn6vga
YXnWuYKPomQVNV0R0HPmuN1N4qo7OMDkFH2mln86w68/9/bHMb7TcVaFGxNblpe2
tIjr/u/XfnS2+NK6ckTWOhmPUrqLG6A3a4v49dymvFdfyjAYOCBTlKzrdQTjdFAX
5EUE007n/G+RxYiEAm0NXpVqa+aw/Yvm5+Lr7GrKpyamAAygu5jYDE3rateyfJ1h
P2fEXEqR4RPD5pgyKD+4ZVbygaYqAw4rZu4OOZ4QtPCtksSenn94Yx9UUlex3TNW
oTBrFp+8z9wjg+oF6/5Ai/PsXLDpbkIusnQo1yYvrULwaAHBvL6wgj1FOL+g6Tmp
P6MhU7EbAgMBAAECggEAAyia0vXQv0jPgAa26a9ozI9RWIU7xZOmy0anV1Peh0+X
SYf05efQez6tLQkTbA9xB73o9TEqlVC+8mqx32hxFffsOIf3zIBcWiqEN3nYaVya
6BlKiUkqa8C0guqkh80zKwTlt9XQpYokK5GblfeFcgd2pctbt1Ea30XFewm80kGH
OrHP4bi25KYkS+6PL5wUI+OqfaR/LsWevwt+cLlZPGmDDgE6+2Bvcg0JWnKKwk1k
jhKpSAek6zUkweaysxx1BscNBvGtDnRRRy0+2jMK66ad80Cg0G8xLQ/tu8kBwfml
T/9ViSRUjUrMdcfSVkrdOV3xAsOXdWspz1aljJAwcQKBgQDxebBimIBgUhNJ+2/9
27OQXsHLBYbqPurR4fKCM7a5qAIpXJcgth0YAuNvJT+rxEHTPypQCTF1fvvJuU+v
w1HjUz1CHzGYWdj0xibQDdtgkPlKqQQeT3Cnpwy3RkT4IF6FCH9YfKcUzYdamVZy
S8kFqLzEStwWIoaQX2HoBZLoUQKBgQDaZtZJoIHr+d0fLz+7rAoB8WEBA8lOaEhU
/OHwrUbuAez6k7zEDsTXgLuB8N34p1yMSaOqdGJ8rj3Pg8AlrXGELfXOF7Fe9zG0
LxujNmftmJKE8XTIALPpSBJgF5LDnh13LcNMd4b6w0DDjF7wCA2T3DTLjjqi3fo7
nDW9WQyTqwKBgEXlbXL8pZw75a1yhHY80/skEoBLt0OytpHODz407d1LjmSekng7
fqxmmaKga4+ynUMic4L7Rj+2Y/d+FlzP8rIUdBThpp9s0mn3uWBbwnZvQFmmFrUX
VYqRxhJ+2pPf+rwTO5lHa62P2HAXFni7CxMCRrGi4ZXepIjBsztP8bghAoGBAMj5
Bsmb1NJkDBGNNhWpm0/sYbpAVLc9CQqD5hnGKdYMmZh/6J11hbdVM7bAAlK1F1nU
zbGmBZb7888ISwGg2Cus61tpvANKb0eCbelDwGEIHBQP6Mm+s8/ATYB1UM2Hq0+n
Iec0ulX45JjNi/NPRcdBRKfnypdissi111HVJtifAoGADrT/t2j1bMHd1zY76Jv5
M/BWhOAgHwBo683ROEL3p6G98Xla2kd7hP6NEdo6OLJnlfK3RfoPWnotLZKEsfQM
Wog9GAcYtQbDDaYWxNBgyscSdVousY1EXs+t5vkt3XSIwUwLcEVJOu7/Fvt+YXpn
nR7aD8F62ARYxI+bv+bQR5w=
-----END PRIVATE KEY-----

9
rsa_public.pem Normal file
View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzgKg0WmyZ+r4GmF51rmC
j6JkFTVdEdBz5rjdTeKqOzjA5BR9ppZ/OsOvP/f2xzG+03FWhRsTW5aXtrSI6/7v
1350tvjSunJE1joZj1K6ixugN2uL+PXcprxXX8owGDggU5Ss63UE43RQF+RFBNNO
5/xvkcWIhAJtDV6VamvmsP2L5ufi6+xqyqcmpgAMoLuY2AxN62rXsnydYT9nxFxK
keETw+aYMig/uGVW8oGmKgMOK2buDjmeELTwrZLEnp5/eGMfVFJXsd0zVqEwaxaf
vM/cI4PqBev+QIvz7Fyw6W5CLrJ0KNcmL61C8GgBwby+sII9RTi/oOk5qT+jIVOx
GwIDAQAB
-----END PUBLIC KEY-----

View File

@@ -1,4 +1,7 @@
<template>
<!-- 许可证状态信息 -->
<LicenseStatus />
<h2 class="settings-heading">{{ $t('config.title') }}</h2>
<p class="kintoneplugin-desc">{{ $t('config.desc') }}</p>
@@ -8,8 +11,8 @@
</div>
<div class="action-area">
<kuc-button text="キャンセル" type="normal" @click="cancel" />
<kuc-button :disabled="loading" text="保存する" class="save-btn" type="submit" @click="save" />
<kuc-button :text="$t('config.cancel')" type="normal" @click="cancel" />
<kuc-button :text="$t('config.save')" class="save-btn" type="submit" @click="save" />
</div>
<!-- 必须使用 v-show spinner dom 创建的时候不显示 -->
@@ -20,6 +23,7 @@ import type { Setting } from '@/types';
import { useI18n } from 'vue-i18n';
import { useKintoneClient } from '@/composables/useKintoneClient';
import type { Spinner } from 'kintone-ui-component';
import LicenseStatus from './LicenseStatus.vue';
import { nextTick, onMounted, reactive, ref, shallowRef, watch } from 'vue';
@@ -45,17 +49,22 @@ onMounted(async () => {
// 等待页面完全渲染后再显示加载状态,实现更平滑的用户体验
nextTick(async () => {
loading.value = true;
// 获取已保存的插件配置
const savedSetting = kintone.plugin.app.getConfig(props.pluginId);
setting.buttonName = savedSetting?.buttonName || $t('config.button.default');
setting.message = savedSetting?.message || $t('config.message.default');
// 测试 KintoneRestAPIClient
const { plugins } = await client.app.getPlugins({
app: kintone.app.getId() as number,
});
const pluginsInfo = plugins.map((p: any) => p.name).join(';show');
const pluginsInfo = plugins.map((p: any) => p.name).join('');
console.log('pluginsInfo', pluginsInfo);
// 模拟加载时间,展示 spinner 效果
await new Promise((resolve) => setTimeout(resolve, 500));
loading.value = false;
});
});
@@ -73,7 +82,7 @@ watch(loading, (load) => {
*/
function validate(setting: Setting): boolean {
if (!setting.buttonName.trim()) {
error.value = 'ボタン名を入力してください。';
error.value = $t('config.button.error.required');
return false;
}
return true;

View File

@@ -0,0 +1,158 @@
<template>
<div v-if="shown" class="license-status-info">
<div class="license-content">
<div class="license-status-text">
<strong>{{ $t('license.status.label') }}</strong>
<span v-if="licenseDisplayInfo" v-html="licenseStatusText"></span><span v-else> ... </span>
</div>
<div class="license-actions">
<template v-if="!licenseDisplayInfo.isPaid">
<button class="action-btn" @click="refreshLicenseStatus" :disabled="checking" ref="checkButton">{{ $t('license.button.checkLicense.label') }}</button>
<kuc-tooltip :title="$t('license.button.checkLicense.desc')" :container="checkButton"></kuc-tooltip>
<button class="action-btn main" @click="purchaseLicense" ref="buyButton">{{ $t('license.button.purchase.label') }}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="open-icon">
<path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<kuc-tooltip :title="$t('license.button.purchase.desc')" :container="buyButton"></kuc-tooltip>
</template>
<button v-else class="action-btn" @click="hidePaidMsg">{{ $t('license.button.hide') }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, shallowRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { LicenseService } from '@/services/license-service';
import type { LicenseInfo } from '@/types/license';
import { LicenseStorage } from '@/utils/license-storage';
const { t: $t } = useI18n();// 配置国际化
type LicenseDisplayInfo = {
isPaid: boolean;
expiryDate: string;
isExpired: boolean;
remainingDays: number;
};
// 状态管理
const licenseInfo = ref<LicenseInfo | undefined>();
const loading = ref(true);
const shown = ref(false);
const checking = ref(false);
const checkButton = shallowRef<HTMLButtonElement | null>(null);
const buyButton = shallowRef<HTMLButtonElement | null>(null);
// 许可证显示信息
const licenseDisplayInfo = computed<LicenseDisplayInfo>(() => {
if (!licenseInfo.value || loading.value) {
return {
isPaid: false,
expiryDate: $t('license.status.unknown'),
isExpired: false,
remainingDays: 1,
};
}
return {
isPaid: licenseInfo.value.isPaid,
expiryDate: LicenseService.formatExpiryDate(licenseInfo.value.expiredTime),
isExpired: licenseInfo.value.expiredTime < Date.now() && !licenseInfo.value.isPaid,
remainingDays: LicenseService.getDaysRemaining(licenseInfo.value.expiredTime),
};
});
onMounted(async () => {
const result = await LicenseService.checkLicense();
await new Promise((resolve) => setTimeout(resolve, 500));
licenseInfo.value = result.license;
shown.value = isPaidAreaShown();
if (!shown.value) {
addPaidLabel();
}
loading.value = false;
});
const addPaidLabel = () => {
const isExist = !!document.querySelector('#license-label')
if (!shown.value && !isExist) {
const spanElement = document.createElement('span');
spanElement.textContent = $t('license.status.permanent'),
spanElement.id = 'license-label';
document.querySelector('#app > .settings-heading')?.appendChild(spanElement);
}
}
const isPaidAreaShown = () => {
if (!licenseInfo.value?.isPaid) {
return true;
}
const settings = LicenseStorage.getSettings(licenseInfo.value.pluginId);
if (settings.hideLicenseAreaIfPaid) {
return false;
}
return true;
};
const licenseStatusText = computed(() => {
if (!licenseDisplayInfo.value) {
return $t('license.status.unknown');
}
if (loading.value) {
return $t('license.status.checking');
}
if (licenseDisplayInfo.value.isPaid) {
return `<span class="text-green">${$t('license.status.permanentDisplay')}</span>`;
}
let status = $t('license.expiry.expiryDate', { date: licenseDisplayInfo.value.expiryDate });
if (licenseDisplayInfo.value.isExpired) {
status += `(<span class="text-red">${$t('license.status.expired')}</span>)`;
return status;
}
const remainingDays = licenseDisplayInfo.value.remainingDays;
const days = $t('license.notification.days', remainingDays)
status += `(${days})`;
return status;
});
async function refreshLicenseStatus() {
checking.value = true;
loading.value = true;
// 模拟加载时间,展示 spinner 效果
await new Promise((resolve) => setTimeout(resolve, 500));
const result = await LicenseService.forceRefreshLicense();
licenseInfo.value = result.license;
loading.value = false;
checking.value = false;
}
function purchaseLicense() {
const { name, email } = kintone.getLoginUser();
const domain = licenseInfo.value?.domain;
const pluginId = licenseInfo.value?.pluginId;
window.open(`https://alisurvey.alicorns.co.jp/s/Iuc5RxZv?input1761283180784=${domain}&input1761283200616=${pluginId}&input1761283314263=${name}&input1761283275767=${email}`);
}
function hidePaidMsg() {
const pluginId = licenseInfo.value?.pluginId;
if (!pluginId) {
return;
}
const settings = LicenseStorage.getSettings(pluginId);
settings.hideLicenseAreaIfPaid = true;
LicenseStorage.saveSetting(settings, pluginId);
shown.value = false;
addPaidLabel();
}
</script>

View File

@@ -15,7 +15,7 @@
/* 最上面的说明 */
.settings-heading {
padding: 1em 0;
margin-bottom: 8px;
margin: 8px 0;
}
/* label 样式 */

103
src/css/license.css Normal file
View File

@@ -0,0 +1,103 @@
.license-status-info {
padding: 12px 16px;
margin-right: 24px;
border-radius: 4px;
border: 1px solid #ddd;
background-color: #f9f9f9;
font-size: 13px;
}
.license-status-info .license-content {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.license-status-info .license-status-text {
color: #333;
flex: 1;
}
.license-status-info .license-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.license-status-info .action-btn {
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
min-width: 65px;
}
.license-status-info .action-btn:not(.main) {
background: transparent;
color: #666;
border: 1px solid #ccc;
}
.license-status-info .action-btn:not(.main):disabled {
background: #efefef;
color: #a0a0a0;
}
.license-status-info .action-btn:not(.main):not(:disabled):hover {
background: #eee;
border-color: #999;
}
.license-status-info .action-btn:not(.main):not(:disabled):active {
background: #BDBDBD;
border-color: #757575;
}
.license-status-info .action-btn.main {
background: #4CAF50;
color: white;
border: 1px solid #4CAF50;
}
.license-status-info .action-btn.main:hover {
background: #388E3C;
border-color: #388E3C;
}
.license-status-info .action-btn.main:active {
background: #33691E;
border-color: #33691E;
}
.license-status-info .action-btn .open-icon {
margin-left: 2px;
vertical-align: middle;
}
.notification-link {
color: #fff;
text-decoration: underline;
}
.notification-link:hover {
color: #fff;
opacity: 0.8;
}
.text-red {
color: #B71C1C;
}
.text-green {
color: #4CAF50;
}
#license-label {
background-color: #4CAF50;
color: white;
padding: 2px 8px;
font-size: 12px;
border-radius: 9999px;
margin-left: 8px;
}

View File

@@ -6,12 +6,56 @@ export default {
title: 'kintone Vue template',
desc: 'kintone Vue template for creating plugin',
button: {
label: 'button name',
default: 'button',
label: 'Button name',
default: 'Button',
error: {
required: '@:config.button.label is required.'
}
},
message: {
label: 'message',
default: '',
},
cancel: 'Cancel',
save: 'Save',
},
error: {
noAreaError: 'Header menu space element is required.',
},
license: {
expiry: {
expiryDate: 'Expiry Date {date}'
},
button: {
purchase: {
label: 'Purchase',
desc: 'All applications under this domain can be used.'
},
checkLicense: {
label: 'Check',
desc: 'Recheck the license status.'
},
hide: 'Hide'
},
notification: {
days: 'today | tomorrow | {count} days later',
gotoLink: 'Please visit <a class="notification-link" href="{link}">Plugin Setting Page</a>.',
expired: 'Your plugin {plugin} license has expired.',
warning: 'Your plugin {plugin} license will be expired {days}.'
},
status: {
label: 'License Status: ',
checking: 'Checking license...',
expired: 'Expired',
expiredDisplay: 'License has expired',
permanent: 'Permanent license',
permanentDisplay: 'Permanent license',
unknown: 'Unknown'
},
error: {
fetchFailed: 'Remote license check failed: {e}',
checkFailed: 'License check failed, plugin disabled: {e}',
jwtFailed: 'JWT verification or parsing failed: {e}',
}
},
};

View File

@@ -1,17 +1,64 @@
// 日语语言包配置
import { warn } from "vue";
// 包含配置页面相关的翻译文本
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: 'ボタン',
error: {
required: '@:config.button.labelを入力してください。'
}
},
message: {
label: 'メッセージ',
default: '',
},
cancel: 'キャンセル',
save: '保存する',
},
error: {
noAreaError: 'このページではヘッダー要素が利用できません。',
},
license: {
expiry: {
expiryDate: '有効期限 {date} ',
},
button: {
purchase: {
label: '購入',
desc: '購入後、このドメイン下の全アプリで利用可能'
},
checkLicense: {
label: '再確認',
desc: 'ライセンス状態を再確認'
},
hide: '非表示'
},
notification: {
days: '今日 | 明日 | {count}日後',
gotoLink: 'ご利用には「<a class="notification-link" href="{link}">プラグインの設定</a>」をご確認ください。',
expired: 'プラグイン「{plugin}」の有効期限が切れました。',
warning: 'プラグイン「{plugin}」の試用期間が{days}で終了します。'
},
status: {
label: 'ライセンス状態: ',
checking: '確認しています...',
expired: '期限切れ',
expiredDisplay: 'ライセンスの有効期限が切れました',
permanent: '永久ライセンス',
permanentDisplay: '永久利用可能',
unknown: '不明'
},
error: {
fetchFailed: 'リモートライセンスチェックに失敗しました: {e}',
checkFailed: 'ライセンスチェックに失敗しました。プラグイン機能が無効になりました: {e}',
jwtFailed: 'JWTの検証または解析に失敗しました: {e}',
}
},
};

View File

@@ -1,57 +1,61 @@
import i18n from '@/i18n';
import type { Setting } from '@/types';
import { KintoneRestAPIClient } from '@kintone/rest-api-client';
import { LicenseService } from '@/services/license-service';
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('このページではヘッダー要素が利用できません。');
}
// 测试 i18n
const { t } = i18n.global;
// 测试 i18n
const { t } = i18n.global;
// 获取 Header 容器元素
const headerSpace = kintone.app.getHeaderMenuSpaceElement();
if (!headerSpace) {
throw new Error(t('error.noAreaError'));
}
// 创建按钮
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);
},
);
});
})(kintone.$PLUGIN_ID);

View File

@@ -1,57 +1,61 @@
import i18n from '@/i18n';
import type { Setting } from '@/types';
import { KintoneRestAPIClient } from '@kintone/rest-api-client';
import { LicenseService } from '@/services/license-service';
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('このページではヘッダー要素が利用できません。');
}
// 测试 i18n
const { t } = i18n.global;
// 测试 i18n
const { t } = i18n.global;
// 获取 Header 容器元素
const headerSpace = kintone.mobile.app.getHeaderSpaceElement();
if (!headerSpace) {
throw new Error(t('error.noAreaError'));
}
// 创建按钮
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 button = new MobileButton({
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);
},
);
});
})(kintone.$PLUGIN_ID);
})(kintone.$PLUGIN_ID);

View File

@@ -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": [

View File

@@ -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;

View File

@@ -0,0 +1,346 @@
import type { LicenseInfo, LicenseCheckResult, SavedLicense } from '@/types/license';
import i18n from '@/i18n';
import { LicenseStorage } from '@/utils/license-storage';
import { PermissionService } from '@/utils/permissions';
import { Notification } from 'kintone-ui-component/lib/notification';
import { MobileNotification } from 'kintone-ui-component/lib/mobile/notification';
import manifestJson from '@/manifest.json';
import { KJUR } from 'jsrsasign';
import rsaPublicKey from '../../rsa_public.pem?raw';
import rsaPrivateKey from '../../rsa_private.pem?raw';
const { t: $t } = i18n.global;
export class LicenseService {
// 常量定义
private static readonly WARNING_DAYS_BEFORE_EXPIRY = 7;
private static readonly TRIAL_DATE = 30;
private static PLUGIN_ID: string = '';
// ============ 基础工具函数 ============
/**
* 获取域名
*/
static getDomain(): string {
return window.location.hostname;
}
/**
* 获取插件名
*/
static getPluginName(): string {
const pluginName = manifestJson.name as Record<string, string>;
const userLang = kintone.getLoginUser().language;
const plugin = pluginName[userLang] || pluginName.ja;
return plugin;
}
/**
* 获取插件ID
*/
static getPluginId(): string {
return this.PLUGIN_ID || new URLSearchParams(window.location.search).get('pluginId') || '';
}
/**
* 检查许可证是否有效
* 参数是解码后的JWT结构验证签名和payload进行原有检查
*/
static checkLicenseAvailable(savedLicense: SavedLicense): boolean {
try {
// 验证完整的JWT
const result = KJUR.jws.JWS.verifyJWT(savedLicense.jwt, rsaPublicKey.trim(), {alg: ['RS256']});
if (!result) {
console.warn($t('license.error.jwtFailed', { e : '' }));
return false;
}
} catch (error) {
console.warn($t('license.error.jwtFailed', { e : error }));
return false;
}
const license = savedLicense.licenseInfo;
const domain = this.getDomain()
const pluginId = this.getPluginId()
// 检查域名和插件ID是否与当前环境一致
if (license.domain !== domain || license.pluginId !== pluginId) {
return false;
}
// 检查是否付费
if (license.isPaid) {
return true;
}
// 检查存储是否过期(是否同一天)
if (!this.isToday(license.fetchTime)) {
// 不是同一天,已过期
return false;
}
// 检查试用是否到期
const expiredTime = new Date(license.expiredTime);
if (expiredTime < new Date()) {
return false;
}
return true;
}
static isToday(timestamp: number): boolean {
const fetchDate = new Date(timestamp).toDateString();
const todayStr = new Date().toDateString();
return fetchDate == todayStr;
}
/**
* 许可证验证
*/
static async checkLicense(): Promise<LicenseCheckResult> {
const localLicense = this.getLocalLicenseInfo() || undefined
if (localLicense) {
return {
isLicenseValid: true,
license: localLicense,
};
}
return await this.checkLicenseRemote()
}
/**
* 远程许可证验证(模拟)
*/
static async checkLicenseRemote(): Promise<LicenseCheckResult> {
try {
// 这里应该是实际的API调用暂时模拟创建加密的试用许可证
const response = await this.mockRemoteCheck(this.getDomain(), this.getPluginId());
if (!response.success || !response.jwt) {
return {
isLicenseValid: false,
isRemote: true,
};
}
const jwt = response.jwt;
// 保存 JWT 到本地存储,获取保存的许可证结构
const savedLicense = LicenseStorage.saveLicense(jwt);
if (!savedLicense) {
return {
isLicenseValid: false,
isRemote: true,
};
}
// 使用解析后的JWT进行验证
const isValid = this.checkLicenseAvailable(savedLicense);
return {
isLicenseValid: isValid,
license: savedLicense.licenseInfo,
isRemote: true,
};
} catch (error) {
console.error($t('license.error.fetchFailed', { e: error }));
return {
isLicenseValid: false,
isRemote: 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 $t('license.status.permanent');
return new Date(timestamp).toLocaleDateString(kintone.getLoginUser().language);
}
/**
* 计算剩余天数
*/
static getDaysRemaining(timestamp: number): number {
if (timestamp === -1) return Infinity;
const now = new Date().getTime();
const remainingMs = timestamp - now;
return Math.floor(remainingMs / (24 * 60 * 60 * 1000));
}
// ============ UI显示函数 ============
/**
* 显示到期警告通知
*/
static async showNotification(license: LicenseInfo | undefined, isWarning: boolean) {
let message;
const plugin = this.getPluginName();
if (isWarning) {
// 尚未到期
const remainingDays = this.getDaysRemaining(license!.expiredTime);
if (remainingDays < 0) {
return
}
const days = $t('license.notification.days', remainingDays)
message = $t('license.notification.warning', { plugin, days });
} else {
// 既に期限切れ
message = $t('license.notification.expired', { plugin });
}
if (await kintone.isMobilePage()) {
const notification = new MobileNotification({
content: message,
});
notification.open();
} else {
const link = `https://alicorn.cybozu.com/k/admin/app/${kintone.app.getId()}/plugin/config?pluginId=${this.getPluginId()}`
const notification = new Notification({
content: message + '<br />' + $t('license.notification.gotoLink', { link }),
type: isWarning ? 'info' : 'danger',
});
notification.open();
}
}
// ============ 主要入口函数 ============
/**
* 检查插件功能访问权限并加载插件(如果获得授权)
*/
static async loadPluginIfAuthorized(
pluginId: string,
callback: () => void | Promise<void>,
) {
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($t('license.status.expiredDisplay'));
} else {
// 管理员可以看到过期弹框
this.showNotification(licenseCheck.license, false);
}
return;
}
// 许可证有效,如果快要到期,管理员可以看到警告
if (isManager &&
licenseCheck.license &&
!licenseCheck.license.isPaid &&
this.isExpiringSoon(licenseCheck.license.expiredTime)) {
// 管理员可以看到过期弹框
this.showNotification(licenseCheck.license, true);
}
// 许可证有效,可以加载插件功能
await callback();
} catch (error) {
console.warn($t('license.error.checkFailed'));
}
}
// ============ 许可证信息管理 ============
/**
* 获取许可证信息(用于显示)
*/
static getLocalLicenseInfo(): LicenseInfo | null {
const savedLicense = LicenseStorage.getLicense(this.getPluginId());
if (!savedLicense) return null;
return savedLicense.licenseInfo;
}
/**
* 强制刷新许可证(清除本地缓存重新检查)
*/
static async forceRefreshLicense(): Promise<LicenseCheckResult> {
// 清除本地缓存
LicenseStorage.clearLicense(this.getPluginId());
return await this.checkLicenseRemote();
}
// ============ 模拟/测试函数 ============
/**
* 创建试用许可证
*/
private static mockCreateTrialLicense(): LicenseInfo {
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + this.TRIAL_DATE);
expiryDate.setHours(23, 59, 59, 999);
return {
expiredTime: expiryDate.getTime(),
isPaid: false,
domain: this.getDomain(),
pluginId: this.getPluginId(),
fetchTime: new Date().getTime(),
version: 1,
};
}
/**
* 生成JWT token内部方法
*/
private static generateJWT(licenseInfo: LicenseInfo): string {
const header = {
alg: 'RS256',
typ: 'JWT'
};
const payload = {
...licenseInfo,
iat: Math.floor(Date.now() / 1000)
};
const sHeader = JSON.stringify(header);
const sPayload = JSON.stringify(payload);
const jwt = KJUR.jws.JWS.sign(null, sHeader, sPayload, rsaPrivateKey.trim());
return jwt;
}
/**
* 模拟远程验证生产环境中会被真实API替换
*/
private static async mockRemoteCheck(domain: string, pluginId: string): Promise<{ success: boolean; jwt?: string }> {
// 模拟API调用这里总是返回加密的试用许可证
// 生产环境这里会调用后端API: POST /api/license/check
const license = this.mockCreateTrialLicense();
const jwt = this.generateJWT(license);
return {
success: true,
jwt,
};
}
}

View File

@@ -1,3 +1,4 @@
// 导出所有类型定义
// 主要导出应用设置相关的类型接口
export * from './model';
export * from './license';

48
src/types/license.d.ts vendored Normal file
View File

@@ -0,0 +1,48 @@
import { KJUR } from 'jsrsasign';
// 许可证相关类型定义
export interface LicenseInfo {
// 到期时间戳,后端需要是最后一天的 23:59:59 如果是永久许可证则为 -1
expiredTime: number;
// 是否是永久许可证
isPaid: boolean;
// 域名
domain: string;
// 插件ID
pluginId: string;
// 获取许可证的时间戳
fetchTime: number;
// 版本号
version: number;
}
export interface LicenseSetting {
suppressMsgTime?: number;
hideLicenseAreaIfPaid?: boolean;
}
export interface LicenseCheckResult {
// 是否有效(许可证真正有效可用)
isLicenseValid: boolean;
// 许可证信息
license?: LicenseInfo;
// 是否来自远程检查(从 checkLicenseRemote 返回时为 true
isRemote?: boolean;
}
export interface PermissionConfig {
// 是否可以管理插件(应用管理员权限)
canManagePlugins: boolean;
}
// 扩展 JWSResult将 payloadObj 类型限定为 LicenseInfo
export interface LicenseJWSResult extends Omit<KJUR.jws.JWS.JWSResult, 'payloadObj'> {
payloadObj?: LicenseInfo;
}
// JWT 解码后的结构
export interface SavedLicense {
jwt: string;
parsed: LicenseJWSResult;
licenseInfo: LicenseInfo;
}

View File

@@ -1,6 +1,16 @@
// 导入官方 REST API 客户端的类型定义
import { KintoneFormFieldProperty } from '@kintone/rest-api-client';
declare global {
namespace kintone {
/**
* 判断当前页面是否为移动端页面
* @returns Promise<boolean> 返回是否为移动端页面
*/
function isMobilePage(): Promise<boolean>;
}
}
// KUC 组件事件类型定义
// 定义 kintone UI 组件触发事件的结构
export interface KucEvent<T> {

View File

@@ -0,0 +1,147 @@
// 本地存储加密工具类
import { LicenseService } from '@/services/license-service';
import type { LicenseJWSResult, LicenseSetting, SavedLicense } from '@/types/license';
import { KJUR } from 'jsrsasign';
export class LicenseStorage {
private static readonly STORAGE_KEY_PREFIX = 'alicorns_plugin_';
private static readonly STORAGE_SETTING_KEY_PREFIX = this.STORAGE_KEY_PREFIX + 'setting_';
/**
* 生成存储key
*/
private static generateStorageKey(pluginId: string): string {
return `${this.STORAGE_KEY_PREFIX}${pluginId}`;
}
/**
* 保存 JWT 到本地存储
*/
static saveLicense(jwt: string): SavedLicense | null {
try {
// 从 JWT 中提取 pluginId 以生成存储key
if (!jwt) return null;
// 解码JWT
const savedLicense = this.convertToSavedLicense(jwt);
if (!savedLicense) {
return null;
}
const key = this.generateStorageKey(savedLicense.licenseInfo.pluginId);
localStorage.setItem(key, jwt);
return savedLicense;
} catch (error) {
console.error('Failed to save license:', error);
throw error;
}
}
/**
* 从本地存储获取许可证信息
*/
static getLicense(pluginId: string): SavedLicense | null {
try {
const key = this.generateStorageKey(pluginId);
const storedJWT = localStorage.getItem(key);
if (!storedJWT) return null;
// 解码JWT
const savedLicense = this.convertToSavedLicense(storedJWT);
if (!savedLicense) {
// JWT解析失败清理存储
this.clearLicense(pluginId);
return null;
}
const isValid = LicenseService.checkLicenseAvailable(savedLicense);
if (!isValid) {
// 获取许可证信息失败,清理存储
this.clearLicense(pluginId);
return null;
}
return savedLicense;
} catch (error) {
console.error('Failed to get license:', error);
// 如果获取失败,清空本地存储
this.clearLicense(pluginId);
return null;
}
}
static convertToSavedLicense(storedJWT: string): SavedLicense | null {
if (!storedJWT) return null;
// decode
let decodedJWT: LicenseJWSResult;
try {
decodedJWT = KJUR.jws.JWS.parse(storedJWT) as LicenseJWSResult;
if (!decodedJWT) {
return null;
}
} catch (error) {
console.error('Failed to get license:', error);
return null;
}
return {
jwt: storedJWT,
parsed: decodedJWT,
licenseInfo: decodedJWT.payloadObj!,
}
}
/**
* 清除许可证信息
*/
static clearLicense(pluginId: string): void {
const key = this.generateStorageKey(pluginId);
localStorage.removeItem(key);
}
/**
* 生成存储key
*/
private static generateSettingStorageKey(pluginId: string): string {
return `${this.STORAGE_SETTING_KEY_PREFIX}${pluginId}`;
}
/**
* 保存设置信息到本地存储
*/
static saveSetting(setting: LicenseSetting, pluginId: string): void {
try {
const key = this.generateSettingStorageKey(pluginId);
localStorage.setItem(key, JSON.stringify(setting));
} catch (error) {
console.error('Failed to save setting:', error);
throw error;
}
}
/**
* 从本地存储获取设置信息
*/
static getSettings(pluginId: string): LicenseSetting {
try {
const key = this.generateSettingStorageKey(pluginId);
const storedData = localStorage.getItem(key);
if (!storedData) return {};
return JSON.parse(storedData);
} catch (error) {
console.error('Failed to get setting:', error);
return {};
}
}
/**
* 清除设置信息
*/
static clearSetting(pluginId: string): void {
const key = this.generateSettingStorageKey(pluginId);
localStorage.removeItem(key);
}
}

51
src/utils/permissions.ts Normal file
View File

@@ -0,0 +1,51 @@
// 权限检查工具类
import type { PermissionConfig } from '@/types/license';
import client from '@/plugins/kintoneClient.ts'
export class PermissionService {
/**
* 检查用户是否有管理插件的权限
* 基于kintone的权限系统判断用户是否可以管理应用插件
*/
static async checkPermissions(): Promise<PermissionConfig> {
try {
// 获取应用信息
const appId = kintone.app.getId() || kintone.mobile.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<boolean> {
try {
await client.app.getAppAcl({ app: appId });
return true;
} catch (error) {
// 如果API调用失败默认认为没有权限
return false;
}
}
/**
* 获取默认权限配置(无权限)
*/
private static getDefaultPermissions(): PermissionConfig {
return {
canManagePlugins: false,
};
}
}