已安装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 { 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: 'branch',
type: 'Badge',
width: 1,
}
];
if (forSite) return appTabColumns;
return appTabColumns.filter(c => c.label !== '计划');
}
const siteAppListOptions: Partial<TabList> = {
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<string, any>) => plan.name === row.plan_info.name
),
onPlanChanged() {
apps.reload();
}
})
);
}
},
{
label: '卸载',
condition: () => row.app !== 'jingrow',
onClick() {
const dialogConfig: DialogConfig = {
title: `卸载应用`,
message: `您确定要从站点 <b>${site.pg?.name}</b> 卸载应用 <b>${row.title}</b> 吗?<br>
`,
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<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');
}
}
];
}
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: 'branch',
type: 'Badge',
width: 1,
}
];
// 为站点应用添加操作列,包含卸载按钮
if (forSite) {
appTabColumns.push({
label: '操作',
width: 0.75,
align: 'right',
type: 'Button',
Button: ({ row, listResource, documentResource }) => {
// 如果是 jingrow 应用,不显示卸载按钮
if (row.app === 'jingrow') {
return null;
}
return {
label: '卸载',
variant: 'ghost',
class: 'text-red-600 hover:text-red-700 hover:bg-red-50',
slots: {
prefix: icon('trash-2')
},
onClick: () => {
const appName = row.title || row.app_title;
const dialogConfig: DialogConfig = {
title: `卸载应用`,
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;
const promise = documentResource.uninstallApp.submit({
app: row.app
});
toast.promise(promise, {
loading: '正在安排应用卸载...',
success: (jobId: string) => {
hide();
return '应用卸载已安排';
},
error: (e: Error) => {
// 如果失败,重新加载列表恢复状态
listResource.reload();
return getToastErrorMessage(e);
}
});
}
};
confirmDialog(dialogConfig);
}
};
}
});
return appTabColumns;
}
return appTabColumns.filter(c => c.label !== '计划');
}
const siteAppListOptions: Partial<TabList> = {
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<string, any>) => plan.name === row.plan_info.name
),
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 { icon } from '../../utils/components';
type ListResource = {
data: Record<string, unknown>[];
reload: () => void;
runDocMethod: {
submit: (r: { method: string; [key: string]: any }) => Promise<unknown>;
};
delete: {
submit: (name: string, cb: { onSuccess: () => void }) => Promise<unknown>;
};
};
export interface ResourceBase {
url: string;
auto: boolean;
cache: string[];
}
export interface ResourceWithParams extends ResourceBase {
params: Record<string, unknown>;
}
export interface ResourceWithMakeParams extends ResourceBase {
makeParams: () => Record<string, unknown>;
}
export type Resource = ResourceWithParams | ResourceWithMakeParams;
export interface DocumentResource {
name: string;
pg: Record<string, any>;
[key: string]: any;
}
type Icon = ReturnType<typeof icon>;
type AsyncComponent = ReturnType<typeof defineAsyncComponent>;
export interface DashboardObject {
pagetype: string;
whitelistedMethods: Record<string, string>;
list: List;
detail: Detail;
routes: RouteDetail[];
}
export interface Detail {
titleField: string;
statusBadge: StatusBadge;
breadcrumbs?: Breadcrumbs;
route: string;
tabs: Tab[];
actions: (r: { documentResource: DocumentResource }) => Action[];
}
export interface List {
route: string;
title: string;
fields: string[]; // TODO: Incomplete
searchField: string;
columns: ColumnField[];
orderBy: string;
filterControls: FilterControls;
primaryAction?: PrimaryAction;
}
type R = {
listResource: ListResource;
documentResource: DocumentResource;
};
type FilterControls = (r: R) => FilterField[];
type PrimaryAction = (r: R) => {
label: string;
variant?: string;
slots: {
prefix: Icon;
};
onClick?: () => void;
};
type StatusBadge = (r: { documentResource: DocumentResource }) => {
label: string;
};
export type Breadcrumb = { label: string; route: string };
export type BreadcrumbArgs = {
documentResource: DocumentResource;
items: Breadcrumb[];
};
export type Breadcrumbs = (r: BreadcrumbArgs) => Breadcrumb[];
export interface FilterField {
label: string;
fieldname: string;
type: string;
class?: string;
options?:
| {
pagetype: string;
filters?: {
pagetype_name?: string;
};
}
| string[];
}
export interface ColumnField {
label: string;
fieldname?: string;
class?: string;
width?: string | number;
type?: string;
format?: (value: any, row: Row) => string | undefined;
link?: (value: unknown, row: Row) => string;
prefix?: (row: Row) => Component | undefined;
suffix?: (row: Row) => Component | undefined;
theme?: (value: unknown) => string;
align?: 'left' | 'right';
}
export type Row = Record<string, any>;
export interface Tab {
label: string;
icon: Icon;
route: string;
type: string;
condition?: (r: DocumentResource) => boolean;
childrenRoutes?: string[];
component?: AsyncComponent;
props?: (r: DocumentResource) => Record<string, unknown>;
list?: TabList;
}
export interface TabList {
pagetype?: string;
orderBy?: string;
filters?: (r: DocumentResource) => Record<string, unknown>;
route?: (row: Row) => Route;
pageLength?: number;
columns: ColumnField[];
fields?: Record<string, string[]>[] | string[];
rowActions?: (r: {
row: Row;
listResource: ListResource;
documentResource: DocumentResource;
}) => Action[];
primaryAction?: PrimaryAction;
filterControls?: FilterControls;
banner?: (r: {
documentResource: DocumentResource;
}) => BannerConfig | undefined;
searchField?: string;
experimental?: boolean;
documentation?: string;
resource?: (r: { documentResource: DocumentResource }) => Resource;
}
interface Action {
label: string;
slots?: {
prefix?: Icon;
};
theme?: string;
variant?: string;
onClick?: () => void;
condition?: () => boolean;
route?: Route;
options?: Option[];
}
export interface Route {
name: string;
params: Record<string, unknown>;
}
export interface RouteDetail {
name: string;
path: string;
component: Component;
}
interface Option {
label: string;
icon: Icon | AsyncComponent;
condition: () => boolean;
onClick: () => void;
}
export interface BannerConfig {
title: string;
}
dismissable: boolean;
id: string;
type?: string;
button?: {
label: string;
variant: string;
onClick?: () => void;
};
}
export interface DialogConfig {
title: string;
message: string;
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;
import type { defineAsyncComponent, h, Component } from 'vue';
import type { icon } from '../../utils/components';
type ListResource = {
data: Record<string, unknown>[];
reload: () => void;
runDocMethod: {
submit: (r: { method: string; [key: string]: any }) => Promise<unknown>;
};
delete: {
submit: (name: string, cb: { onSuccess: () => void }) => Promise<unknown>;
};
};
export interface ResourceBase {
url: string;
auto: boolean;
cache: string[];
}
export interface ResourceWithParams extends ResourceBase {
params: Record<string, unknown>;
}
export interface ResourceWithMakeParams extends ResourceBase {
makeParams: () => Record<string, unknown>;
}
export type Resource = ResourceWithParams | ResourceWithMakeParams;
export interface DocumentResource {
name: string;
pg: Record<string, any>;
[key: string]: any;
}
type Icon = ReturnType<typeof icon>;
type AsyncComponent = ReturnType<typeof defineAsyncComponent>;
export interface DashboardObject {
pagetype: string;
whitelistedMethods: Record<string, string>;
list: List;
detail: Detail;
routes: RouteDetail[];
}
export interface Detail {
titleField: string;
statusBadge: StatusBadge;
breadcrumbs?: Breadcrumbs;
route: string;
tabs: Tab[];
actions: (r: { documentResource: DocumentResource }) => Action[];
}
export interface List {
route: string;
title: string;
fields: string[]; // TODO: Incomplete
searchField: string;
columns: ColumnField[];
orderBy: string;
filterControls: FilterControls;
primaryAction?: PrimaryAction;
}
type R = {
listResource: ListResource;
documentResource: DocumentResource;
};
type FilterControls = (r: R) => FilterField[];
type PrimaryAction = (r: R) => {
label: string;
variant?: string;
slots: {
prefix: Icon;
};
onClick?: () => void;
};
type StatusBadge = (r: { documentResource: DocumentResource }) => {
label: string;
};
export type Breadcrumb = { label: string; route: string };
export type BreadcrumbArgs = {
documentResource: DocumentResource;
items: Breadcrumb[];
};
export type Breadcrumbs = (r: BreadcrumbArgs) => Breadcrumb[];
export interface FilterField {
label: string;
fieldname: string;
type: string;
class?: string;
options?:
| {
pagetype: string;
filters?: {
pagetype_name?: string;
};
}
| string[];
}
export interface ColumnField {
label: string;
fieldname?: string;
class?: string;
width?: string | number;
type?: string;
format?: (value: any, row: Row) => string | undefined;
link?: (value: unknown, row: Row) => string;
prefix?: (row: Row) => Component | undefined;
suffix?: (row: Row) => Component | undefined;
theme?: (value: unknown) => string;
align?: 'left' | 'right';
Button?: (r: {
row: Row;
listResource: ListResource;
documentResource: DocumentResource;
}) => {
label: string;
variant?: string;
class?: string;
slots?: {
prefix?: Icon;
};
onClick?: () => void;
} | null;
}
export type Row = Record<string, any>;
export interface Tab {
label: string;
icon: Icon;
route: string;
type: string;
condition?: (r: DocumentResource) => boolean;
childrenRoutes?: string[];
component?: AsyncComponent;
props?: (r: DocumentResource) => Record<string, unknown>;
list?: TabList;
}
export interface TabList {
pagetype?: string;
orderBy?: string;
filters?: (r: DocumentResource) => Record<string, unknown>;
route?: (row: Row) => Route;
pageLength?: number;
columns: ColumnField[];
fields?: Record<string, string[]>[] | string[];
rowActions?: (r: {
row: Row;
listResource: ListResource;
documentResource: DocumentResource;
}) => Action[];
primaryAction?: PrimaryAction;
filterControls?: FilterControls;
banner?: (r: {
documentResource: DocumentResource;
}) => BannerConfig | undefined;
searchField?: string;
experimental?: boolean;
documentation?: string;
resource?: (r: { documentResource: DocumentResource }) => Resource;
}
interface Action {
label: string;
slots?: {
prefix?: Icon;
};
theme?: string;
variant?: string;
onClick?: () => void;
condition?: () => boolean;
route?: Route;
options?: Option[];
}
export interface Route {
name: string;
params: Record<string, unknown>;
}
export interface RouteDetail {
name: string;
path: string;
component: Component;
}
interface Option {
label: string;
icon: Icon | AsyncComponent;
condition: () => boolean;
onClick: () => void;
}
export interface BannerConfig {
title: string;
dismissable: boolean;
id: string;
type?: string;
button?: {
label: string;
variant: string;
onClick?: () => void;
};
}
export interface DialogConfig {
title: string;
message: string;
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 { getAppsTab } from './common/apps';
import { isMobile } from '../utils/device';
import { getJobsTab } from './common/jobs';
export default {
pagetype: 'Site',
@ -881,6 +882,7 @@ export default {
},
},
},
getJobsTab('Site'),
{
label: '操作',
icon: icon('sliders'),
@ -1340,5 +1342,10 @@ export default {
path: 'updates/:id',
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)
if status not in allowed_status:
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)

1094
yarn.lock

File diff suppressed because it is too large Load Diff