已安装app后增加卸载按钮

This commit is contained in:
jingrow 2025-06-30 12:45:25 +08:00
parent 07133169da
commit d14334fe03
6 changed files with 2997 additions and 2947 deletions

View File

@ -1,230 +1,284 @@
import { defineAsyncComponent, h } from 'vue'; import { defineAsyncComponent, h } from 'vue';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { getTeam } from '../../data/team'; import { getTeam } from '../../data/team';
import router from '../../router'; import router from '../../router';
import { confirmDialog, icon, renderDialog } from '../../utils/components'; import { confirmDialog, icon, renderDialog } from '../../utils/components';
import { planTitle } from '../../utils/format'; import { planTitle } from '../../utils/format';
import type { import type {
ColumnField, ColumnField,
DialogConfig, DialogConfig,
FilterField, FilterField,
Tab, Tab,
TabList TabList
} from './types'; } from './types';
import { getUpsellBanner } from '.'; import { getUpsellBanner } from '.';
import { isMobile } from '../../utils/device'; import { isMobile } from '../../utils/device';
import { getToastErrorMessage } from '../../utils/toast'; import { getToastErrorMessage } from '../../utils/toast';
export function getAppsTab(forSite: boolean) { export function getAppsTab(forSite: boolean) {
return { return {
label: '应用', label: '应用',
icon: icon('grid'), icon: icon('grid'),
route: 'apps', route: 'apps',
type: 'list', type: 'list',
condition: docResource => forSite && docResource.pg?.status !== 'Archived', condition: docResource => forSite && docResource.pg?.status !== 'Archived',
list: getAppsTabList(forSite) list: getAppsTabList(forSite)
} satisfies Tab as Tab; } satisfies Tab as Tab;
} }
function getAppsTabList(forSite: boolean) { function getAppsTabList(forSite: boolean) {
const options = forSite ? siteAppListOptions : benchAppListOptions; const options = forSite ? siteAppListOptions : benchAppListOptions;
const list: TabList = { const list: TabList = {
pagetype: '', pagetype: '',
filters: () => ({}), filters: () => ({}),
...options, ...options,
columns: getAppsTabColumns(forSite), columns: getAppsTabColumns(forSite),
searchField: !forSite ? 'title' : undefined, searchField: !forSite ? 'title' : undefined,
filterControls: r => { filterControls: r => {
if (forSite) return []; if (forSite) return [];
else else
return [ return [
{ {
type: 'select', type: 'select',
label: '分支', label: '分支',
class: !isMobile() ? 'w-24' : '', class: !isMobile() ? 'w-24' : '',
fieldname: 'branch', fieldname: 'branch',
options: [ options: [
'', '',
...new Set(r.listResource.data?.map(i => String(i.branch)) || []) ...new Set(r.listResource.data?.map(i => String(i.branch)) || [])
] ]
}, },
{ {
type: 'select', type: 'select',
label: '所有者', label: '所有者',
class: !isMobile() ? 'w-24' : '', class: !isMobile() ? 'w-24' : '',
fieldname: 'repository_owner', fieldname: 'repository_owner',
options: [ options: [
'', '',
...new Set( ...new Set(
r.listResource.data?.map( r.listResource.data?.map(
i => String(i.repository_url).split('/').at(-2) || '' i => String(i.repository_url).split('/').at(-2) || ''
) || [] ) || []
) )
] ]
} }
] satisfies FilterField[]; ] satisfies FilterField[];
} }
}; };
return list; return list;
} }
function getAppsTabColumns(forSite: boolean) { function getAppsTabColumns(forSite: boolean) {
const appTabColumns: ColumnField[] = [ const appTabColumns: ColumnField[] = [
{ {
label: '应用', label: '应用',
fieldname: 'title', fieldname: 'title',
width: 1, width: 1,
suffix(row) { suffix(row) {
if (!row.is_app_patched) { if (!row.is_app_patched) {
return; return;
} }
return h( return h(
'div', 'div',
{ {
title: '应用已打补丁', title: '应用已打补丁',
class: 'rounded-full bg-gray-100 p-1' class: 'rounded-full bg-gray-100 p-1'
}, },
h(icon('hash', 'w-3 h-3')) h(icon('hash', 'w-3 h-3'))
); );
}, },
format: (value, row) => value || row.app_title format: (value, row) => value || row.app_title
}, },
{ {
label: '计划', label: '计划',
width: 0.75, width: 0.75,
class: 'text-gray-600 text-sm', class: 'text-gray-600 text-sm',
format(_, row) { format(_, row) {
const planText = planTitle(row.plan_info); const planText = planTitle(row.plan_info);
if (planText) return `${planText}/月`; if (planText) return `${planText}/月`;
else return '免费'; else return '免费';
} }
}, },
{ {
label: '版本', label: '版本',
fieldname: 'branch', fieldname: 'branch',
type: 'Badge', type: 'Badge',
width: 1, width: 1,
} }
]; ];
if (forSite) return appTabColumns; // 为站点应用添加操作列,包含卸载按钮
return appTabColumns.filter(c => c.label !== '计划'); if (forSite) {
} appTabColumns.push({
label: '操作',
const siteAppListOptions: Partial<TabList> = { width: 0.75,
pagetype: 'Site App', align: 'right',
filters: res => { type: 'Button',
return { parenttype: 'Site', parent: res.pg?.name }; Button: ({ row, listResource, documentResource }) => {
}, // 如果是 jingrow 应用,不显示卸载按钮
primaryAction({ listResource: apps, documentResource: site }) { if (row.app === 'jingrow') {
return { return null;
label: '安装应用', }
slots: {
prefix: icon('plus') return {
}, label: '卸载',
onClick() { variant: 'ghost',
const InstallAppDialog = defineAsyncComponent( class: 'text-red-600 hover:text-red-700 hover:bg-red-50',
() => import('../../components/site/InstallAppDialog.vue') slots: {
); prefix: icon('trash-2')
},
renderDialog( onClick: () => {
h(InstallAppDialog, { const appName = row.title || row.app_title;
site: site.name, const dialogConfig: DialogConfig = {
onInstalled() { title: `卸载应用`,
apps.reload(); message: `您确定要从站点 <b>${documentResource.pg?.name}</b> 卸载应用 <b>${appName}</b> 吗?<br>
} `,
}) onSuccess({ hide }) {
); // 立即从列表中移除该应用(乐观更新)
} const currentData = listResource.data || [];
}; const updatedData = currentData.filter(item => item.name !== row.name);
}, listResource.data = updatedData;
rowActions({ row, listResource: apps, documentResource: site }) {
let $team = getTeam(); const promise = documentResource.uninstallApp.submit({
app: row.app
return [ });
{
label: '在 Desk 中查看', toast.promise(promise, {
condition: () => $team.pg?.is_desk_user, loading: '正在安排应用卸载...',
onClick() { success: (jobId: string) => {
window.open(`/app/app-source/${row.name}`, '_blank'); hide();
} return '应用卸载已安排';
}, },
{ error: (e: Error) => {
label: '更改计划', // 如果失败,重新加载列表恢复状态
condition: () => row.plan_info && row.plans.length > 1, listResource.reload();
onClick() { return getToastErrorMessage(e);
let SiteAppPlanChangeDialog = defineAsyncComponent( }
() => import('../../components/site/SiteAppPlanSelectDialog.vue') });
); }
renderDialog( };
h(SiteAppPlanChangeDialog, { confirmDialog(dialogConfig);
app: row, }
currentPlan: row.plans.find( };
(plan: Record<string, any>) => plan.name === row.plan_info.name }
), });
onPlanChanged() {
apps.reload(); return appTabColumns;
} }
})
); return appTabColumns.filter(c => c.label !== '计划');
} }
},
{ const siteAppListOptions: Partial<TabList> = {
label: '卸载', pagetype: 'Site App',
condition: () => row.app !== 'jingrow', filters: res => {
onClick() { return { parenttype: 'Site', parent: res.pg?.name };
const dialogConfig: DialogConfig = { },
title: `卸载应用`, primaryAction({ listResource: apps, documentResource: site }) {
message: `您确定要从站点 <b>${site.pg?.name}</b> 卸载应用 <b>${row.title}</b> 吗?<br> return {
`, label: '安装应用',
onSuccess({ hide }) { slots: {
if (site.uninstallApp.loading) return; prefix: icon('plus')
toast.promise( },
site.uninstallApp.submit({ onClick() {
app: row.app const InstallAppDialog = defineAsyncComponent(
}), () => import('../../components/site/InstallAppDialog.vue')
{ );
loading: '正在安排应用卸载...',
success: (jobId: string) => { renderDialog(
hide(); h(InstallAppDialog, {
router.push({ site: site.name,
name: 'Site Job', onInstalled() {
params: { apps.reload();
name: site.name, }
id: jobId })
} );
}); }
return '应用卸载已安排'; };
}, },
error: (e: Error) => getToastErrorMessage(e) rowActions({ row, listResource: apps, documentResource: site }) {
} let $team = getTeam();
);
} return [
}; {
confirmDialog(dialogConfig); label: '在 Desk 中查看',
} condition: () => $team.pg?.is_desk_user,
} onClick() {
]; window.open(`/app/app-source/${row.name}`, '_blank');
} }
}; },
{
const benchAppListOptions: Partial<TabList> = { label: '更改计划',
pagetype: 'Bench App', condition: () => row.plan_info && row.plans.length > 1,
filters: res => { onClick() {
return { parenttype: 'Bench', parent: res.pg?.name }; let SiteAppPlanChangeDialog = defineAsyncComponent(
}, () => import('../../components/site/SiteAppPlanSelectDialog.vue')
rowActions({ row }) { );
let $team = getTeam(); renderDialog(
return [ h(SiteAppPlanChangeDialog, {
{ app: row,
label: '在 Desk 中查看', currentPlan: row.plans.find(
condition: () => $team.pg?.is_desk_user, (plan: Record<string, any>) => plan.name === row.plan_info.name
onClick() { ),
window.open(`/app/app-release/${row.release}`, '_blank'); onPlanChanged() {
} apps.reload();
} }
]; })
} );
}
},
{
label: '卸载',
condition: () => row.app !== 'jingrow',
onClick() {
const appName = row.title || row.app_title;
const dialogConfig: DialogConfig = {
title: `卸载应用`,
message: `您确定要从站点 <b>${site.pg?.name}</b> 卸载应用 <b>${appName}</b> 吗?<br>
`,
onSuccess({ hide }) {
toast.promise(
site.uninstallApp.submit({
app: row.app
}),
{
loading: '正在安排应用卸载...',
success: (jobId: string) => {
hide();
apps.reload();
return '应用卸载已安排';
},
error: (e: Error) => {
return getToastErrorMessage(e);
}
}
);
}
};
confirmDialog(dialogConfig);
}
}
];
}
};
const benchAppListOptions: Partial<TabList> = {
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');
}
}
];
}
}; };

View File

@ -1,216 +1,228 @@
import type { defineAsyncComponent, h, Component } from 'vue'; import type { defineAsyncComponent, h, Component } from 'vue';
import type { icon } from '../../utils/components'; import type { icon } from '../../utils/components';
type ListResource = { type ListResource = {
data: Record<string, unknown>[]; data: Record<string, unknown>[];
reload: () => void; reload: () => void;
runDocMethod: { runDocMethod: {
submit: (r: { method: string; [key: string]: any }) => Promise<unknown>; submit: (r: { method: string; [key: string]: any }) => Promise<unknown>;
}; };
delete: { delete: {
submit: (name: string, cb: { onSuccess: () => void }) => Promise<unknown>; submit: (name: string, cb: { onSuccess: () => void }) => Promise<unknown>;
}; };
}; };
export interface ResourceBase { export interface ResourceBase {
url: string; url: string;
auto: boolean; auto: boolean;
cache: string[]; cache: string[];
} }
export interface ResourceWithParams extends ResourceBase { export interface ResourceWithParams extends ResourceBase {
params: Record<string, unknown>; params: Record<string, unknown>;
} }
export interface ResourceWithMakeParams extends ResourceBase { export interface ResourceWithMakeParams extends ResourceBase {
makeParams: () => Record<string, unknown>; makeParams: () => Record<string, unknown>;
} }
export type Resource = ResourceWithParams | ResourceWithMakeParams; export type Resource = ResourceWithParams | ResourceWithMakeParams;
export interface DocumentResource { export interface DocumentResource {
name: string; name: string;
pg: Record<string, any>; pg: Record<string, any>;
[key: string]: any; [key: string]: any;
} }
type Icon = ReturnType<typeof icon>; type Icon = ReturnType<typeof icon>;
type AsyncComponent = ReturnType<typeof defineAsyncComponent>; type AsyncComponent = ReturnType<typeof defineAsyncComponent>;
export interface DashboardObject { export interface DashboardObject {
pagetype: string; pagetype: string;
whitelistedMethods: Record<string, string>; whitelistedMethods: Record<string, string>;
list: List; list: List;
detail: Detail; detail: Detail;
routes: RouteDetail[]; routes: RouteDetail[];
} }
export interface Detail { export interface Detail {
titleField: string; titleField: string;
statusBadge: StatusBadge; statusBadge: StatusBadge;
breadcrumbs?: Breadcrumbs; breadcrumbs?: Breadcrumbs;
route: string; route: string;
tabs: Tab[]; tabs: Tab[];
actions: (r: { documentResource: DocumentResource }) => Action[]; actions: (r: { documentResource: DocumentResource }) => Action[];
} }
export interface List { export interface List {
route: string; route: string;
title: string; title: string;
fields: string[]; // TODO: Incomplete fields: string[]; // TODO: Incomplete
searchField: string; searchField: string;
columns: ColumnField[]; columns: ColumnField[];
orderBy: string; orderBy: string;
filterControls: FilterControls; filterControls: FilterControls;
primaryAction?: PrimaryAction; primaryAction?: PrimaryAction;
} }
type R = { type R = {
listResource: ListResource; listResource: ListResource;
documentResource: DocumentResource; documentResource: DocumentResource;
}; };
type FilterControls = (r: R) => FilterField[]; type FilterControls = (r: R) => FilterField[];
type PrimaryAction = (r: R) => { type PrimaryAction = (r: R) => {
label: string; label: string;
variant?: string; variant?: string;
slots: { slots: {
prefix: Icon; prefix: Icon;
}; };
onClick?: () => void; onClick?: () => void;
}; };
type StatusBadge = (r: { documentResource: DocumentResource }) => { type StatusBadge = (r: { documentResource: DocumentResource }) => {
label: string; label: string;
}; };
export type Breadcrumb = { label: string; route: string }; export type Breadcrumb = { label: string; route: string };
export type BreadcrumbArgs = { export type BreadcrumbArgs = {
documentResource: DocumentResource; documentResource: DocumentResource;
items: Breadcrumb[]; items: Breadcrumb[];
}; };
export type Breadcrumbs = (r: BreadcrumbArgs) => Breadcrumb[]; export type Breadcrumbs = (r: BreadcrumbArgs) => Breadcrumb[];
export interface FilterField { export interface FilterField {
label: string; label: string;
fieldname: string; fieldname: string;
type: string; type: string;
class?: string; class?: string;
options?: options?:
| { | {
pagetype: string; pagetype: string;
filters?: { filters?: {
pagetype_name?: string; pagetype_name?: string;
}; };
} }
| string[]; | string[];
} }
export interface ColumnField { export interface ColumnField {
label: string; label: string;
fieldname?: string; fieldname?: string;
class?: string; class?: string;
width?: string | number; width?: string | number;
type?: string; type?: string;
format?: (value: any, row: Row) => string | undefined; format?: (value: any, row: Row) => string | undefined;
link?: (value: unknown, row: Row) => string; link?: (value: unknown, row: Row) => string;
prefix?: (row: Row) => Component | undefined; prefix?: (row: Row) => Component | undefined;
suffix?: (row: Row) => Component | undefined; suffix?: (row: Row) => Component | undefined;
theme?: (value: unknown) => string; theme?: (value: unknown) => string;
align?: 'left' | 'right'; align?: 'left' | 'right';
} Button?: (r: {
row: Row;
export type Row = Record<string, any>; listResource: ListResource;
documentResource: DocumentResource;
export interface Tab { }) => {
label: string; label: string;
icon: Icon; variant?: string;
route: string; class?: string;
type: string; slots?: {
condition?: (r: DocumentResource) => boolean; prefix?: Icon;
childrenRoutes?: string[]; };
component?: AsyncComponent; onClick?: () => void;
props?: (r: DocumentResource) => Record<string, unknown>; } | null;
list?: TabList; }
}
export type Row = Record<string, any>;
export interface TabList {
pagetype?: string; export interface Tab {
orderBy?: string; label: string;
filters?: (r: DocumentResource) => Record<string, unknown>; icon: Icon;
route?: (row: Row) => Route; route: string;
pageLength?: number; type: string;
columns: ColumnField[]; condition?: (r: DocumentResource) => boolean;
fields?: Record<string, string[]>[] | string[]; childrenRoutes?: string[];
rowActions?: (r: { component?: AsyncComponent;
row: Row; props?: (r: DocumentResource) => Record<string, unknown>;
listResource: ListResource; list?: TabList;
documentResource: DocumentResource; }
}) => Action[];
primaryAction?: PrimaryAction; export interface TabList {
filterControls?: FilterControls; pagetype?: string;
banner?: (r: { orderBy?: string;
documentResource: DocumentResource; filters?: (r: DocumentResource) => Record<string, unknown>;
}) => BannerConfig | undefined; route?: (row: Row) => Route;
searchField?: string; pageLength?: number;
experimental?: boolean; columns: ColumnField[];
documentation?: string; fields?: Record<string, string[]>[] | string[];
resource?: (r: { documentResource: DocumentResource }) => Resource; rowActions?: (r: {
} row: Row;
listResource: ListResource;
interface Action { documentResource: DocumentResource;
label: string; }) => Action[];
slots?: { primaryAction?: PrimaryAction;
prefix?: Icon; filterControls?: FilterControls;
}; banner?: (r: {
theme?: string; documentResource: DocumentResource;
variant?: string; }) => BannerConfig | undefined;
onClick?: () => void; searchField?: string;
condition?: () => boolean; experimental?: boolean;
route?: Route; documentation?: string;
options?: Option[]; resource?: (r: { documentResource: DocumentResource }) => Resource;
} }
export interface Route { interface Action {
name: string; label: string;
params: Record<string, unknown>; slots?: {
} prefix?: Icon;
};
export interface RouteDetail { theme?: string;
name: string; variant?: string;
path: string; onClick?: () => void;
component: Component; condition?: () => boolean;
} route?: Route;
options?: Option[];
interface Option { }
label: string;
icon: Icon | AsyncComponent; export interface Route {
condition: () => boolean; name: string;
onClick: () => void; params: Record<string, unknown>;
} }
export interface BannerConfig { export interface RouteDetail {
title: string; name: string;
} path: string;
dismissable: boolean; component: Component;
id: string; }
type?: string;
button?: { interface Option {
label: string; label: string;
variant: string; icon: Icon | AsyncComponent;
onClick?: () => void; condition: () => boolean;
}; onClick: () => void;
} }
export interface DialogConfig { export interface BannerConfig {
title: string; title: string;
message: string; dismissable: boolean;
primaryAction?: { onClick: () => void }; id: string;
onSuccess?: (o: { hide: () => void }) => void; type?: string;
} button?: {
label: string;
export interface Process { variant: string;
program: string; onClick?: () => void;
name: string; };
status: string; }
uptime?: number;
uptime_string?: string; export interface DialogConfig {
message?: string; title: string;
group?: string; message: string;
pid?: number; primaryAction?: { onClick: () => void };
onSuccess?: (o: { hide: () => void }) => void;
}
export interface Process {
program: string;
name: string;
status: string;
uptime?: number;
uptime_string?: string;
message?: string;
group?: string;
pid?: number;
} }

View File

@ -22,6 +22,7 @@ import { trialDays } from '../utils/site';
import { clusterOptions, getUpsellBanner } from './common'; import { clusterOptions, getUpsellBanner } from './common';
import { getAppsTab } from './common/apps'; import { getAppsTab } from './common/apps';
import { isMobile } from '../utils/device'; import { isMobile } from '../utils/device';
import { getJobsTab } from './common/jobs';
export default { export default {
pagetype: 'Site', pagetype: 'Site',
@ -881,6 +882,7 @@ export default {
}, },
}, },
}, },
getJobsTab('Site'),
{ {
label: '操作', label: '操作',
icon: icon('sliders'), icon: icon('sliders'),
@ -1340,5 +1342,10 @@ export default {
path: 'updates/:id', path: 'updates/:id',
component: () => import('../pages/SiteUpdate.vue'), component: () => import('../pages/SiteUpdate.vue'),
}, },
{
name: 'Site Job',
path: 'jobs/:id',
component: () => import('../pages/JobPage.vue')
}
], ],
}; };

File diff suppressed because it is too large Load Diff

View File

@ -299,7 +299,7 @@ class Site(Document, TagHelpers):
status = jingrow.get_value(inst.pagetype, inst.name, "status", for_update=True) status = jingrow.get_value(inst.pagetype, inst.name, "status", for_update=True)
if status not in allowed_status: if status not in allowed_status:
jingrow.throw( jingrow.throw(
f"Site action not allowed for site with status: {jingrow.bold(status)}.\nAllowed status are: {jingrow.bold(comma_and(allowed_status))}." f"不允许对状态为 {jingrow.bold(status)} 的站点执行此操作。\n允许的状态为:{jingrow.bold(comma_and(allowed_status))}"
) )
return func(inst, *args, **kwargs) return func(inst, *args, **kwargs)

1094
yarn.lock

File diff suppressed because it is too large Load Diff