diff --git a/dashboard/src2/data/session.js b/dashboard/src2/data/session.js new file mode 100644 index 0000000..1a49ab1 --- /dev/null +++ b/dashboard/src2/data/session.js @@ -0,0 +1,110 @@ +import { computed, reactive } from 'vue'; +import { createResource } from 'jingrow-ui'; +import { clear } from 'idb-keyval'; +import router from '../router'; + +export let session = reactive({ + login: createResource({ + url: 'login', + makeParams({ email, password }) { + return { + usr: email, + pwd: password + }; + } + }), + logout: createResource({ + url: 'logout', + async onSuccess() { + session.user = getSessionUser(); + await router.replace({ name: 'Login' }); + localStorage.removeItem('current_team'); + // On logout, reset posthog user identity and device id + if (window.posthog?.__loaded) { + posthog.reset(true); + } + + // clear all cache from the session + clear(); + + window.location.reload(); + } + }), + logoutWithoutReload: createResource({ + url: 'logout', + async onSuccess() { + session.user = getSessionUser(); + localStorage.removeItem('current_team'); + // On logout, reset posthog user identity and device id + if (window.posthog?.__loaded) { + posthog.reset(true); + } + + clear(); + } + }), + roles: createResource({ + url: 'jcloud.api.account.get_permission_roles', + cache: ['roles', localStorage.getItem('current_team')], + initialData: [] + }), + isTeamAdmin: computed( + () => + session.roles.data.length + ? session.roles.data.some(role => role.admin_access) + : false // if no roles, assume not admin and has member access + ), + hasBillingAccess: computed(() => + session.roles.data.length + ? session.roles.data.some(role => role.allow_billing) + : true + ), + hasWebhookConfigurationAccess: computed(() => + session.roles.data.length + ? session.roles.data.some(role => role.allow_webhook_configuration) + : true + ), + hasAppsAccess: computed(() => + session.roles.data.length + ? session.roles.data.some(role => role.allow_apps) + : true + ), + hasPartnerAccess: computed(() => + session.roles.data.length + ? session.roles.data.some(role => role.allow_partner) + : true + ), + hasSiteCreationAccess: computed(() => + session.roles.data.length + ? session.roles.data.some(role => role.allow_site_creation) + : true + ), + hasBenchCreationAccess: computed(() => + session.roles.data.length + ? session.roles.data.some(role => role.allow_bench_creation) + : true + ), + hasServerCreationAccess: computed(() => + session.roles.data.length + ? session.roles.data.some(role => role.allow_server_creation) + : true + ), + user: getSessionUser(), + isLoggedIn: computed(() => !!session.user), + isSystemUser: getSessionCookies().get('system_user') === 'yes' +}); + +export default session; + +export function getSessionUser() { + let cookies = getSessionCookies(); + let sessionUser = cookies.get('user_id'); + if (!sessionUser || sessionUser === 'Guest') { + sessionUser = null; + } + return sessionUser; +} + +function getSessionCookies() { + return new URLSearchParams(document.cookie.split('; ').join('&')); +} diff --git a/dashboard/src2/objects/common/apps.ts b/dashboard/src2/objects/common/apps.ts new file mode 100644 index 0000000..cbe94e5 --- /dev/null +++ b/dashboard/src2/objects/common/apps.ts @@ -0,0 +1,256 @@ +import { defineAsyncComponent, h } from 'vue'; +import { toast } from 'vue-sonner'; +import { getTeam } from '../../data/team'; +import router from '../../router'; +import { confirmDialog, icon, renderDialog } from '../../utils/components'; +import { planTitle } from '../../utils/format'; +import type { + ColumnField, + DialogConfig, + FilterField, + Tab, + TabList +} from './types'; +import { getUpsellBanner } from '.'; +import { isMobile } from '../../utils/device'; +import { getToastErrorMessage } from '../../utils/toast'; + +export function getAppsTab(forSite: boolean) { + return { + label: '应用', + icon: icon('grid'), + route: 'apps', + type: 'list', + condition: docResource => forSite && docResource.pg?.status !== 'Archived', + list: getAppsTabList(forSite) + } satisfies Tab as Tab; +} + +function getAppsTabList(forSite: boolean) { + const options = forSite ? siteAppListOptions : benchAppListOptions; + const list: TabList = { + pagetype: '', + filters: () => ({}), + ...options, + columns: getAppsTabColumns(forSite), + searchField: !forSite ? 'title' : undefined, + filterControls: r => { + if (forSite) return []; + else + return [ + { + type: 'select', + label: '分支', + class: !isMobile() ? 'w-24' : '', + fieldname: 'branch', + options: [ + '', + ...new Set(r.listResource.data?.map(i => String(i.branch)) || []) + ] + }, + { + type: 'select', + label: '所有者', + class: !isMobile() ? 'w-24' : '', + fieldname: 'repository_owner', + options: [ + '', + ...new Set( + r.listResource.data?.map( + i => String(i.repository_url).split('/').at(-2) || '' + ) || [] + ) + ] + } + ] satisfies FilterField[]; + } + }; + + return list; +} + +function getAppsTabColumns(forSite: boolean) { + const appTabColumns: ColumnField[] = [ + { + label: '应用', + fieldname: 'title', + width: 1, + suffix(row) { + if (!row.is_app_patched) { + return; + } + + return h( + 'div', + { + title: '应用已打补丁', + class: 'rounded-full bg-gray-100 p-1' + }, + h(icon('hash', 'w-3 h-3')) + ); + }, + format: (value, row) => value || row.app_title + }, + { + label: '计划', + width: 0.75, + class: 'text-gray-600 text-sm', + format(_, row) { + const planText = planTitle(row.plan_info); + if (planText) return `${planText}/月`; + else return '免费'; + } + }, + { + label: '仓库', + fieldname: 'repository_url', + format: value => String(value).split('/').slice(-2).join('/'), + link: value => String(value) + }, + { + label: '分支', + fieldname: 'branch', + type: 'Badge', + width: 1, + link: (value, row) => { + return `${row.repository_url}/tree/${value}`; + } + }, + { + label: '提交', + fieldname: 'hash', + type: 'Badge', + width: 1, + link: (value, row) => { + return `${row.repository_url}/commit/${value}`; + }, + format(value) { + return String(value).slice(0, 7); + } + }, + { + label: '提交信息', + fieldname: 'commit_message', + width: '30rem' + } + ]; + + if (forSite) return appTabColumns; + return appTabColumns.filter(c => c.label !== '计划'); +} + +const siteAppListOptions: Partial = { + pagetype: 'Site App', + filters: res => { + return { parenttype: 'Site', parent: res.pg?.name }; + }, + primaryAction({ listResource: apps, documentResource: site }) { + return { + label: '安装应用', + slots: { + prefix: icon('plus') + }, + onClick() { + const InstallAppDialog = defineAsyncComponent( + () => import('../../components/site/InstallAppDialog.vue') + ); + + renderDialog( + h(InstallAppDialog, { + site: site.name, + onInstalled() { + apps.reload(); + } + }) + ); + } + }; + }, + rowActions({ row, listResource: apps, documentResource: site }) { + let $team = getTeam(); + + return [ + { + label: '在 Desk 中查看', + condition: () => $team.pg?.is_desk_user, + onClick() { + window.open(`/app/app-source/${row.name}`, '_blank'); + } + }, + { + label: '更改计划', + condition: () => row.plan_info && row.plans.length > 1, + onClick() { + let SiteAppPlanChangeDialog = defineAsyncComponent( + () => import('../../components/site/SiteAppPlanSelectDialog.vue') + ); + renderDialog( + h(SiteAppPlanChangeDialog, { + app: row, + currentPlan: row.plans.find( + (plan: Record) => plan.name === row.plan_info.name + ), + onPlanChanged() { + apps.reload(); + } + }) + ); + } + }, + { + label: '卸载', + condition: () => row.app !== 'jingrow', + onClick() { + const dialogConfig: DialogConfig = { + title: `卸载应用`, + message: `您确定要从站点 ${site.pg?.name} 卸载应用 ${row.title} 吗?
+ 所有与此应用相关的页面类型和模块将被移除。`, + onSuccess({ hide }) { + if (site.uninstallApp.loading) return; + toast.promise( + site.uninstallApp.submit({ + app: row.app + }), + { + loading: '正在安排应用卸载...', + success: (jobId: string) => { + hide(); + router.push({ + name: 'Site Job', + params: { + name: site.name, + id: jobId + } + }); + return '应用卸载已安排'; + }, + error: (e: Error) => getToastErrorMessage(e) + } + ); + } + }; + confirmDialog(dialogConfig); + } + } + ]; + } +}; + +const benchAppListOptions: Partial = { + pagetype: 'Bench App', + filters: res => { + return { parenttype: 'Bench', parent: res.pg?.name }; + }, + rowActions({ row }) { + let $team = getTeam(); + return [ + { + label: '在 Desk 中查看', + condition: () => $team.pg?.is_desk_user, + onClick() { + window.open(`/app/app-release/${row.release}`, '_blank'); + } + } + ]; + } +}; \ No newline at end of file