发布 v1.10 #5
@@ -8,7 +8,7 @@ describe('page data recovery', () => {
|
||||
it('loads the restored page and query without requesting the first page', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { records: [], totalRow: 30 } });
|
||||
.mockResolvedValue({ data: { records: [], totalRow: 50 } });
|
||||
const wrapper = mount(PageData, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
@@ -60,6 +60,37 @@ describe('page data recovery', () => {
|
||||
expect(wrapper.text()).not.toContain('数据加载失败,请重试');
|
||||
});
|
||||
|
||||
it('contracts an out-of-range page to the latest page with one retry', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: { records: [], totalRow: 21 } })
|
||||
.mockResolvedValueOnce({
|
||||
data: { records: [{ id: 'last-record' }], totalRow: 21 },
|
||||
});
|
||||
const wrapper = mount(PageData, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
},
|
||||
props: {
|
||||
initialPageNumber: 4,
|
||||
pageSize: 10,
|
||||
pageUrl: '/page',
|
||||
requestClient: { get },
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(get.mock.calls.map((call) => call[1].params.pageNumber)).toEqual([
|
||||
4, 3,
|
||||
]);
|
||||
expect((wrapper.vm as any).getPageState()).toEqual({
|
||||
pageNumber: 3,
|
||||
pageSize: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('coalesces repeated reloads while a page request is still running', async () => {
|
||||
let resolveInitialRequest: (value: {
|
||||
data: { records: never[]; totalRow: number };
|
||||
@@ -90,4 +121,85 @@ describe('page data recovery', () => {
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('restores a mounted list to a new route state with one target request', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { records: [], totalRow: 100 } });
|
||||
const wrapper = mount(PageData, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
},
|
||||
props: { pageUrl: '/page', requestClient: { get } },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
(wrapper.vm as any).restoreState({
|
||||
pageNumber: 4,
|
||||
pageSize: 20,
|
||||
queryParams: { title: '月报' },
|
||||
}),
|
||||
).toBe(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(get).toHaveBeenLastCalledWith('/page', {
|
||||
params: { pageNumber: 4, pageSize: 20, title: '月报' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not request again when the restored state is unchanged', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { records: [], totalRow: 50 } });
|
||||
const wrapper = mount(PageData, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
},
|
||||
props: {
|
||||
initialPageNumber: 3,
|
||||
initialQueryParams: { categoryId: undefined, title: '月报' },
|
||||
pageSize: 20,
|
||||
pageUrl: '/page',
|
||||
requestClient: { get },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
(wrapper.vm as any).restoreState({
|
||||
pageNumber: 3,
|
||||
pageSize: 20,
|
||||
queryParams: { title: '月报' },
|
||||
}),
|
||||
).toBe(false);
|
||||
await flushPromises();
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('detaches query state from the caller reactive object', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { records: [], totalRow: 0 } });
|
||||
const wrapper = mount(PageData, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
},
|
||||
props: { pageUrl: '/page', requestClient: { get } },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const query = { title: '月报' };
|
||||
(wrapper.vm as any).setQuery(query);
|
||||
await flushPromises();
|
||||
query.title = '未提交草稿';
|
||||
await flushPromises();
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(get).toHaveBeenLastCalledWith('/page', {
|
||||
params: { pageNumber: 1, pageSize: 10, title: '月报' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,10 @@ interface PageDataState {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface PageDataRestoreState extends PageDataState {
|
||||
queryParams?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PageDataReloadOptions {
|
||||
silent?: boolean;
|
||||
}
|
||||
@@ -49,7 +53,9 @@ const emit = defineEmits<{
|
||||
const pageList = ref<PageDataRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadError = ref<unknown>();
|
||||
const queryParams = ref<Record<string, any>>({ ...props.initialQueryParams });
|
||||
const queryParams = ref<Record<string, any>>(
|
||||
normalizeQueryParams(props.initialQueryParams),
|
||||
);
|
||||
let activePageRequest: null | Promise<void> = null;
|
||||
let pendingPageRequest: null | PageDataRequest = null;
|
||||
let pageRequestVersion = 0;
|
||||
@@ -80,8 +86,16 @@ const loadPageListOnce = async (request: PageDataRequest) => {
|
||||
...queryParams.value,
|
||||
});
|
||||
if (request.version === pageRequestVersion) {
|
||||
const rawTotal = Number(res.data?.totalRow || 0);
|
||||
const total = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : 0;
|
||||
const lastPage = Math.max(1, Math.ceil(total / pageInfo.pageSize));
|
||||
pageInfo.total = total;
|
||||
if (pageInfo.pageNumber > lastPage) {
|
||||
pageInfo.pageNumber = lastPage;
|
||||
emitPageState();
|
||||
return;
|
||||
}
|
||||
pageList.value = res.data?.records || [];
|
||||
pageInfo.total = res.data?.totalRow || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
if (request.version === pageRequestVersion) {
|
||||
@@ -151,6 +165,24 @@ const getPageState = (): PageDataState => ({
|
||||
pageSize: pageInfo.pageSize,
|
||||
});
|
||||
|
||||
function normalizeQueryParams(value: Record<string, any> = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, item]) => item !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function queryParamsEqual(
|
||||
left: Record<string, any>,
|
||||
right: Record<string, any>,
|
||||
) {
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
return (
|
||||
leftKeys.length === rightKeys.length &&
|
||||
leftKeys.every((key) => Object.is(left[key], right[key]))
|
||||
);
|
||||
}
|
||||
|
||||
const emitPageState = () => {
|
||||
emit('stateChange', getPageState());
|
||||
};
|
||||
@@ -178,15 +210,37 @@ const patchRowById = (
|
||||
const setQuery = (newQueryParams: Record<string, any>) => {
|
||||
pageInfo.pageNumber = 1;
|
||||
pageInfo.pageSize = props.pageSize;
|
||||
queryParams.value = newQueryParams;
|
||||
queryParams.value = normalizeQueryParams(newQueryParams);
|
||||
emitPageState();
|
||||
};
|
||||
|
||||
/**
|
||||
* 将已挂载的列表恢复为路由指定状态,同一状态不会重复发起请求。
|
||||
*/
|
||||
const restoreState = (state: PageDataRestoreState) => {
|
||||
const nextPageNumber = Math.max(1, Math.trunc(state.pageNumber));
|
||||
const nextPageSize = Math.max(1, Math.trunc(state.pageSize));
|
||||
const nextQueryParams = normalizeQueryParams(state.queryParams);
|
||||
if (
|
||||
pageInfo.pageNumber === nextPageNumber &&
|
||||
pageInfo.pageSize === nextPageSize &&
|
||||
queryParamsEqual(queryParams.value, nextQueryParams)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
pageInfo.pageNumber = nextPageNumber;
|
||||
pageInfo.pageSize = nextPageSize;
|
||||
queryParams.value = nextQueryParams;
|
||||
emitPageState();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
getPageState,
|
||||
reload,
|
||||
patchRowById,
|
||||
restoreState,
|
||||
setQuery,
|
||||
});
|
||||
|
||||
|
||||
129
easyflow-ui-admin/app/src/composables/useListRouteState.test.ts
Normal file
129
easyflow-ui-admin/app/src/composables/useListRouteState.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { nextTick, reactive } from 'vue';
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildListRouteFullPath,
|
||||
buildListRouteStateQuery,
|
||||
defineListRouteStateSchema,
|
||||
enumListRouteField,
|
||||
mergeListRouteStateQuery,
|
||||
parseListRouteState,
|
||||
positiveIntegerListRouteField,
|
||||
stringListRouteField,
|
||||
watchListRouteState,
|
||||
} from './useListRouteState';
|
||||
|
||||
interface TestListState {
|
||||
categoryId: string;
|
||||
keyword: string;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
status: 'all' | 'enabled';
|
||||
}
|
||||
|
||||
const schema = defineListRouteStateSchema<TestListState>({
|
||||
path: '/items',
|
||||
fields: {
|
||||
categoryId: stringListRouteField(),
|
||||
keyword: stringListRouteField({ trim: true }),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [10, 20],
|
||||
defaultValue: 10,
|
||||
}),
|
||||
status: enumListRouteField(['all', 'enabled'], 'all'),
|
||||
},
|
||||
});
|
||||
|
||||
describe('list route state', () => {
|
||||
it('parses valid state and safely falls back for invalid values', () => {
|
||||
expect(
|
||||
parseListRouteState(
|
||||
{
|
||||
categoryId: '7',
|
||||
keyword: ' 月报 ',
|
||||
pageNumber: '-2',
|
||||
pageSize: '999',
|
||||
status: 'unknown',
|
||||
},
|
||||
schema,
|
||||
),
|
||||
).toEqual({
|
||||
categoryId: '7',
|
||||
keyword: '月报',
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
status: 'all',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits defaults and preserves query fields owned by other features', () => {
|
||||
const state: TestListState = {
|
||||
categoryId: '',
|
||||
keyword: '',
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
status: 'all',
|
||||
};
|
||||
|
||||
expect(buildListRouteStateQuery(state, schema)).toEqual({});
|
||||
expect(
|
||||
mergeListRouteStateQuery(
|
||||
{
|
||||
devLogin: 'admin',
|
||||
keyword: '旧关键字',
|
||||
pageNumber: '4',
|
||||
},
|
||||
state,
|
||||
schema,
|
||||
),
|
||||
).toEqual({ devLogin: 'admin' });
|
||||
});
|
||||
|
||||
it('builds the exact current list full path', () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ component: {}, path: '/items' }],
|
||||
});
|
||||
|
||||
const state: TestListState = {
|
||||
categoryId: '7',
|
||||
keyword: '月报',
|
||||
pageNumber: 3,
|
||||
pageSize: 20,
|
||||
status: 'enabled',
|
||||
};
|
||||
|
||||
expect(
|
||||
buildListRouteFullPath(router, { devLogin: 'admin' }, state, schema),
|
||||
).toBe(
|
||||
'/items?devLogin=admin&categoryId=7&keyword=%E6%9C%88%E6%8A%A5&pageNumber=3&pageSize=20&status=enabled',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits parsed state when the current list full path changes', async () => {
|
||||
const route = reactive({
|
||||
fullPath: '/items',
|
||||
path: '/items',
|
||||
query: {},
|
||||
}) as any;
|
||||
const onChange = vi.fn();
|
||||
const stop = watchListRouteState(route, schema, onChange);
|
||||
|
||||
route.query = { keyword: '月报', pageNumber: '4', pageSize: '20' };
|
||||
route.fullPath = '/items?keyword=月报&pageNumber=4&pageSize=20';
|
||||
await nextTick();
|
||||
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
categoryId: '',
|
||||
keyword: '月报',
|
||||
pageNumber: 4,
|
||||
pageSize: 20,
|
||||
status: 'all',
|
||||
});
|
||||
stop();
|
||||
});
|
||||
});
|
||||
214
easyflow-ui-admin/app/src/composables/useListRouteState.ts
Normal file
214
easyflow-ui-admin/app/src/composables/useListRouteState.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import type {
|
||||
LocationQuery,
|
||||
LocationQueryRaw,
|
||||
RouteLocationNormalizedLoaded,
|
||||
Router,
|
||||
} from 'vue-router';
|
||||
|
||||
import { watch } from 'vue';
|
||||
|
||||
type ListRoutePrimitive = number | string;
|
||||
|
||||
interface ListRouteStateField<Value extends ListRoutePrimitive> {
|
||||
defaultValue: Value;
|
||||
parse: (value: string) => Value;
|
||||
queryKey?: string;
|
||||
serialize: (value: Value) => string | undefined;
|
||||
}
|
||||
|
||||
type ListRouteStateSchema<State extends object> = {
|
||||
fields: {
|
||||
[Key in keyof State]: ListRouteStateField<
|
||||
Extract<State[Key], ListRoutePrimitive>
|
||||
>;
|
||||
};
|
||||
path: string;
|
||||
};
|
||||
|
||||
interface StringListRouteFieldOptions {
|
||||
defaultValue?: string;
|
||||
queryKey?: string;
|
||||
trim?: boolean;
|
||||
}
|
||||
|
||||
interface PositiveIntegerListRouteFieldOptions {
|
||||
allowedValues?: readonly number[];
|
||||
defaultValue: number;
|
||||
queryKey?: string;
|
||||
}
|
||||
|
||||
function readListRouteQueryValue(query: LocationQuery, key: string): string {
|
||||
const value = query[key];
|
||||
const normalized = Array.isArray(value) ? value[0] : value;
|
||||
return normalized === null || normalized === undefined
|
||||
? ''
|
||||
: String(normalized);
|
||||
}
|
||||
|
||||
function stringListRouteField(
|
||||
options: StringListRouteFieldOptions = {},
|
||||
): ListRouteStateField<string> {
|
||||
const defaultValue = options.defaultValue ?? '';
|
||||
const normalize = (value: string) =>
|
||||
options.trim ? value.trim() : String(value || '');
|
||||
return {
|
||||
defaultValue,
|
||||
parse(value) {
|
||||
return normalize(value) || defaultValue;
|
||||
},
|
||||
queryKey: options.queryKey,
|
||||
serialize(value) {
|
||||
const normalized = normalize(value) || defaultValue;
|
||||
return normalized === defaultValue ? undefined : normalized;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function enumListRouteField<
|
||||
const AllowedValues extends readonly string[],
|
||||
const DefaultValue extends string,
|
||||
>(
|
||||
allowedValues: AllowedValues,
|
||||
defaultValue: DefaultValue,
|
||||
queryKey?: string,
|
||||
): ListRouteStateField<AllowedValues[number] | DefaultValue> {
|
||||
const allowed = new Set<string>(allowedValues);
|
||||
return {
|
||||
defaultValue,
|
||||
parse(value) {
|
||||
return allowed.has(value)
|
||||
? (value as AllowedValues[number])
|
||||
: defaultValue;
|
||||
},
|
||||
queryKey,
|
||||
serialize(value) {
|
||||
return value === defaultValue || !allowed.has(value) ? undefined : value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function positiveIntegerListRouteField(
|
||||
options: PositiveIntegerListRouteFieldOptions,
|
||||
): ListRouteStateField<number> {
|
||||
const allowedValues = options.allowedValues
|
||||
? new Set(options.allowedValues)
|
||||
: undefined;
|
||||
const isValid = (value: number) =>
|
||||
Number.isSafeInteger(value) &&
|
||||
value > 0 &&
|
||||
(!allowedValues || allowedValues.has(value));
|
||||
return {
|
||||
defaultValue: options.defaultValue,
|
||||
parse(value) {
|
||||
if (!/^\d+$/.test(value)) return options.defaultValue;
|
||||
const parsed = Number(value);
|
||||
return isValid(parsed) ? parsed : options.defaultValue;
|
||||
},
|
||||
queryKey: options.queryKey,
|
||||
serialize(value) {
|
||||
return value === options.defaultValue || !isValid(value)
|
||||
? undefined
|
||||
: String(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defineListRouteStateSchema<State extends object>(
|
||||
schema: ListRouteStateSchema<State>,
|
||||
): ListRouteStateSchema<State> {
|
||||
return schema;
|
||||
}
|
||||
|
||||
function listRouteFieldEntries<State extends object>(
|
||||
schema: ListRouteStateSchema<State>,
|
||||
) {
|
||||
return (Object.keys(schema.fields) as Array<keyof State>).map((key) => ({
|
||||
field: schema.fields[key],
|
||||
key,
|
||||
queryKey: schema.fields[key].queryKey || String(key),
|
||||
}));
|
||||
}
|
||||
|
||||
function parseListRouteState<State extends object>(
|
||||
query: LocationQuery,
|
||||
schema: ListRouteStateSchema<State>,
|
||||
): State {
|
||||
const state = {} as State;
|
||||
for (const { field, key, queryKey } of listRouteFieldEntries(schema)) {
|
||||
state[key] = field.parse(
|
||||
readListRouteQueryValue(query, queryKey),
|
||||
) as State[typeof key];
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function buildListRouteStateQuery<State extends object>(
|
||||
state: State,
|
||||
schema: ListRouteStateSchema<State>,
|
||||
): LocationQueryRaw {
|
||||
const query: LocationQueryRaw = {};
|
||||
for (const { field, key, queryKey } of listRouteFieldEntries(schema)) {
|
||||
const value = field.serialize(
|
||||
state[key] as Extract<State[typeof key], ListRoutePrimitive>,
|
||||
);
|
||||
if (value !== undefined) query[queryKey] = value;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
function mergeListRouteStateQuery<State extends object>(
|
||||
query: LocationQuery,
|
||||
state: State,
|
||||
schema: ListRouteStateSchema<State>,
|
||||
): LocationQueryRaw {
|
||||
const ownedKeys = new Set(
|
||||
listRouteFieldEntries(schema).map(({ queryKey }) => queryKey),
|
||||
);
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(query).filter(([key]) => !ownedKeys.has(key)),
|
||||
),
|
||||
...buildListRouteStateQuery(state, schema),
|
||||
};
|
||||
}
|
||||
|
||||
function buildListRouteFullPath<State extends object>(
|
||||
router: Router,
|
||||
query: LocationQuery,
|
||||
state: State,
|
||||
schema: ListRouteStateSchema<State>,
|
||||
): string {
|
||||
return router.resolve({
|
||||
path: schema.path,
|
||||
query: mergeListRouteStateQuery(query, state, schema),
|
||||
}).fullPath;
|
||||
}
|
||||
|
||||
function watchListRouteState<State extends object>(
|
||||
route: RouteLocationNormalizedLoaded,
|
||||
schema: ListRouteStateSchema<State>,
|
||||
onChange: (state: State) => void,
|
||||
) {
|
||||
return watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (route.path !== schema.path) return;
|
||||
onChange(parseListRouteState(route.query, schema));
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
buildListRouteFullPath,
|
||||
buildListRouteStateQuery,
|
||||
defineListRouteStateSchema,
|
||||
enumListRouteField,
|
||||
mergeListRouteStateQuery,
|
||||
parseListRouteState,
|
||||
positiveIntegerListRouteField,
|
||||
readListRouteQueryValue,
|
||||
stringListRouteField,
|
||||
watchListRouteState,
|
||||
};
|
||||
export type { ListRouteStateField, ListRouteStateSchema };
|
||||
@@ -1,78 +1,59 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readViewSource(relativePath: string) {
|
||||
return readFileSync(resolve('app/src/views', relativePath), 'utf8');
|
||||
const workspaceViews = resolve('app/src/views');
|
||||
const viewsRoot = existsSync(workspaceViews)
|
||||
? workspaceViews
|
||||
: resolve('src/views');
|
||||
return readFileSync(resolve(viewsRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
describe('detail return navigation', () => {
|
||||
it.each([
|
||||
['system/sysFeedback/sysFeedbackDetail.vue', '/sys/sysFeedback'],
|
||||
['system/sysJob/SysJobLogList.vue', '/sys/sysJob'],
|
||||
['ai/workflow/WorkflowDesign.vue', '/ai/workflow'],
|
||||
['ai/workflow/components/WorkflowChatPage.vue', '/ai/workflow'],
|
||||
['ai/workflow/execute/WorkflowExecResultList.vue', '/ai/workflow'],
|
||||
])('returns %s to its fixed parent list', (relativePath, parentPath) => {
|
||||
const source = readViewSource(relativePath);
|
||||
[
|
||||
'ai/documentCollection/DocumentCollection.vue',
|
||||
'ai/documentCollection/Document.vue',
|
||||
],
|
||||
['ai/skill/SkillList.vue', 'ai/skill/SkillDetail.vue'],
|
||||
['ai/agents/AgentList.vue', 'ai/agents/AgentDesigner.vue'],
|
||||
['ai/bots/index.vue', 'ai/bots/pages/setting/index.vue'],
|
||||
[
|
||||
'system/approval/ApprovalManage.vue',
|
||||
'system/approval/ApprovalDetail.vue',
|
||||
],
|
||||
['system/sysJob/SysJobList.vue', 'system/sysJob/SysJobLogList.vue'],
|
||||
[
|
||||
'system/sysFeedback/sysFeedbackList.vue',
|
||||
'system/sysFeedback/sysFeedbackDetail.vue',
|
||||
],
|
||||
['ai/workflow/WorkflowList.vue', 'ai/workflow/WorkflowDesign.vue'],
|
||||
[
|
||||
'ai/workflow/WorkflowList.vue',
|
||||
'ai/workflow/components/WorkflowChatPage.vue',
|
||||
],
|
||||
['ai/plugin/Plugin.vue', 'ai/plugin/PluginTools.vue'],
|
||||
['ai/plugin/PluginToolTable.vue', 'ai/plugin/PluginToolEdit.vue'],
|
||||
])(
|
||||
'passes an exact return target from %s and validates it in %s',
|
||||
(listPath, detailPath) => {
|
||||
expect(readViewSource(listPath)).toContain('withListReturnTo');
|
||||
expect(readViewSource(detailPath)).toContain('navigateBackToList');
|
||||
},
|
||||
);
|
||||
|
||||
expect(source).not.toMatch(/router\.(?:back|go)\s*\(/);
|
||||
expect(source).toContain(`path: '${parentPath}'`);
|
||||
});
|
||||
|
||||
it('returns approval details to the originating approval tab', () => {
|
||||
const detailSource = readViewSource('system/approval/ApprovalDetail.vue');
|
||||
const listSource = readViewSource('system/approval/ApprovalManage.vue');
|
||||
|
||||
expect(detailSource).not.toMatch(/router\.(?:back|go)\s*\(/);
|
||||
expect(detailSource).toContain("path: '/sys/approval'");
|
||||
expect(detailSource).toContain('query: { tab }');
|
||||
expect(listSource).toContain('tab: activeTab.value');
|
||||
});
|
||||
|
||||
it('returns workflow design to the originating list page and filters', () => {
|
||||
const detailSource = readViewSource('ai/workflow/WorkflowDesign.vue');
|
||||
const listSource = readViewSource('ai/workflow/WorkflowList.vue');
|
||||
|
||||
expect(detailSource).toContain(
|
||||
'parseWorkflowDesignReturnState(route.query)',
|
||||
);
|
||||
expect(detailSource).toContain(
|
||||
'query: buildWorkflowListRouteQuery(listState)',
|
||||
);
|
||||
expect(listSource).toContain('buildWorkflowDesignReturnQuery');
|
||||
expect(listSource).toContain('getPageState');
|
||||
});
|
||||
|
||||
it('returns plugin tool editing to its plugin tool list with a safe fallback', () => {
|
||||
const editSource = readViewSource('ai/plugin/PluginToolEdit.vue');
|
||||
const toolsSource = readViewSource('ai/plugin/PluginTools.vue');
|
||||
const pluginListSource = readViewSource('ai/plugin/Plugin.vue');
|
||||
const listSource = readViewSource('ai/plugin/PluginToolTable.vue');
|
||||
|
||||
expect(editSource).not.toMatch(/router\.(?:back|go)\s*\(/);
|
||||
expect(editSource).toContain("path: '/ai/plugin/tools'");
|
||||
expect(editSource).toContain("path: '/ai/plugin'");
|
||||
expect(editSource).toContain('buildPluginToolsRouteQueryFromEdit');
|
||||
expect(toolsSource).toContain('parsePluginToolsReturnState');
|
||||
expect(toolsSource).toContain('buildPluginListRouteQuery');
|
||||
expect(pluginListSource).toContain('buildPluginToolsReturnQuery');
|
||||
expect(listSource).toContain('pluginId: props.pluginId');
|
||||
expect(listSource).toContain('...route.query');
|
||||
});
|
||||
|
||||
it('returns execution steps to the filtered execution record list', () => {
|
||||
const stepSource = readViewSource(
|
||||
'ai/workflow/execute/WorkflowExecStepList.vue',
|
||||
);
|
||||
it('preserves the nested execution-record list before opening a step', () => {
|
||||
const recordSource = readViewSource(
|
||||
'ai/workflow/execute/WorkflowExecResultList.vue',
|
||||
);
|
||||
const stepSource = readViewSource(
|
||||
'ai/workflow/execute/WorkflowExecStepList.vue',
|
||||
);
|
||||
|
||||
expect(stepSource).not.toMatch(/router\.(?:back|go)\s*\(/);
|
||||
expect(stepSource).toContain("name: 'ExecRecord'");
|
||||
expect(stepSource).toContain('query: workflowId ? { workflowId } : {}');
|
||||
expect(recordSource).toContain('workflowId: $route.query.workflowId');
|
||||
expect(recordSource).toContain('withListReturnTo(currentListFullPath())');
|
||||
expect(stepSource).toContain('navigateBackToList');
|
||||
expect(stepSource).toContain("['/ai/workflow/executeRecords']");
|
||||
});
|
||||
});
|
||||
|
||||
54
easyflow-ui-admin/app/src/router/list-return-context.test.ts
Normal file
54
easyflow-ui-admin/app/src/router/list-return-context.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
readListReturnTo,
|
||||
resolveListReturnPath,
|
||||
withListReturnTo,
|
||||
} from './list-return-context';
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ component: {}, path: '/items' },
|
||||
{ component: {}, path: '/other' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('list return context', () => {
|
||||
it('round trips an exact internal list URL', () => {
|
||||
const router = createTestRouter();
|
||||
const query = withListReturnTo('/items?pageNumber=4&keyword=月报', {
|
||||
id: '7',
|
||||
});
|
||||
|
||||
expect(readListReturnTo(query as any)).toBe(
|
||||
'/items?pageNumber=4&keyword=月报',
|
||||
);
|
||||
expect(
|
||||
resolveListReturnPath(router, query as any, ['/items'], '/items'),
|
||||
).toBe('/items?pageNumber=4&keyword=月报');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://example.com/items?pageNumber=4',
|
||||
'//example.com/items?pageNumber=4',
|
||||
'/other?pageNumber=4',
|
||||
'/missing?pageNumber=4',
|
||||
'/items#unsafe',
|
||||
String.raw`/items\unsafe`,
|
||||
`/items?value=${'x'.repeat(4096)}`,
|
||||
])('rejects an unsafe or unrelated return target: %s', (returnTo) => {
|
||||
expect(
|
||||
resolveListReturnPath(
|
||||
createTestRouter(),
|
||||
{ returnTo },
|
||||
['/items'],
|
||||
'/items',
|
||||
),
|
||||
).toBe('/items');
|
||||
});
|
||||
});
|
||||
105
easyflow-ui-admin/app/src/router/list-return-context.ts
Normal file
105
easyflow-ui-admin/app/src/router/list-return-context.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { LocationQuery, LocationQueryRaw, Router } from 'vue-router';
|
||||
|
||||
const LIST_RETURN_QUERY_KEY = 'returnTo';
|
||||
const MAX_LIST_RETURN_LENGTH = 4096;
|
||||
|
||||
function hasUnsafeReturnCharacter(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0) || 0;
|
||||
if (character === '\\' || codePoint <= 31 || codePoint === 127) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readListReturnTo(query: LocationQuery): string {
|
||||
const value = query[LIST_RETURN_QUERY_KEY];
|
||||
const normalized = Array.isArray(value) ? value[0] : value;
|
||||
return normalized === null || normalized === undefined
|
||||
? ''
|
||||
: String(normalized);
|
||||
}
|
||||
|
||||
function withListReturnTo(
|
||||
returnTo: string,
|
||||
query: LocationQueryRaw = {},
|
||||
): LocationQueryRaw {
|
||||
return {
|
||||
...query,
|
||||
[LIST_RETURN_QUERY_KEY]: returnTo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveListReturnPath(
|
||||
router: Router,
|
||||
query: LocationQuery,
|
||||
allowedListPaths: readonly string[],
|
||||
fallbackPath: string,
|
||||
): string {
|
||||
const raw = readListReturnTo(query);
|
||||
if (
|
||||
!raw ||
|
||||
raw.length > MAX_LIST_RETURN_LENGTH ||
|
||||
!raw.startsWith('/') ||
|
||||
raw.startsWith('//') ||
|
||||
hasUnsafeReturnCharacter(raw)
|
||||
) {
|
||||
return fallbackPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw, 'https://easyflow.local');
|
||||
if (parsed.origin !== 'https://easyflow.local' || parsed.hash) {
|
||||
return fallbackPath;
|
||||
}
|
||||
if (!allowedListPaths.includes(parsed.pathname)) {
|
||||
return fallbackPath;
|
||||
}
|
||||
const resolved = router.resolve(raw);
|
||||
return resolved.matched.length > 0 ? resolved.fullPath : fallbackPath;
|
||||
} catch {
|
||||
return fallbackPath;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHistoryBackPath(router: Router): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const raw = window.history.state?.back;
|
||||
if (typeof raw !== 'string' || !raw) return '';
|
||||
const hashIndex = raw.indexOf('#');
|
||||
const candidate = hashIndex === -1 ? raw : raw.slice(hashIndex + 1);
|
||||
if (!candidate.startsWith('/')) return '';
|
||||
try {
|
||||
return router.resolve(candidate).fullPath;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateBackToList(
|
||||
router: Router,
|
||||
query: LocationQuery,
|
||||
allowedListPaths: readonly string[],
|
||||
fallbackPath: string,
|
||||
): Promise<void> {
|
||||
const target = resolveListReturnPath(
|
||||
router,
|
||||
query,
|
||||
allowedListPaths,
|
||||
fallbackPath,
|
||||
);
|
||||
if (resolveHistoryBackPath(router) === target) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
await router.replace(target);
|
||||
}
|
||||
|
||||
export {
|
||||
LIST_RETURN_QUERY_KEY,
|
||||
navigateBackToList,
|
||||
readListReturnTo,
|
||||
resolveListReturnPath,
|
||||
withListReturnTo,
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { ElButton, ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { tryit } from 'radash';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { navigateBackToList } from '#/router/list-return-context';
|
||||
import {
|
||||
canAiResourceOffline,
|
||||
canAiResourcePublish,
|
||||
@@ -291,8 +292,7 @@ async function loadMcpToolsForOption(id: number | string) {
|
||||
String(item.value) === key
|
||||
? {
|
||||
...item,
|
||||
label:
|
||||
currentOption.label || 'MCP',
|
||||
label: currentOption.label || 'MCP',
|
||||
raw: mergedResource,
|
||||
}
|
||||
: item,
|
||||
@@ -481,8 +481,13 @@ function handleCloseTryout() {
|
||||
selectBase();
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
router.push(AGENT_TAB_PAGE_KEY);
|
||||
async function handleBack() {
|
||||
await navigateBackToList(
|
||||
router,
|
||||
route.query,
|
||||
[AGENT_TAB_PAGE_KEY],
|
||||
AGENT_TAB_PAGE_KEY,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -6,10 +6,9 @@ import type {
|
||||
ActionButton,
|
||||
CardPrimaryAction,
|
||||
} from '#/components/page/CardList.vue';
|
||||
import CardList from '#/components/page/CardList.vue';
|
||||
|
||||
import { computed, markRaw, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@easyflow/access';
|
||||
import { defaultAssistantAvatar } from '@easyflow/common-ui';
|
||||
@@ -27,9 +26,17 @@ import { ElIcon, ElMessage, ElMessageBox, ElPopover } from 'element-plus';
|
||||
import { tryit } from 'radash';
|
||||
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import CardList from '#/components/page/CardList.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import {
|
||||
buildListRouteFullPath,
|
||||
mergeListRouteStateQuery,
|
||||
parseListRouteState,
|
||||
watchListRouteState,
|
||||
} from '#/composables/useListRouteState';
|
||||
import { $t } from '#/locales';
|
||||
import { withListReturnTo } from '#/router/list-return-context';
|
||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||
import {
|
||||
canAiResourceDelete,
|
||||
@@ -40,6 +47,7 @@ import {
|
||||
resolveAiResourceDisplayStatus,
|
||||
} from '#/views/ai/shared/publish-status';
|
||||
|
||||
import { agentListRouteSchema } from './agent-list-route-state';
|
||||
import {
|
||||
getAgentCategories,
|
||||
submitAgentDeleteApproval,
|
||||
@@ -48,9 +56,35 @@ import {
|
||||
updateAgentVisibilityScope,
|
||||
} from './api';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const initialListState = parseListRouteState(route.query, agentListRouteSchema);
|
||||
const pageDataRef = ref();
|
||||
const sideList = ref<any[]>([]);
|
||||
const selectedCategoryId = ref(initialListState.categoryId);
|
||||
const searchKeyword = ref(initialListState.keyword);
|
||||
const currentPageNumber = ref(initialListState.pageNumber);
|
||||
const currentPageSize = ref(initialListState.pageSize);
|
||||
let initialCategoryChangePending = Boolean(initialListState.categoryId);
|
||||
watchListRouteState(route, agentListRouteSchema, (state) => {
|
||||
if (state.categoryId !== selectedCategoryId.value) {
|
||||
initialCategoryChangePending = Boolean(state.categoryId);
|
||||
}
|
||||
selectedCategoryId.value = state.categoryId;
|
||||
searchKeyword.value = state.keyword;
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
pageDataRef.value?.restoreState?.({
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
queryParams: {
|
||||
categoryId: state.categoryId || undefined,
|
||||
description: state.keyword || undefined,
|
||||
isQueryOr: true,
|
||||
name: state.keyword || undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
const AGENT_TAB_PAGE_KEY = '/ai/agents';
|
||||
const DEFAULT_AGENT_TITLE = '未命名智能体';
|
||||
type VisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
|
||||
@@ -104,6 +138,7 @@ const primaryAction: CardPrimaryAction = {
|
||||
query: {
|
||||
pageKey: AGENT_TAB_PAGE_KEY,
|
||||
navTitle: resolveNavTitle(row),
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -149,10 +184,12 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
function handleSearch(keyword: string) {
|
||||
searchKeyword.value = keyword;
|
||||
pageDataRef.value?.setQuery({
|
||||
categoryId: selectedCategoryId.value || undefined,
|
||||
isQueryOr: true,
|
||||
name: keyword,
|
||||
description: keyword,
|
||||
name: keyword || undefined,
|
||||
description: keyword || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,6 +200,7 @@ function handleButtonClick(payload: any) {
|
||||
query: {
|
||||
pageKey: AGENT_TAB_PAGE_KEY,
|
||||
navTitle: DEFAULT_AGENT_TITLE,
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -221,7 +259,19 @@ async function updateVisibilityScope(
|
||||
}
|
||||
|
||||
function changeCategory(category: any) {
|
||||
pageDataRef.value?.setQuery({ categoryId: category.id });
|
||||
const categoryId = String(category?.id || '');
|
||||
if (initialCategoryChangePending && categoryId === selectedCategoryId.value) {
|
||||
initialCategoryChangePending = false;
|
||||
return;
|
||||
}
|
||||
initialCategoryChangePending = false;
|
||||
selectedCategoryId.value = categoryId;
|
||||
pageDataRef.value?.setQuery({
|
||||
categoryId: categoryId || undefined,
|
||||
isQueryOr: true,
|
||||
name: searchKeyword.value || undefined,
|
||||
description: searchKeyword.value || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
@@ -231,9 +281,57 @@ async function loadCategories() {
|
||||
{ id: '', categoryName: $t('common.allCategories') },
|
||||
...(res.data || []),
|
||||
];
|
||||
if (
|
||||
selectedCategoryId.value &&
|
||||
!sideList.value.some(
|
||||
(item) => String(item.id) === selectedCategoryId.value,
|
||||
)
|
||||
) {
|
||||
selectedCategoryId.value = '';
|
||||
initialCategoryChangePending = false;
|
||||
pageDataRef.value?.setQuery?.({
|
||||
isQueryOr: true,
|
||||
name: searchKeyword.value || undefined,
|
||||
description: searchKeyword.value || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function currentListFullPath() {
|
||||
return buildListRouteFullPath(
|
||||
router,
|
||||
route.query,
|
||||
{
|
||||
categoryId: selectedCategoryId.value,
|
||||
keyword: searchKeyword.value,
|
||||
pageNumber: currentPageNumber.value,
|
||||
pageSize: currentPageSize.value,
|
||||
},
|
||||
agentListRouteSchema,
|
||||
);
|
||||
}
|
||||
|
||||
function handlePageStateChange(state: {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
const query = mergeListRouteStateQuery(
|
||||
route.query,
|
||||
{
|
||||
categoryId: selectedCategoryId.value,
|
||||
keyword: searchKeyword.value,
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
},
|
||||
agentListRouteSchema,
|
||||
);
|
||||
const target = router.resolve({ path: agentListRouteSchema.path, query });
|
||||
if (target.fullPath !== route.fullPath) void router.replace(target);
|
||||
}
|
||||
|
||||
function resolvePublishStatusMeta(
|
||||
displayPublishStatus?: string,
|
||||
publishStatus?: string,
|
||||
@@ -333,6 +431,7 @@ async function handleDeleteAction(row: AgentInfo) {
|
||||
<div class="agent-list-page">
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
:initial-value="searchKeyword"
|
||||
@search="handleSearch"
|
||||
@button-click="handleButtonClick"
|
||||
/>
|
||||
@@ -341,6 +440,7 @@ async function handleDeleteAction(row: AgentInfo) {
|
||||
label-key="categoryName"
|
||||
value-key="id"
|
||||
:menus="sideList"
|
||||
:default-selected="selectedCategoryId"
|
||||
@change="changeCategory"
|
||||
/>
|
||||
<div class="agent-list-page__content">
|
||||
@@ -348,7 +448,15 @@ async function handleDeleteAction(row: AgentInfo) {
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/agent/page"
|
||||
:page-sizes="[12, 18, 24]"
|
||||
:page-size="12"
|
||||
:page-size="initialListState.pageSize"
|
||||
:initial-page-number="initialListState.pageNumber"
|
||||
:initial-query-params="{
|
||||
categoryId: initialListState.categoryId || undefined,
|
||||
isQueryOr: true,
|
||||
name: initialListState.keyword || undefined,
|
||||
description: initialListState.keyword || undefined,
|
||||
}"
|
||||
@state-change="handlePageStateChange"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<CardList
|
||||
@@ -366,13 +474,12 @@ async function handleDeleteAction(row: AgentInfo) {
|
||||
<template #publish>
|
||||
<div
|
||||
class="agent-publish-chip"
|
||||
:class="
|
||||
'agent-publish-chip--' +
|
||||
:class="`agent-publish-chip--${
|
||||
resolvePublishStatusMeta(
|
||||
item.displayPublishStatus,
|
||||
item.publishStatus,
|
||||
).tone
|
||||
"
|
||||
}`"
|
||||
>
|
||||
<span class="agent-publish-chip__dot"></span>
|
||||
<span>{{
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
defineListRouteStateSchema,
|
||||
positiveIntegerListRouteField,
|
||||
stringListRouteField,
|
||||
} from '#/composables/useListRouteState';
|
||||
|
||||
interface AgentListRouteState {
|
||||
categoryId: string;
|
||||
keyword: string;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const agentListRouteSchema = defineListRouteStateSchema<AgentListRouteState>({
|
||||
path: '/ai/agents',
|
||||
fields: {
|
||||
categoryId: stringListRouteField(),
|
||||
keyword: stringListRouteField(),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [12, 18, 24],
|
||||
defaultValue: 12,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export { agentListRouteSchema };
|
||||
export type { AgentListRouteState };
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
defineListRouteStateSchema,
|
||||
positiveIntegerListRouteField,
|
||||
stringListRouteField,
|
||||
} from '#/composables/useListRouteState';
|
||||
|
||||
interface BotListRouteState {
|
||||
categoryId: string;
|
||||
keyword: string;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const botListRouteSchema = defineListRouteStateSchema<BotListRouteState>({
|
||||
path: '/ai/bots',
|
||||
fields: {
|
||||
categoryId: stringListRouteField(),
|
||||
keyword: stringListRouteField(),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [12, 18, 24],
|
||||
defaultValue: 12,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export { botListRouteSchema };
|
||||
export type { BotListRouteState };
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
} from '#/components/page/CardList.vue';
|
||||
|
||||
import { computed, markRaw, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
import { $t } from '@easyflow/locales';
|
||||
@@ -37,6 +37,13 @@ import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import CardList from '#/components/page/CardList.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import {
|
||||
buildListRouteFullPath,
|
||||
mergeListRouteStateQuery,
|
||||
parseListRouteState,
|
||||
watchListRouteState,
|
||||
} from '#/composables/useListRouteState';
|
||||
import { withListReturnTo } from '#/router/list-return-context';
|
||||
import {
|
||||
confirmPublishSubmission,
|
||||
} from '#/views/ai/shared/approval-application-reason';
|
||||
@@ -50,6 +57,7 @@ import {
|
||||
} from '#/views/ai/shared/publish-status';
|
||||
import { useDictStore } from '#/store';
|
||||
|
||||
import { botListRouteSchema } from './bot-list-route-state';
|
||||
import Modal from './modal.vue';
|
||||
|
||||
interface FieldDefinition {
|
||||
@@ -70,8 +78,33 @@ onMounted(() => {
|
||||
getSideList();
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const initialListState = parseListRouteState(route.query, botListRouteSchema);
|
||||
const pageDataRef = ref();
|
||||
const selectedCategoryId = ref(initialListState.categoryId);
|
||||
const searchKeyword = ref(initialListState.keyword);
|
||||
const currentPageNumber = ref(initialListState.pageNumber);
|
||||
const currentPageSize = ref(initialListState.pageSize);
|
||||
let initialCategoryChangePending = Boolean(initialListState.categoryId);
|
||||
watchListRouteState(route, botListRouteSchema, (state) => {
|
||||
if (state.categoryId !== selectedCategoryId.value) {
|
||||
initialCategoryChangePending = Boolean(state.categoryId);
|
||||
}
|
||||
selectedCategoryId.value = state.categoryId;
|
||||
searchKeyword.value = state.keyword;
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
pageDataRef.value?.restoreState?.({
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
queryParams: {
|
||||
categoryId: state.categoryId || undefined,
|
||||
isQueryOr: true,
|
||||
title: state.keyword || undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
const modalRef = ref<InstanceType<typeof Modal>>();
|
||||
const dictStore = useDictStore();
|
||||
|
||||
@@ -98,6 +131,7 @@ const primaryAction: CardPrimaryAction = {
|
||||
query: {
|
||||
pageKey: '/ai/bots',
|
||||
navTitle: resolveNavTitle(row),
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -273,7 +307,12 @@ function resolvePublishStatusMetaByInstance(
|
||||
}
|
||||
|
||||
const handleSearch = (params: string) => {
|
||||
pageDataRef.value.setQuery({ title: params, isQueryOr: true });
|
||||
searchKeyword.value = params;
|
||||
pageDataRef.value.setQuery({
|
||||
categoryId: selectedCategoryId.value || undefined,
|
||||
title: params || undefined,
|
||||
isQueryOr: true,
|
||||
});
|
||||
};
|
||||
const handleButtonClick = () => {
|
||||
modalRef.value?.open('create');
|
||||
@@ -347,7 +386,18 @@ function initDict() {
|
||||
dictStore.fetchDictionary('dataStatus');
|
||||
}
|
||||
function changeCategory(category: any) {
|
||||
pageDataRef.value.setQuery({ categoryId: category.id });
|
||||
const categoryId = String(category?.id || '');
|
||||
if (initialCategoryChangePending && categoryId === selectedCategoryId.value) {
|
||||
initialCategoryChangePending = false;
|
||||
return;
|
||||
}
|
||||
initialCategoryChangePending = false;
|
||||
selectedCategoryId.value = categoryId;
|
||||
pageDataRef.value.setQuery({
|
||||
categoryId: categoryId || undefined,
|
||||
title: searchKeyword.value || undefined,
|
||||
isQueryOr: true,
|
||||
});
|
||||
}
|
||||
function showControlDialog(item: any) {
|
||||
formRef.value?.resetFields();
|
||||
@@ -412,14 +462,60 @@ const getSideList = async () => {
|
||||
},
|
||||
...res.data,
|
||||
];
|
||||
if (
|
||||
selectedCategoryId.value &&
|
||||
!sideList.value.some(
|
||||
(category) => String(category.id) === selectedCategoryId.value,
|
||||
)
|
||||
) {
|
||||
selectedCategoryId.value = '';
|
||||
initialCategoryChangePending = false;
|
||||
pageDataRef.value?.setQuery?.({
|
||||
title: searchKeyword.value || undefined,
|
||||
isQueryOr: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
function currentListFullPath() {
|
||||
return buildListRouteFullPath(
|
||||
router,
|
||||
route.query,
|
||||
{
|
||||
categoryId: selectedCategoryId.value,
|
||||
keyword: searchKeyword.value,
|
||||
pageNumber: currentPageNumber.value,
|
||||
pageSize: currentPageSize.value,
|
||||
},
|
||||
botListRouteSchema,
|
||||
);
|
||||
}
|
||||
function handlePageStateChange(state: {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
const query = mergeListRouteStateQuery(
|
||||
route.query,
|
||||
{
|
||||
categoryId: selectedCategoryId.value,
|
||||
keyword: searchKeyword.value,
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
},
|
||||
botListRouteSchema,
|
||||
);
|
||||
const target = router.resolve({ path: botListRouteSchema.path, query });
|
||||
if (target.fullPath !== route.fullPath) void router.replace(target);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-6">
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
:initial-value="searchKeyword"
|
||||
@search="handleSearch"
|
||||
@button-click="handleButtonClick"
|
||||
/>
|
||||
@@ -430,6 +526,7 @@ const getSideList = async () => {
|
||||
:menus="sideList"
|
||||
:control-btns="controlBtns"
|
||||
:footer-button="footerButton"
|
||||
:default-selected="selectedCategoryId"
|
||||
@change="changeCategory"
|
||||
/>
|
||||
<div class="h-[calc(100vh-192px)] flex-1 overflow-auto">
|
||||
@@ -437,7 +534,14 @@ const getSideList = async () => {
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/bot/page"
|
||||
:page-sizes="[12, 18, 24]"
|
||||
:page-size="12"
|
||||
:page-size="initialListState.pageSize"
|
||||
:initial-page-number="initialListState.pageNumber"
|
||||
:initial-query-params="{
|
||||
categoryId: initialListState.categoryId || undefined,
|
||||
title: initialListState.keyword || undefined,
|
||||
isQueryOr: true,
|
||||
}"
|
||||
@state-change="handlePageStateChange"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<CardList
|
||||
@@ -462,7 +566,7 @@ const getSideList = async () => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- 创建&编辑Bot弹窗 -->
|
||||
<Modal ref="modalRef" @success="pageDataRef.setQuery({})" />
|
||||
<Modal ref="modalRef" @success="pageDataRef.reload()" />
|
||||
|
||||
<EasyFlowFormModal
|
||||
v-model:open="dialogVisible"
|
||||
|
||||
@@ -4,10 +4,14 @@ import type { BotInfo } from '@easyflow/types';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { ArrowLeft } from '@element-plus/icons-vue';
|
||||
import { ElButton } from 'element-plus';
|
||||
import { tryit } from 'radash';
|
||||
|
||||
import { getBotDetails } from '#/api';
|
||||
import { hasPermission } from '#/api/common/hasPermission';
|
||||
import { $t } from '#/locales';
|
||||
import { navigateBackToList } from '#/router/list-return-context';
|
||||
|
||||
import Config from './config.vue';
|
||||
import Preview from './preview.vue';
|
||||
@@ -55,10 +59,16 @@ const fetchBotDetail = async (id: string) => {
|
||||
syncNavTitle((res.data?.title || res.data?.name || '') as string);
|
||||
}
|
||||
};
|
||||
async function backToBotList() {
|
||||
await navigateBackToList(router, route.query, ['/ai/bots'], '/ai/bots');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-container">
|
||||
<ElButton :icon="ArrowLeft" class="settings-back" @click="backToBotList">
|
||||
{{ $t('button.back') }}
|
||||
</ElButton>
|
||||
<div class="row-container">
|
||||
<div class="row-item">
|
||||
<Prompt :bot="bot" :has-save-permission="hasSavePermission" />
|
||||
@@ -74,14 +84,22 @@ const fetchBotDetail = async (id: string) => {
|
||||
</template>
|
||||
<style scoped>
|
||||
.settings-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: calc(100vh - 90px);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-back {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.row-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.row-item {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ElButton, ElIcon, ElImage, ElMessage } from 'element-plus';
|
||||
import { api } from '#/api/request';
|
||||
import bookIcon from '#/assets/ai/knowledge/book.svg';
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import { navigateBackToList } from '#/router/list-return-context';
|
||||
import { createLazyComponentController } from '#/utils/lazy-component';
|
||||
import DocumentImportBatchStatus from '#/views/ai/documentCollection/DocumentImportBatchStatus.vue';
|
||||
|
||||
@@ -150,8 +151,13 @@ const getKnowledge = () => {
|
||||
onMounted(() => {
|
||||
getKnowledge();
|
||||
});
|
||||
const back = () => {
|
||||
router.push({ path: '/ai/documentCollection' });
|
||||
const back = async () => {
|
||||
await navigateBackToList(
|
||||
router,
|
||||
route.query,
|
||||
['/ai/documentCollection'],
|
||||
'/ai/documentCollection',
|
||||
);
|
||||
};
|
||||
const isFaqCollection = computed(
|
||||
() => knowledgeInfo.value.collectionType === 'FAQ',
|
||||
|
||||
@@ -5,9 +5,10 @@ import type {
|
||||
ActionButton,
|
||||
CardPrimaryAction,
|
||||
} from '#/components/page/CardList.vue';
|
||||
import type { OfflineImpactCheck } from '#/views/ai/shared/offline-impact';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@easyflow/access';
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
@@ -43,15 +44,20 @@ import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import CardPage from '#/components/page/CardList.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import {
|
||||
buildListRouteFullPath,
|
||||
mergeListRouteStateQuery,
|
||||
parseListRouteState,
|
||||
watchListRouteState,
|
||||
} from '#/composables/useListRouteState';
|
||||
import { withListReturnTo } from '#/router/list-return-context';
|
||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
import { createLazyComponentController } from '#/utils/lazy-component';
|
||||
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||
import { documentCollectionListRouteSchema } from '#/views/ai/documentCollection/document-collection-list-route-state';
|
||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||
import {
|
||||
buildOfflineImpactMessage,
|
||||
type OfflineImpactCheck,
|
||||
} from '#/views/ai/shared/offline-impact';
|
||||
import { buildOfflineImpactMessage } from '#/views/ai/shared/offline-impact';
|
||||
import {
|
||||
canAiResourceDelete,
|
||||
canAiResourceOffline,
|
||||
@@ -61,7 +67,35 @@ import {
|
||||
resolveAiResourceDisplayStatus,
|
||||
} from '#/views/ai/shared/publish-status';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const initialListState = parseListRouteState(
|
||||
route.query,
|
||||
documentCollectionListRouteSchema,
|
||||
);
|
||||
const selectedCategoryId = ref(initialListState.categoryId);
|
||||
const searchKeyword = ref(initialListState.keyword);
|
||||
const currentPageNumber = ref(initialListState.pageNumber);
|
||||
const currentPageSize = ref(initialListState.pageSize);
|
||||
let initialCategoryChangePending = Boolean(initialListState.categoryId);
|
||||
watchListRouteState(route, documentCollectionListRouteSchema, (state) => {
|
||||
if (state.categoryId !== selectedCategoryId.value) {
|
||||
initialCategoryChangePending = Boolean(state.categoryId);
|
||||
}
|
||||
selectedCategoryId.value = state.categoryId;
|
||||
searchKeyword.value = state.keyword;
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
pageDataRef.value?.restoreState?.({
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
queryParams: {
|
||||
categoryId: state.categoryId || undefined,
|
||||
isQueryOr: true,
|
||||
title: state.keyword || undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
const userStore = useUserStore();
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const collectionTypeLabelMap = {
|
||||
@@ -153,6 +187,20 @@ function resolveNavTitle(row: Record<string, any>) {
|
||||
return row?.title || row?.name || '';
|
||||
}
|
||||
|
||||
function currentListFullPath() {
|
||||
return buildListRouteFullPath(
|
||||
router,
|
||||
route.query,
|
||||
{
|
||||
categoryId: selectedCategoryId.value,
|
||||
keyword: searchKeyword.value,
|
||||
pageNumber: currentPageNumber.value,
|
||||
pageSize: currentPageSize.value,
|
||||
},
|
||||
documentCollectionListRouteSchema,
|
||||
);
|
||||
}
|
||||
|
||||
function openKnowledgeDetail(row: {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -164,6 +212,7 @@ function openKnowledgeDetail(row: {
|
||||
id: row.id,
|
||||
pageKey: '/ai/documentCollection',
|
||||
navTitle: resolveNavTitle(row),
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -243,6 +292,7 @@ const actions: ActionButton[] = [
|
||||
pageKey: '/ai/documentCollection',
|
||||
navTitle: resolveNavTitle(row),
|
||||
activeMenu: 'knowledgeSearch',
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -451,18 +501,18 @@ function resolvePublishStatusMeta(
|
||||
tone: 'danger',
|
||||
};
|
||||
}
|
||||
case 'OFFLINE_PENDING': {
|
||||
return {
|
||||
label: $t('documentCollection.publishStatusOfflinePending'),
|
||||
tone: 'pending',
|
||||
};
|
||||
}
|
||||
case 'OFFLINE': {
|
||||
return {
|
||||
label: $t('documentCollection.publishStatusOffline'),
|
||||
tone: 'draft',
|
||||
};
|
||||
}
|
||||
case 'OFFLINE_PENDING': {
|
||||
return {
|
||||
label: $t('documentCollection.publishStatusOfflinePending'),
|
||||
tone: 'pending',
|
||||
};
|
||||
}
|
||||
case 'PUBLISH_PENDING': {
|
||||
return {
|
||||
label: $t('documentCollection.publishStatusPublishPending'),
|
||||
@@ -537,7 +587,12 @@ const formRules = computed(() => {
|
||||
return rules;
|
||||
});
|
||||
const handleSearch = (params: any) => {
|
||||
pageDataRef.value.setQuery({ title: params, isQueryOr: true });
|
||||
searchKeyword.value = String(params || '');
|
||||
pageDataRef.value.setQuery({
|
||||
categoryId: selectedCategoryId.value || undefined,
|
||||
title: searchKeyword.value || undefined,
|
||||
isQueryOr: true,
|
||||
});
|
||||
};
|
||||
const reloadKnowledgeList = () => {
|
||||
pageDataRef.value?.reload?.();
|
||||
@@ -567,6 +622,19 @@ const getCategoryList = async () => {
|
||||
},
|
||||
...res.data,
|
||||
];
|
||||
if (
|
||||
selectedCategoryId.value &&
|
||||
!categoryList.value.some(
|
||||
(item) => String(item.id) === selectedCategoryId.value,
|
||||
)
|
||||
) {
|
||||
selectedCategoryId.value = '';
|
||||
initialCategoryChangePending = false;
|
||||
pageDataRef.value?.setQuery?.({
|
||||
title: searchKeyword.value || undefined,
|
||||
isQueryOr: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
function removeCategory(row: any) {
|
||||
@@ -686,7 +754,42 @@ function handleSubmit() {
|
||||
});
|
||||
}
|
||||
function changeCategory(category: any) {
|
||||
pageDataRef.value.setQuery({ categoryId: category.id });
|
||||
const categoryId = String(category?.id || '');
|
||||
if (initialCategoryChangePending && categoryId === selectedCategoryId.value) {
|
||||
initialCategoryChangePending = false;
|
||||
return;
|
||||
}
|
||||
initialCategoryChangePending = false;
|
||||
selectedCategoryId.value = categoryId;
|
||||
pageDataRef.value.setQuery({
|
||||
categoryId: categoryId || undefined,
|
||||
title: searchKeyword.value || undefined,
|
||||
isQueryOr: true,
|
||||
});
|
||||
}
|
||||
function handlePageStateChange(state: {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
const query = mergeListRouteStateQuery(
|
||||
route.query,
|
||||
{
|
||||
categoryId: selectedCategoryId.value,
|
||||
keyword: searchKeyword.value,
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
},
|
||||
documentCollectionListRouteSchema,
|
||||
);
|
||||
const target = router.resolve({
|
||||
path: documentCollectionListRouteSchema.path,
|
||||
query,
|
||||
});
|
||||
if (target.fullPath !== route.fullPath) {
|
||||
void router.replace(target);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -695,6 +798,7 @@ function changeCategory(category: any) {
|
||||
<div class="knowledge-header">
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
:initial-value="searchKeyword"
|
||||
@search="handleSearch"
|
||||
@button-click="handleButtonClick"
|
||||
/>
|
||||
@@ -706,15 +810,22 @@ function changeCategory(category: any) {
|
||||
:menus="categoryList"
|
||||
:control-btns="controlBtns"
|
||||
:footer-button="footerButton"
|
||||
:default-selected="selectedCategoryId"
|
||||
@change="changeCategory"
|
||||
/>
|
||||
<div class="h-full flex-1 overflow-auto">
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/documentCollection/page"
|
||||
:page-size="12"
|
||||
:page-size="initialListState.pageSize"
|
||||
:page-sizes="[12, 24, 36, 48]"
|
||||
:init-query-params="{ status: 1 }"
|
||||
:initial-page-number="initialListState.pageNumber"
|
||||
:initial-query-params="{
|
||||
categoryId: initialListState.categoryId || undefined,
|
||||
title: initialListState.keyword || undefined,
|
||||
isQueryOr: true,
|
||||
}"
|
||||
@state-change="handlePageStateChange"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<CardPage
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
defineListRouteStateSchema,
|
||||
positiveIntegerListRouteField,
|
||||
stringListRouteField,
|
||||
} from '#/composables/useListRouteState';
|
||||
|
||||
interface DocumentCollectionListRouteState {
|
||||
categoryId: string;
|
||||
keyword: string;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const documentCollectionListRouteSchema =
|
||||
defineListRouteStateSchema<DocumentCollectionListRouteState>({
|
||||
path: '/ai/documentCollection',
|
||||
fields: {
|
||||
categoryId: stringListRouteField(),
|
||||
keyword: stringListRouteField(),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [12, 24, 36, 48],
|
||||
defaultValue: 12,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export { documentCollectionListRouteSchema };
|
||||
export type { DocumentCollectionListRouteState };
|
||||
@@ -29,13 +29,18 @@ import PluginToolIcon from '#/components/icons/PluginToolIcon.vue';
|
||||
import CardPage from '#/components/page/CardList.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import {
|
||||
buildListRouteFullPath,
|
||||
watchListRouteState,
|
||||
} from '#/composables/useListRouteState';
|
||||
import { withListReturnTo } from '#/router/list-return-context';
|
||||
import { createLazyComponentController } from '#/utils/lazy-component';
|
||||
|
||||
import { buildPluginPageQueryParams } from './plugin-query';
|
||||
import {
|
||||
buildPluginToolsReturnQuery,
|
||||
mergePluginListRouteQuery,
|
||||
parsePluginListRouteState,
|
||||
pluginListRouteSchema,
|
||||
} from './plugin-route-state';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -95,7 +100,7 @@ function openPluginTools(item: PluginRecord) {
|
||||
id: item.id,
|
||||
pageKey: '/ai/plugin',
|
||||
navTitle: resolveNavTitle(item),
|
||||
...buildPluginToolsReturnQuery(currentListState()),
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -217,6 +222,20 @@ const handleDelete = (item: PluginRecord) => {
|
||||
};
|
||||
|
||||
const pageDataRef = ref();
|
||||
watchListRouteState(route, pluginListRouteSchema, (state) => {
|
||||
if (String(state.categoryId) !== String(selectedCategoryId.value)) {
|
||||
suppressInitialCategoryChange.value = true;
|
||||
}
|
||||
selectedCategoryId.value = state.categoryId;
|
||||
searchKeyword.value = state.keyword.trim();
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
pageDataRef.value?.restoreState?.({
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
queryParams: buildPluginPageQueryParams(state.categoryId, state.keyword),
|
||||
});
|
||||
});
|
||||
const headerButtons = [
|
||||
{
|
||||
key: 'add',
|
||||
@@ -265,6 +284,15 @@ function currentListState(): PluginListRouteState {
|
||||
};
|
||||
}
|
||||
|
||||
function currentListFullPath() {
|
||||
return buildListRouteFullPath(
|
||||
router,
|
||||
route.query,
|
||||
currentListState(),
|
||||
pluginListRouteSchema,
|
||||
);
|
||||
}
|
||||
|
||||
function syncListRouteState() {
|
||||
const target = {
|
||||
path: '/ai/plugin',
|
||||
@@ -325,7 +353,7 @@ const handleDeleteCategory = (params: PluginCategory) => {
|
||||
const handleClickCategory = (item: PluginCategory) => {
|
||||
if (
|
||||
suppressInitialCategoryChange.value &&
|
||||
String(item.id) === initialListState.categoryId
|
||||
String(item.id) === String(selectedCategoryId.value)
|
||||
) {
|
||||
suppressInitialCategoryChange.value = false;
|
||||
selectedCategoryId.value = item.id;
|
||||
|
||||
@@ -19,16 +19,11 @@ import {
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { navigateBackToList } from '#/router/list-return-context';
|
||||
import PluginInputAndOutParams from '#/views/ai/plugin/PluginInputAndOutParams.vue';
|
||||
import PluginRunTestModal from '#/views/ai/plugin/PluginRunTestModal.vue';
|
||||
import WorkflowApprovalSnapshotPreview from '#/views/system/approval/components/WorkflowApprovalSnapshotPreview.vue';
|
||||
|
||||
import {
|
||||
buildPluginListRouteQuery,
|
||||
buildPluginToolsRouteQueryFromEdit,
|
||||
parsePluginToolsReturnState,
|
||||
} from './plugin-route-state';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -169,19 +164,20 @@ function handleClickHeader(index: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function back() {
|
||||
async function back() {
|
||||
const pluginId = String(route.query.pluginId || '');
|
||||
if (pluginId) {
|
||||
void router.replace({
|
||||
path: '/ai/plugin/tools',
|
||||
query: buildPluginToolsRouteQueryFromEdit(route.query, pluginId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
void router.replace({
|
||||
path: '/ai/plugin',
|
||||
query: buildPluginListRouteQuery(parsePluginToolsReturnState(route.query)),
|
||||
});
|
||||
const fallbackPath = pluginId
|
||||
? router.resolve({
|
||||
path: '/ai/plugin/tools',
|
||||
query: { id: pluginId },
|
||||
}).fullPath
|
||||
: '/ai/plugin';
|
||||
await navigateBackToList(
|
||||
router,
|
||||
route.query,
|
||||
pluginId ? ['/ai/plugin/tools'] : ['/ai/plugin'],
|
||||
fallbackPath,
|
||||
);
|
||||
}
|
||||
|
||||
function updatePluginTool(index: number) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import { withListReturnTo } from '#/router/list-return-context';
|
||||
import AiPluginToolModal from '#/views/ai/plugin/AiPluginToolModal.vue';
|
||||
|
||||
import { buildPluginToolPageQueryParams } from './plugin-query';
|
||||
@@ -31,6 +32,10 @@ const props = defineProps({
|
||||
default: 1,
|
||||
type: Number,
|
||||
},
|
||||
returnTo: {
|
||||
default: '/ai/plugin/tools',
|
||||
type: String,
|
||||
},
|
||||
initialKeyword: {
|
||||
default: '',
|
||||
type: String,
|
||||
@@ -53,7 +58,6 @@ const emit = defineEmits<{
|
||||
},
|
||||
): void;
|
||||
}>();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
defineExpose({
|
||||
openPluginToolModal() {
|
||||
@@ -65,17 +69,27 @@ defineExpose({
|
||||
handleSearch: (params: string) => {
|
||||
pageDataRef.value.setQuery(buildPluginToolPageQueryParams(params));
|
||||
},
|
||||
restoreState: (state: {
|
||||
keyword: string;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}) => {
|
||||
return pageDataRef.value?.restoreState?.({
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
queryParams: buildPluginToolPageQueryParams(state.keyword),
|
||||
});
|
||||
},
|
||||
});
|
||||
const pageDataRef = ref();
|
||||
const handleEdit = (row: any) => {
|
||||
router.push({
|
||||
path: '/ai/plugin/tool/edit',
|
||||
query: {
|
||||
...route.query,
|
||||
query: withListReturnTo(props.returnTo, {
|
||||
id: row.id,
|
||||
pageKey: '/ai/plugin',
|
||||
pluginId: props.pluginId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -10,13 +10,17 @@ import { Back, Plus } from '@element-plus/icons-vue';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import {
|
||||
buildListRouteFullPath,
|
||||
watchListRouteState,
|
||||
} from '#/composables/useListRouteState';
|
||||
import { navigateBackToList } from '#/router/list-return-context';
|
||||
import PluginToolTable from '#/views/ai/plugin/PluginToolTable.vue';
|
||||
|
||||
import {
|
||||
buildPluginListRouteQuery,
|
||||
mergePluginToolListRouteQuery,
|
||||
parsePluginToolListRouteState,
|
||||
parsePluginToolsReturnState,
|
||||
pluginToolListRouteSchema,
|
||||
} from './plugin-route-state';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -29,6 +33,20 @@ const pluginToolRef = ref();
|
||||
const toolSearchKeyword = ref(initialToolListState.keyword.trim());
|
||||
const currentToolPageNumber = ref(initialToolListState.pageNumber);
|
||||
const currentToolPageSize = ref(initialToolListState.pageSize);
|
||||
watchListRouteState(route, pluginToolListRouteSchema, (state) => {
|
||||
const nextPluginId = String(route.query.id || '');
|
||||
const pluginChanged = nextPluginId !== pluginId.value;
|
||||
pluginId.value = nextPluginId;
|
||||
toolSearchKeyword.value = state.keyword.trim();
|
||||
currentToolPageNumber.value = state.pageNumber;
|
||||
currentToolPageSize.value = state.pageSize;
|
||||
const restored = pluginToolRef.value?.restoreState?.(state);
|
||||
if (pluginChanged) {
|
||||
pluginInfo.value = {};
|
||||
if (!restored) pluginToolRef.value?.reload?.();
|
||||
void loadPluginInfo();
|
||||
}
|
||||
});
|
||||
|
||||
const headerButtons = computed<any[]>(() => {
|
||||
const buttons: any[] = [
|
||||
@@ -73,6 +91,15 @@ function currentToolListState(): PluginToolListRouteState {
|
||||
};
|
||||
}
|
||||
|
||||
function currentToolListFullPath() {
|
||||
return buildListRouteFullPath(
|
||||
router,
|
||||
route.query,
|
||||
currentToolListState(),
|
||||
pluginToolListRouteSchema,
|
||||
);
|
||||
}
|
||||
|
||||
function syncToolListRouteState() {
|
||||
const target = {
|
||||
path: '/ai/plugin/tools',
|
||||
@@ -100,12 +127,12 @@ function handleSearch(params: string) {
|
||||
function handleButtonClick(event: any) {
|
||||
switch (event.key) {
|
||||
case 'back': {
|
||||
void router.replace({
|
||||
path: '/ai/plugin',
|
||||
query: buildPluginListRouteQuery(
|
||||
parsePluginToolsReturnState(route.query),
|
||||
),
|
||||
});
|
||||
void navigateBackToList(
|
||||
router,
|
||||
route.query,
|
||||
['/ai/plugin'],
|
||||
'/ai/plugin',
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'createTool': {
|
||||
@@ -133,6 +160,7 @@ function handleButtonClick(event: any) {
|
||||
:initial-keyword="initialToolListState.keyword"
|
||||
:initial-page-number="initialToolListState.pageNumber"
|
||||
:initial-page-size="initialToolListState.pageSize"
|
||||
:return-to="currentToolListFullPath()"
|
||||
@state-change="handleToolPageStateChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,11 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPluginListRouteQuery,
|
||||
buildPluginToolsReturnQuery,
|
||||
buildPluginToolsRouteQueryFromEdit,
|
||||
buildPluginToolListRouteQuery,
|
||||
mergePluginListRouteQuery,
|
||||
mergePluginToolListRouteQuery,
|
||||
parsePluginListRouteState,
|
||||
parsePluginToolListRouteState,
|
||||
parsePluginToolsReturnState,
|
||||
} from './plugin-route-state';
|
||||
|
||||
describe('plugin route state', () => {
|
||||
@@ -28,22 +26,13 @@ describe('plugin route state', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('round trips the plugin list state through plugin tools', () => {
|
||||
it('serializes plugin list state into canonical list query', () => {
|
||||
const state = {
|
||||
categoryId: '7',
|
||||
keyword: '天气',
|
||||
pageNumber: 3,
|
||||
pageSize: 24,
|
||||
};
|
||||
const returnQuery = buildPluginToolsReturnQuery(state);
|
||||
|
||||
expect(returnQuery).toEqual({
|
||||
returnCategoryId: '7',
|
||||
returnKeyword: '天气',
|
||||
returnPageNumber: '3',
|
||||
returnPageSize: '24',
|
||||
});
|
||||
expect(parsePluginToolsReturnState(returnQuery)).toEqual(state);
|
||||
expect(buildPluginListRouteQuery(state)).toEqual({
|
||||
categoryId: '7',
|
||||
keyword: '天气',
|
||||
@@ -66,8 +55,8 @@ describe('plugin route state', () => {
|
||||
});
|
||||
expect(
|
||||
parsePluginToolListRouteState({
|
||||
toolPageNumber: '0',
|
||||
toolPageSize: '999',
|
||||
pageNumber: '0',
|
||||
pageSize: '999',
|
||||
}),
|
||||
).toEqual({
|
||||
keyword: '',
|
||||
@@ -99,9 +88,8 @@ describe('plugin route state', () => {
|
||||
mergePluginToolListRouteQuery(
|
||||
{
|
||||
id: '88',
|
||||
returnCategoryId: '7',
|
||||
returnPageNumber: '3',
|
||||
toolKeyword: '旧工具',
|
||||
returnTo: '/ai/plugin?categoryId=7&pageNumber=3',
|
||||
keyword: '旧工具',
|
||||
},
|
||||
{
|
||||
keyword: '查询工具',
|
||||
@@ -111,29 +99,24 @@ describe('plugin route state', () => {
|
||||
),
|
||||
).toEqual({
|
||||
id: '88',
|
||||
returnCategoryId: '7',
|
||||
returnPageNumber: '3',
|
||||
toolKeyword: '查询工具',
|
||||
toolPageNumber: '2',
|
||||
toolPageSize: '20',
|
||||
keyword: '查询工具',
|
||||
pageNumber: '2',
|
||||
pageSize: '20',
|
||||
returnTo: '/ai/plugin?categoryId=7&pageNumber=3',
|
||||
});
|
||||
});
|
||||
|
||||
it('restores plugin tools query after editing a tool', () => {
|
||||
it('serializes plugin tool list state with common query keys', () => {
|
||||
expect(
|
||||
buildPluginToolsRouteQueryFromEdit(
|
||||
{
|
||||
id: 'tool-1',
|
||||
pluginId: 'plugin-1',
|
||||
returnCategoryId: '7',
|
||||
toolPageNumber: '2',
|
||||
},
|
||||
'plugin-1',
|
||||
),
|
||||
buildPluginToolListRouteQuery({
|
||||
keyword: '查询工具',
|
||||
pageNumber: 2,
|
||||
pageSize: 20,
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'plugin-1',
|
||||
returnCategoryId: '7',
|
||||
toolPageNumber: '2',
|
||||
keyword: '查询工具',
|
||||
pageNumber: '2',
|
||||
pageSize: '20',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
import type { LocationQuery } from 'vue-router';
|
||||
import type { LocationQuery, LocationQueryRaw } from 'vue-router';
|
||||
|
||||
const DEFAULT_PLUGIN_PAGE_NUMBER = 1;
|
||||
const DEFAULT_PLUGIN_PAGE_SIZE = 12;
|
||||
const DEFAULT_PLUGIN_CATEGORY_ID = '0';
|
||||
const PLUGIN_PAGE_SIZES = new Set([12, 24, 36, 48]);
|
||||
|
||||
const DEFAULT_TOOL_PAGE_NUMBER = 1;
|
||||
const DEFAULT_TOOL_PAGE_SIZE = 10;
|
||||
const TOOL_PAGE_SIZES = new Set([10, 20, 50, 100]);
|
||||
|
||||
const PLUGIN_LIST_QUERY_KEYS = {
|
||||
categoryId: 'categoryId',
|
||||
keyword: 'keyword',
|
||||
pageNumber: 'pageNumber',
|
||||
pageSize: 'pageSize',
|
||||
} as const;
|
||||
|
||||
const PLUGIN_RETURN_QUERY_KEYS = {
|
||||
categoryId: 'returnCategoryId',
|
||||
keyword: 'returnKeyword',
|
||||
pageNumber: 'returnPageNumber',
|
||||
pageSize: 'returnPageSize',
|
||||
} as const;
|
||||
|
||||
const TOOL_LIST_QUERY_KEYS = {
|
||||
keyword: 'toolKeyword',
|
||||
pageNumber: 'toolPageNumber',
|
||||
pageSize: 'toolPageSize',
|
||||
} as const;
|
||||
import {
|
||||
buildListRouteStateQuery,
|
||||
defineListRouteStateSchema,
|
||||
mergeListRouteStateQuery,
|
||||
parseListRouteState,
|
||||
positiveIntegerListRouteField,
|
||||
stringListRouteField,
|
||||
} from '#/composables/useListRouteState';
|
||||
|
||||
interface PluginListRouteState {
|
||||
categoryId: string;
|
||||
@@ -42,156 +22,74 @@ interface PluginToolListRouteState {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
function readQueryValue(query: LocationQuery, key: string): string {
|
||||
const value = query[key];
|
||||
const normalized = Array.isArray(value) ? value[0] : value;
|
||||
return normalized === null || normalized === undefined
|
||||
? ''
|
||||
: String(normalized);
|
||||
const pluginListRouteSchema = defineListRouteStateSchema<PluginListRouteState>({
|
||||
path: '/ai/plugin',
|
||||
fields: {
|
||||
categoryId: stringListRouteField({ defaultValue: '0' }),
|
||||
keyword: stringListRouteField(),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [12, 24, 36, 48],
|
||||
defaultValue: 12,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const pluginToolListRouteSchema =
|
||||
defineListRouteStateSchema<PluginToolListRouteState>({
|
||||
path: '/ai/plugin/tools',
|
||||
fields: {
|
||||
keyword: stringListRouteField(),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [10, 20, 50, 100],
|
||||
defaultValue: 10,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
function parsePluginListRouteState(query: LocationQuery) {
|
||||
return parseListRouteState(query, pluginListRouteSchema);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string, fallback: number): number {
|
||||
if (!/^\d+$/.test(value)) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parsePluginState(
|
||||
query: LocationQuery,
|
||||
keys: typeof PLUGIN_LIST_QUERY_KEYS | typeof PLUGIN_RETURN_QUERY_KEYS,
|
||||
): PluginListRouteState {
|
||||
const pageSize = parsePositiveInteger(
|
||||
readQueryValue(query, keys.pageSize),
|
||||
DEFAULT_PLUGIN_PAGE_SIZE,
|
||||
);
|
||||
return {
|
||||
categoryId:
|
||||
readQueryValue(query, keys.categoryId) || DEFAULT_PLUGIN_CATEGORY_ID,
|
||||
keyword: readQueryValue(query, keys.keyword),
|
||||
pageNumber: parsePositiveInteger(
|
||||
readQueryValue(query, keys.pageNumber),
|
||||
DEFAULT_PLUGIN_PAGE_NUMBER,
|
||||
),
|
||||
pageSize: PLUGIN_PAGE_SIZES.has(pageSize)
|
||||
? pageSize
|
||||
: DEFAULT_PLUGIN_PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPluginStateQuery(
|
||||
function buildPluginListRouteQuery(
|
||||
state: PluginListRouteState,
|
||||
keys: typeof PLUGIN_LIST_QUERY_KEYS | typeof PLUGIN_RETURN_QUERY_KEYS,
|
||||
): LocationQuery {
|
||||
return {
|
||||
...(state.pageNumber === DEFAULT_PLUGIN_PAGE_NUMBER
|
||||
? {}
|
||||
: { [keys.pageNumber]: String(state.pageNumber) }),
|
||||
...(state.pageSize === DEFAULT_PLUGIN_PAGE_SIZE
|
||||
? {}
|
||||
: { [keys.pageSize]: String(state.pageSize) }),
|
||||
...(state.categoryId === DEFAULT_PLUGIN_CATEGORY_ID
|
||||
? {}
|
||||
: { [keys.categoryId]: state.categoryId }),
|
||||
...(state.keyword ? { [keys.keyword]: state.keyword } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePluginListRouteState(query: LocationQuery): PluginListRouteState {
|
||||
return parsePluginState(query, PLUGIN_LIST_QUERY_KEYS);
|
||||
}
|
||||
|
||||
function parsePluginToolsReturnState(
|
||||
query: LocationQuery,
|
||||
): PluginListRouteState {
|
||||
return parsePluginState(query, PLUGIN_RETURN_QUERY_KEYS);
|
||||
}
|
||||
|
||||
function buildPluginListRouteQuery(state: PluginListRouteState): LocationQuery {
|
||||
return buildPluginStateQuery(state, PLUGIN_LIST_QUERY_KEYS);
|
||||
}
|
||||
|
||||
function buildPluginToolsReturnQuery(
|
||||
state: PluginListRouteState,
|
||||
): LocationQuery {
|
||||
return buildPluginStateQuery(state, PLUGIN_RETURN_QUERY_KEYS);
|
||||
): LocationQueryRaw {
|
||||
return buildListRouteStateQuery(state, pluginListRouteSchema);
|
||||
}
|
||||
|
||||
function mergePluginListRouteQuery(
|
||||
query: LocationQuery,
|
||||
state: PluginListRouteState,
|
||||
): LocationQuery {
|
||||
const listKeys = new Set<string>(Object.values(PLUGIN_LIST_QUERY_KEYS));
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(query).filter(([key]) => !listKeys.has(key)),
|
||||
),
|
||||
...buildPluginListRouteQuery(state),
|
||||
};
|
||||
): LocationQueryRaw {
|
||||
return mergeListRouteStateQuery(query, state, pluginListRouteSchema);
|
||||
}
|
||||
|
||||
function parsePluginToolListRouteState(
|
||||
query: LocationQuery,
|
||||
): PluginToolListRouteState {
|
||||
const pageSize = parsePositiveInteger(
|
||||
readQueryValue(query, TOOL_LIST_QUERY_KEYS.pageSize),
|
||||
DEFAULT_TOOL_PAGE_SIZE,
|
||||
);
|
||||
return {
|
||||
keyword: readQueryValue(query, TOOL_LIST_QUERY_KEYS.keyword),
|
||||
pageNumber: parsePositiveInteger(
|
||||
readQueryValue(query, TOOL_LIST_QUERY_KEYS.pageNumber),
|
||||
DEFAULT_TOOL_PAGE_NUMBER,
|
||||
),
|
||||
pageSize: TOOL_PAGE_SIZES.has(pageSize) ? pageSize : DEFAULT_TOOL_PAGE_SIZE,
|
||||
};
|
||||
function parsePluginToolListRouteState(query: LocationQuery) {
|
||||
return parseListRouteState(query, pluginToolListRouteSchema);
|
||||
}
|
||||
|
||||
function buildPluginToolListRouteQuery(
|
||||
state: PluginToolListRouteState,
|
||||
): LocationQuery {
|
||||
return {
|
||||
...(state.pageNumber === DEFAULT_TOOL_PAGE_NUMBER
|
||||
? {}
|
||||
: { [TOOL_LIST_QUERY_KEYS.pageNumber]: String(state.pageNumber) }),
|
||||
...(state.pageSize === DEFAULT_TOOL_PAGE_SIZE
|
||||
? {}
|
||||
: { [TOOL_LIST_QUERY_KEYS.pageSize]: String(state.pageSize) }),
|
||||
...(state.keyword ? { [TOOL_LIST_QUERY_KEYS.keyword]: state.keyword } : {}),
|
||||
};
|
||||
): LocationQueryRaw {
|
||||
return buildListRouteStateQuery(state, pluginToolListRouteSchema);
|
||||
}
|
||||
|
||||
function mergePluginToolListRouteQuery(
|
||||
query: LocationQuery,
|
||||
state: PluginToolListRouteState,
|
||||
): LocationQuery {
|
||||
const listKeys = new Set<string>(Object.values(TOOL_LIST_QUERY_KEYS));
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(query).filter(([key]) => !listKeys.has(key)),
|
||||
),
|
||||
...buildPluginToolListRouteQuery(state),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPluginToolsRouteQueryFromEdit(
|
||||
query: LocationQuery,
|
||||
pluginId: string,
|
||||
): LocationQuery {
|
||||
const nextQuery: LocationQuery = { ...query, id: pluginId };
|
||||
delete nextQuery.pluginId;
|
||||
return nextQuery;
|
||||
): LocationQueryRaw {
|
||||
return mergeListRouteStateQuery(query, state, pluginToolListRouteSchema);
|
||||
}
|
||||
|
||||
export {
|
||||
buildPluginListRouteQuery,
|
||||
buildPluginToolsReturnQuery,
|
||||
buildPluginToolsRouteQueryFromEdit,
|
||||
buildPluginToolListRouteQuery,
|
||||
mergePluginListRouteQuery,
|
||||
mergePluginToolListRouteQuery,
|
||||
parsePluginListRouteState,
|
||||
parsePluginToolListRouteState,
|
||||
parsePluginToolsReturnState,
|
||||
pluginListRouteSchema,
|
||||
pluginToolListRouteSchema,
|
||||
};
|
||||
export type { PluginListRouteState, PluginToolListRouteState };
|
||||
|
||||
@@ -37,6 +37,10 @@ import {
|
||||
} from 'element-plus';
|
||||
|
||||
import { hasPermission } from '#/api/common/hasPermission';
|
||||
import {
|
||||
navigateBackToList,
|
||||
resolveListReturnPath,
|
||||
} from '#/router/list-return-context';
|
||||
import {
|
||||
canAiResourceDelete,
|
||||
canAiResourceOffline,
|
||||
@@ -211,15 +215,21 @@ async function confirmUnsavedNavigation() {
|
||||
onBeforeRouteLeave(confirmUnsavedNavigation);
|
||||
onBeforeRouteUpdate(confirmUnsavedNavigation);
|
||||
|
||||
async function replaceRouteDuringOperation(path: string) {
|
||||
async function returnToSkillListDuringOperation() {
|
||||
allowOperationNavigation = true;
|
||||
try {
|
||||
await router.replace(path);
|
||||
await router.replace(
|
||||
resolveListReturnPath(router, route.query, ['/ai/skill'], '/ai/skill'),
|
||||
);
|
||||
} finally {
|
||||
allowOperationNavigation = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function backToSkillList() {
|
||||
await navigateBackToList(router, route.query, ['/ai/skill'], '/ai/skill');
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const request = ++initRequest;
|
||||
loading.value = true;
|
||||
@@ -227,7 +237,7 @@ async function init() {
|
||||
loadAccessDenied.value = false;
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await replaceRouteDuringOperation('/ai/skill');
|
||||
await returnToSkillListDuringOperation();
|
||||
return;
|
||||
}
|
||||
await loadCategories(request);
|
||||
@@ -355,9 +365,8 @@ async function saveFiles(allowLifecycleAction = false, showFeedback = true) {
|
||||
}
|
||||
|
||||
async function flushPendingChanges(allowLifecycleAction = false) {
|
||||
if (resourceDirty.value) {
|
||||
if (!(await saveFiles(allowLifecycleAction, false))) return false;
|
||||
}
|
||||
if (resourceDirty.value && !(await saveFiles(allowLifecycleAction, false)))
|
||||
return false;
|
||||
const panel = capabilityPanelRef.value;
|
||||
if (capabilityDirty.value || panel?.hasDirty()) {
|
||||
if (!canBindCapabilities.value || !panel) {
|
||||
@@ -524,7 +533,7 @@ async function remove() {
|
||||
if (res.errorCode === 0) {
|
||||
if (res.data === null || res.data === undefined) {
|
||||
ElMessage.success(res.message || 'Skill 已删除');
|
||||
await replaceRouteDuringOperation('/ai/skill');
|
||||
await returnToSkillListDuringOperation();
|
||||
} else {
|
||||
ElMessage.success(res.message || '已提交删除审批');
|
||||
await init();
|
||||
@@ -561,7 +570,7 @@ function handleSaveShortcut(event: KeyboardEvent) {
|
||||
text
|
||||
:disabled="operationLocked"
|
||||
aria-label="返回 Skill 列表"
|
||||
@click="router.push('/ai/skill')"
|
||||
@click="backToSkillList"
|
||||
/>
|
||||
<div class="skill-detail-page__title">
|
||||
<div>
|
||||
@@ -674,7 +683,7 @@ function handleSaveShortcut(event: KeyboardEvent) {
|
||||
<template #extra>
|
||||
<ElButton
|
||||
:type="loadAccessDenied ? 'primary' : 'default'"
|
||||
@click="router.push('/ai/skill')"
|
||||
@click="backToSkillList"
|
||||
>
|
||||
返回 Skill 列表
|
||||
</ElButton>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
reactive,
|
||||
ref,
|
||||
} from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { downloadFileFromBlob, formatDate } from '@easyflow/utils';
|
||||
|
||||
@@ -61,7 +61,14 @@ import { hasPermission } from '#/api/common/hasPermission';
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import {
|
||||
buildListRouteFullPath,
|
||||
mergeListRouteStateQuery,
|
||||
parseListRouteState,
|
||||
watchListRouteState,
|
||||
} from '#/composables/useListRouteState';
|
||||
import { $t } from '#/locales';
|
||||
import { withListReturnTo } from '#/router/list-return-context';
|
||||
import {
|
||||
canAiResourceDelete,
|
||||
canAiResourceOffline,
|
||||
@@ -95,19 +102,43 @@ import {
|
||||
resolveSkillImportConflictReasonLabel,
|
||||
resolveSkillImportStep,
|
||||
} from './skill-import';
|
||||
import { skillListRouteSchema } from './skill-list-route-state';
|
||||
import SkillCategoryFormDialog from './SkillCategoryFormDialog.vue';
|
||||
import SkillCreateDialog from './SkillCreateDialog.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const initialListState = parseListRouteState(route.query, skillListRouteSchema);
|
||||
const pageDataRef = ref<any>();
|
||||
const headerSearchRef = ref<{ reset: () => void }>();
|
||||
const importInputRef = ref<HTMLInputElement>();
|
||||
const categories = ref<SkillCategory[]>([]);
|
||||
const categoryLoading = ref(false);
|
||||
const selectedRows = ref<SkillInfo[]>([]);
|
||||
const selectedCategoryId = ref<number | string>('');
|
||||
const selectedCategoryId = ref<number | string>(initialListState.categoryId);
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
keyword: initialListState.keyword,
|
||||
});
|
||||
const currentPageNumber = ref(initialListState.pageNumber);
|
||||
const currentPageSize = ref(initialListState.pageSize);
|
||||
let initialCategoryChangePending = Boolean(initialListState.categoryId);
|
||||
watchListRouteState(route, skillListRouteSchema, (state) => {
|
||||
if (String(state.categoryId) !== String(selectedCategoryId.value)) {
|
||||
initialCategoryChangePending = Boolean(state.categoryId);
|
||||
}
|
||||
selectedCategoryId.value = state.categoryId;
|
||||
filters.keyword = state.keyword;
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
selectedRows.value = [];
|
||||
pageDataRef.value?.restoreState?.({
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
queryParams: {
|
||||
categoryId: state.categoryId || undefined,
|
||||
displayName: state.keyword || undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const importDialogOpen = ref(false);
|
||||
@@ -311,6 +342,16 @@ async function loadCategories() {
|
||||
const res = await getSkillCategories();
|
||||
if (res.errorCode !== 0) throw new Error(res.message);
|
||||
categories.value = res.data || [];
|
||||
if (
|
||||
selectedCategoryId.value &&
|
||||
!flatCategories.value.some(
|
||||
(category) => String(category.id) === String(selectedCategoryId.value),
|
||||
)
|
||||
) {
|
||||
selectedCategoryId.value = '';
|
||||
initialCategoryChangePending = false;
|
||||
applyFilters();
|
||||
}
|
||||
} catch (error) {
|
||||
categories.value = [];
|
||||
ElMessage.error(
|
||||
@@ -362,10 +403,53 @@ function selectedTargetCategory() {
|
||||
}
|
||||
|
||||
function selectCategory(data?: SkillCategory) {
|
||||
selectedCategoryId.value = data?.id || '';
|
||||
const categoryId = data?.id || '';
|
||||
if (
|
||||
initialCategoryChangePending &&
|
||||
String(categoryId) === String(selectedCategoryId.value)
|
||||
) {
|
||||
initialCategoryChangePending = false;
|
||||
return;
|
||||
}
|
||||
initialCategoryChangePending = false;
|
||||
selectedCategoryId.value = categoryId;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function currentListFullPath() {
|
||||
return buildListRouteFullPath(
|
||||
router,
|
||||
route.query,
|
||||
{
|
||||
categoryId: String(selectedCategoryId.value || ''),
|
||||
keyword: filters.keyword,
|
||||
pageNumber: currentPageNumber.value,
|
||||
pageSize: currentPageSize.value,
|
||||
},
|
||||
skillListRouteSchema,
|
||||
);
|
||||
}
|
||||
|
||||
function handlePageStateChange(state: {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
const query = mergeListRouteStateQuery(
|
||||
route.query,
|
||||
{
|
||||
categoryId: String(selectedCategoryId.value || ''),
|
||||
keyword: filters.keyword,
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
},
|
||||
skillListRouteSchema,
|
||||
);
|
||||
const target = router.resolve({ path: skillListRouteSchema.path, query });
|
||||
if (target.fullPath !== route.fullPath) void router.replace(target);
|
||||
}
|
||||
|
||||
function formatModified(value?: string) {
|
||||
return value ? formatDate(value, 'YYYY-MM-DD HH:mm') : '—';
|
||||
}
|
||||
@@ -388,6 +472,7 @@ function openDetail(row: SkillInfo) {
|
||||
query: {
|
||||
navTitle: row.displayName || row.name || 'Skill 详情',
|
||||
pageKey: '/ai/skill',
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -402,6 +487,7 @@ function handleSkillCreated(payload: {
|
||||
...(payload.intent === 'PUBLISH' ? { publishIntent: '1' } : {}),
|
||||
navTitle: payload.skill.displayName || payload.skill.name || 'Skill 详情',
|
||||
pageKey: '/ai/skill',
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1045,6 +1131,7 @@ function isRowBusy(row: SkillInfo) {
|
||||
<HeaderSearch
|
||||
ref="headerSearchRef"
|
||||
:buttons="headerButtons"
|
||||
:initial-value="filters.keyword"
|
||||
search-placeholder="请输入 Skill 名称或描述"
|
||||
@search="handleHeaderSearch"
|
||||
@button-click="handleHeaderButtonClick"
|
||||
@@ -1094,8 +1181,14 @@ function isRowBusy(row: SkillInfo) {
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/skill/page"
|
||||
:page-size="12"
|
||||
:page-size="initialListState.pageSize"
|
||||
:page-sizes="[12, 24, 48]"
|
||||
:initial-page-number="initialListState.pageNumber"
|
||||
:initial-query-params="{
|
||||
categoryId: initialListState.categoryId || undefined,
|
||||
displayName: initialListState.keyword || undefined,
|
||||
}"
|
||||
@state-change="handlePageStateChange"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
defineListRouteStateSchema,
|
||||
positiveIntegerListRouteField,
|
||||
stringListRouteField,
|
||||
} from '#/composables/useListRouteState';
|
||||
|
||||
interface SkillListRouteState {
|
||||
categoryId: string;
|
||||
keyword: string;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const skillListRouteSchema = defineListRouteStateSchema<SkillListRouteState>({
|
||||
path: '/ai/skill',
|
||||
fields: {
|
||||
categoryId: stringListRouteField(),
|
||||
keyword: stringListRouteField(),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [12, 24, 48],
|
||||
defaultValue: 12,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export { skillListRouteSchema };
|
||||
export type { SkillListRouteState };
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ref,
|
||||
shallowRef,
|
||||
} from 'vue';
|
||||
import {useRoute} from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import {usePreferences} from '@easyflow/preferences';
|
||||
import {getOptions, sortNodes} from '@easyflow/utils';
|
||||
@@ -22,6 +22,7 @@ import {api} from '#/api/request';
|
||||
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
|
||||
import {$t} from '#/locales';
|
||||
import {router} from '#/router';
|
||||
import { navigateBackToList } from '#/router/list-return-context';
|
||||
import {
|
||||
resolveWorkflowShareFailureReason,
|
||||
resolveWorkflowShareWorkflowId,
|
||||
@@ -51,11 +52,6 @@ import {
|
||||
isWorkflowDataEmpty,
|
||||
normalizeWorkflowStartNodes,
|
||||
} from '../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
||||
import {
|
||||
buildWorkflowListRouteQuery,
|
||||
parseWorkflowDesignReturnState,
|
||||
} from './workflow-list-route-state';
|
||||
|
||||
import '@tinyflow-ai/vue/dist/index.css';
|
||||
|
||||
const WORKFLOW_VISIBLE_RENDER_NODE_THRESHOLD = 40;
|
||||
@@ -161,12 +157,13 @@ async function initializeWorkflow() {
|
||||
}
|
||||
}
|
||||
|
||||
function backToWorkflowList() {
|
||||
const listState = parseWorkflowDesignReturnState(route.query);
|
||||
router.replace({
|
||||
path: '/ai/workflow',
|
||||
query: buildWorkflowListRouteQuery(listState),
|
||||
});
|
||||
async function backToWorkflowList() {
|
||||
await navigateBackToList(
|
||||
router,
|
||||
route.query,
|
||||
['/ai/workflow'],
|
||||
'/ai/workflow',
|
||||
);
|
||||
}
|
||||
const WORKFLOW_DRAFT_WRITE_DELAY = 320;
|
||||
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
@@ -60,8 +60,13 @@ import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import CardList from '#/components/page/CardList.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
import {
|
||||
buildListRouteFullPath,
|
||||
watchListRouteState,
|
||||
} from '#/composables/useListRouteState';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { withListReturnTo } from '#/router/list-return-context';
|
||||
import { useDictStore } from '#/store';
|
||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
import { createLazyComponentController } from '#/utils/lazy-component';
|
||||
@@ -79,9 +84,9 @@ import {
|
||||
} from '#/views/ai/shared/publish-status';
|
||||
|
||||
import {
|
||||
buildWorkflowDesignReturnQuery,
|
||||
mergeWorkflowListRouteQuery,
|
||||
parseWorkflowListRouteState,
|
||||
workflowListRouteSchema,
|
||||
} from './workflow-list-route-state';
|
||||
|
||||
const ElXMarkdown = defineAsyncComponent(
|
||||
@@ -193,6 +198,7 @@ const actions: ActionButton[] = [
|
||||
name: 'RunPage',
|
||||
query: {
|
||||
id: row.id,
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -219,6 +225,7 @@ const actions: ActionButton[] = [
|
||||
name: 'ExecRecord',
|
||||
query: {
|
||||
workflowId: row.id,
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -305,6 +312,23 @@ const initialQueryParams = {
|
||||
categoryId: initialListState.categoryId || undefined,
|
||||
title: initialListState.keyword || undefined,
|
||||
};
|
||||
watchListRouteState(route, workflowListRouteSchema, (state) => {
|
||||
if (String(state.categoryId) !== String(selectedCategoryId.value)) {
|
||||
suppressInitialCategoryChange.value = Boolean(state.categoryId);
|
||||
}
|
||||
selectedCategoryId.value = state.categoryId;
|
||||
searchKeyword.value = state.keyword;
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
pageDataRef.value?.restoreState?.({
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
queryParams: {
|
||||
categoryId: state.categoryId || undefined,
|
||||
title: state.keyword || undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
const dictStore = useDictStore();
|
||||
const headerButtons = [
|
||||
{
|
||||
@@ -389,6 +413,14 @@ function currentListState(): WorkflowListRouteState {
|
||||
pageSize: pageState?.pageSize ?? currentPageSize.value,
|
||||
};
|
||||
}
|
||||
function currentListFullPath() {
|
||||
return buildListRouteFullPath(
|
||||
router,
|
||||
route.query,
|
||||
currentListState(),
|
||||
workflowListRouteSchema,
|
||||
);
|
||||
}
|
||||
function syncListRouteState() {
|
||||
const target = {
|
||||
path: '/ai/workflow',
|
||||
@@ -1134,7 +1166,7 @@ function toDesignPage(row: any) {
|
||||
id: row.id,
|
||||
pageKey: '/ai/workflow',
|
||||
navTitle: resolveNavTitle(row),
|
||||
...buildWorkflowDesignReturnQuery(currentListState()),
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1258,7 +1290,7 @@ function changeCategory(category: any) {
|
||||
const categoryId = category?.id ?? '';
|
||||
if (
|
||||
suppressInitialCategoryChange.value &&
|
||||
String(categoryId) === initialListState.categoryId
|
||||
String(categoryId) === String(selectedCategoryId.value)
|
||||
) {
|
||||
suppressInitialCategoryChange.value = false;
|
||||
selectedCategoryId.value = categoryId;
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import { api, SseClient } from '#/api/request';
|
||||
import workflowIcon from '#/assets/ai/workflow/workflowIcon.png';
|
||||
import { router } from '#/router';
|
||||
import { navigateBackToList } from '#/router/list-return-context';
|
||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||
import { resolveWorkflowShareFailureReason } from '#/utils/workflow-share-context';
|
||||
@@ -266,6 +267,15 @@ function initializeAdditionalValues() {
|
||||
extraSubmitting.value = false;
|
||||
}
|
||||
|
||||
async function backToWorkflowList() {
|
||||
await navigateBackToList(
|
||||
router,
|
||||
route.query,
|
||||
['/ai/workflow'],
|
||||
'/ai/workflow',
|
||||
);
|
||||
}
|
||||
|
||||
async function submitAdditionalInputs() {
|
||||
if (extraSubmitted.value) {
|
||||
return true;
|
||||
@@ -865,7 +875,7 @@ function executionTraceText(
|
||||
circle
|
||||
text
|
||||
aria-label="返回工作流"
|
||||
@click="router.replace({ path: '/ai/workflow' })"
|
||||
@click="backToWorkflowList"
|
||||
/>
|
||||
<ElAvatar
|
||||
:size="40"
|
||||
|
||||
@@ -22,18 +22,53 @@ import {
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import {
|
||||
buildListRouteFullPath,
|
||||
mergeListRouteStateQuery,
|
||||
parseListRouteState,
|
||||
watchListRouteState,
|
||||
} from '#/composables/useListRouteState';
|
||||
import { $t } from '#/locales';
|
||||
import {
|
||||
navigateBackToList,
|
||||
withListReturnTo,
|
||||
} from '#/router/list-return-context';
|
||||
import { useDictStore } from '#/store';
|
||||
|
||||
import { workflowExecListRouteSchema } from './workflow-exec-list-route-state';
|
||||
|
||||
const router = useRouter();
|
||||
const $route = useRoute();
|
||||
const initialListState = parseListRouteState(
|
||||
$route.query,
|
||||
workflowExecListRouteSchema,
|
||||
);
|
||||
onMounted(() => {
|
||||
initDict();
|
||||
});
|
||||
const formRef = ref<FormInstance>();
|
||||
const pageDataRef = ref();
|
||||
const formInline = ref({
|
||||
execKey: '',
|
||||
execKey: initialListState.execKey,
|
||||
});
|
||||
const appliedExecKey = ref(initialListState.execKey);
|
||||
const currentPageNumber = ref(initialListState.pageNumber);
|
||||
const currentPageSize = ref(initialListState.pageSize);
|
||||
let currentWorkflowId = String($route.query.workflowId || '');
|
||||
watchListRouteState($route, workflowExecListRouteSchema, (state) => {
|
||||
const nextWorkflowId = String($route.query.workflowId || '');
|
||||
const workflowChanged = nextWorkflowId !== currentWorkflowId;
|
||||
currentWorkflowId = nextWorkflowId;
|
||||
formInline.value.execKey = state.execKey;
|
||||
appliedExecKey.value = state.execKey;
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
const restored = pageDataRef.value?.restoreState?.({
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
queryParams: { execKey: state.execKey || undefined },
|
||||
});
|
||||
if (workflowChanged && !restored) pageDataRef.value?.reload?.();
|
||||
});
|
||||
const dictStore = useDictStore();
|
||||
function initDict() {
|
||||
@@ -42,12 +77,17 @@ function initDict() {
|
||||
function search(formEl: FormInstance | undefined) {
|
||||
formEl?.validate((valid) => {
|
||||
if (valid) {
|
||||
pageDataRef.value.setQuery(formInline.value);
|
||||
appliedExecKey.value = formInline.value.execKey;
|
||||
pageDataRef.value.setQuery({
|
||||
execKey: appliedExecKey.value || undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
function reset(formEl: FormInstance | undefined) {
|
||||
formEl?.resetFields();
|
||||
formInline.value.execKey = '';
|
||||
appliedExecKey.value = '';
|
||||
formEl?.clearValidate();
|
||||
pageDataRef.value.setQuery({});
|
||||
}
|
||||
function remove(row: any) {
|
||||
@@ -64,7 +104,7 @@ function remove(row: any) {
|
||||
instance.confirmButtonLoading = false;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
reset(formRef.value);
|
||||
pageDataRef.value?.reload?.();
|
||||
done();
|
||||
}
|
||||
})
|
||||
@@ -83,9 +123,51 @@ function toStepPage(row: any) {
|
||||
query: {
|
||||
recordId: row.id,
|
||||
workflowId: $route.query.workflowId,
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
}
|
||||
function currentListFullPath() {
|
||||
return buildListRouteFullPath(
|
||||
router,
|
||||
$route.query,
|
||||
{
|
||||
execKey: appliedExecKey.value,
|
||||
pageNumber: currentPageNumber.value,
|
||||
pageSize: currentPageSize.value,
|
||||
},
|
||||
workflowExecListRouteSchema,
|
||||
);
|
||||
}
|
||||
function handlePageStateChange(state: {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
const query = mergeListRouteStateQuery(
|
||||
$route.query,
|
||||
{
|
||||
execKey: appliedExecKey.value,
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
},
|
||||
workflowExecListRouteSchema,
|
||||
);
|
||||
const target = router.resolve({
|
||||
path: workflowExecListRouteSchema.path,
|
||||
query,
|
||||
});
|
||||
if (target.fullPath !== $route.fullPath) void router.replace(target);
|
||||
}
|
||||
async function backToWorkflowList() {
|
||||
await navigateBackToList(
|
||||
router,
|
||||
$route.query,
|
||||
['/ai/workflow'],
|
||||
'/ai/workflow',
|
||||
);
|
||||
}
|
||||
function getTagType(row: any) {
|
||||
switch (row.status) {
|
||||
case 1: {
|
||||
@@ -113,10 +195,7 @@ function getTagType(row: any) {
|
||||
<template>
|
||||
<div class="page-container border-border border">
|
||||
<div class="mb-3">
|
||||
<ElButton
|
||||
:icon="ArrowLeft"
|
||||
@click="router.replace({ path: '/ai/workflow' })"
|
||||
>
|
||||
<ElButton :icon="ArrowLeft" @click="backToWorkflowList">
|
||||
{{ $t('button.back') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
@@ -140,10 +219,15 @@ function getTagType(row: any) {
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/workflowExecResult/page"
|
||||
:page-size="10"
|
||||
:page-size="initialListState.pageSize"
|
||||
:initial-page-number="initialListState.pageNumber"
|
||||
:initial-query-params="{
|
||||
execKey: initialListState.execKey || undefined,
|
||||
}"
|
||||
:extra-query-params="{
|
||||
workflowId: $route.query.workflowId,
|
||||
}"
|
||||
@state-change="handlePageStateChange"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable :data="pageList" border>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import { $t } from '#/locales';
|
||||
import { navigateBackToList } from '#/router/list-return-context';
|
||||
import { useDictStore } from '#/store';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -44,12 +45,18 @@ function reset(formEl: FormInstance | undefined) {
|
||||
formEl?.resetFields();
|
||||
pageDataRef.value.setQuery({});
|
||||
}
|
||||
function backToExecRecords() {
|
||||
async function backToExecRecords() {
|
||||
const workflowId = $route.query.workflowId;
|
||||
void router.replace({
|
||||
const fallbackPath = router.resolve({
|
||||
name: 'ExecRecord',
|
||||
query: workflowId ? { workflowId } : {},
|
||||
});
|
||||
}).fullPath;
|
||||
await navigateBackToList(
|
||||
router,
|
||||
$route.query,
|
||||
['/ai/workflow/executeRecords'],
|
||||
fallbackPath,
|
||||
);
|
||||
}
|
||||
function getTagType(row: any) {
|
||||
switch (row.status) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
defineListRouteStateSchema,
|
||||
positiveIntegerListRouteField,
|
||||
stringListRouteField,
|
||||
} from '#/composables/useListRouteState';
|
||||
|
||||
interface WorkflowExecListRouteState {
|
||||
execKey: string;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const workflowExecListRouteSchema =
|
||||
defineListRouteStateSchema<WorkflowExecListRouteState>({
|
||||
path: '/ai/workflow/executeRecords',
|
||||
fields: {
|
||||
execKey: stringListRouteField(),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [10, 20, 50, 100],
|
||||
defaultValue: 10,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export { workflowExecListRouteSchema };
|
||||
export type { WorkflowExecListRouteState };
|
||||
@@ -1,10 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildWorkflowDesignReturnQuery,
|
||||
buildWorkflowListRouteQuery,
|
||||
mergeWorkflowListRouteQuery,
|
||||
parseWorkflowDesignReturnState,
|
||||
parseWorkflowListRouteState,
|
||||
} from './workflow-list-route-state';
|
||||
|
||||
@@ -39,7 +37,7 @@ describe('workflow list route state', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('round trips list state through workflow design return query', () => {
|
||||
it('serializes workflow list state into canonical list query', () => {
|
||||
const state = {
|
||||
categoryId: '7',
|
||||
keyword: '月报',
|
||||
@@ -47,15 +45,6 @@ describe('workflow list route state', () => {
|
||||
pageSize: 18,
|
||||
};
|
||||
|
||||
const designQuery = buildWorkflowDesignReturnQuery(state);
|
||||
|
||||
expect(designQuery).toEqual({
|
||||
returnCategoryId: '7',
|
||||
returnKeyword: '月报',
|
||||
returnPageNumber: '3',
|
||||
returnPageSize: '18',
|
||||
});
|
||||
expect(parseWorkflowDesignReturnState(designQuery)).toEqual(state);
|
||||
expect(buildWorkflowListRouteQuery(state)).toEqual({
|
||||
categoryId: '7',
|
||||
keyword: '月报',
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
import type { LocationQuery } from 'vue-router';
|
||||
import type { LocationQuery, LocationQueryRaw } from 'vue-router';
|
||||
|
||||
const DEFAULT_PAGE_NUMBER = 1;
|
||||
const DEFAULT_PAGE_SIZE = 12;
|
||||
const WORKFLOW_PAGE_SIZES = new Set([12, 18, 24]);
|
||||
|
||||
const LIST_QUERY_KEYS = {
|
||||
categoryId: 'categoryId',
|
||||
keyword: 'keyword',
|
||||
pageNumber: 'pageNumber',
|
||||
pageSize: 'pageSize',
|
||||
} as const;
|
||||
|
||||
const RETURN_QUERY_KEYS = {
|
||||
categoryId: 'returnCategoryId',
|
||||
keyword: 'returnKeyword',
|
||||
pageNumber: 'returnPageNumber',
|
||||
pageSize: 'returnPageSize',
|
||||
} as const;
|
||||
|
||||
interface WorkflowListQueryKeys {
|
||||
readonly categoryId: string;
|
||||
readonly keyword: string;
|
||||
readonly pageNumber: string;
|
||||
readonly pageSize: string;
|
||||
}
|
||||
import {
|
||||
buildListRouteStateQuery,
|
||||
defineListRouteStateSchema,
|
||||
mergeListRouteStateQuery,
|
||||
parseListRouteState,
|
||||
positiveIntegerListRouteField,
|
||||
stringListRouteField,
|
||||
} from '#/composables/useListRouteState';
|
||||
|
||||
interface WorkflowListRouteState {
|
||||
categoryId: string;
|
||||
@@ -32,100 +16,43 @@ interface WorkflowListRouteState {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
function readQueryValue(query: LocationQuery, key: string): string {
|
||||
const value = query[key];
|
||||
const normalized = Array.isArray(value) ? value[0] : value;
|
||||
return normalized === null || normalized === undefined
|
||||
? ''
|
||||
: String(normalized);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string, fallback: number): number {
|
||||
if (!/^\d+$/.test(value)) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseState(
|
||||
query: LocationQuery,
|
||||
keys: WorkflowListQueryKeys,
|
||||
): WorkflowListRouteState {
|
||||
const pageSize = parsePositiveInteger(
|
||||
readQueryValue(query, keys.pageSize),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
);
|
||||
return {
|
||||
categoryId: readQueryValue(query, keys.categoryId),
|
||||
keyword: readQueryValue(query, keys.keyword),
|
||||
pageNumber: parsePositiveInteger(
|
||||
readQueryValue(query, keys.pageNumber),
|
||||
DEFAULT_PAGE_NUMBER,
|
||||
),
|
||||
pageSize: WORKFLOW_PAGE_SIZES.has(pageSize) ? pageSize : DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStateQuery(
|
||||
state: WorkflowListRouteState,
|
||||
keys: WorkflowListQueryKeys,
|
||||
): LocationQuery {
|
||||
return {
|
||||
...(state.pageNumber === DEFAULT_PAGE_NUMBER
|
||||
? {}
|
||||
: { [keys.pageNumber]: String(state.pageNumber) }),
|
||||
...(state.pageSize === DEFAULT_PAGE_SIZE
|
||||
? {}
|
||||
: { [keys.pageSize]: String(state.pageSize) }),
|
||||
...(state.categoryId ? { [keys.categoryId]: state.categoryId } : {}),
|
||||
...(state.keyword ? { [keys.keyword]: state.keyword } : {}),
|
||||
};
|
||||
}
|
||||
const workflowListRouteSchema =
|
||||
defineListRouteStateSchema<WorkflowListRouteState>({
|
||||
path: '/ai/workflow',
|
||||
fields: {
|
||||
categoryId: stringListRouteField(),
|
||||
keyword: stringListRouteField(),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [12, 18, 24],
|
||||
defaultValue: 12,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
function parseWorkflowListRouteState(
|
||||
query: LocationQuery,
|
||||
): WorkflowListRouteState {
|
||||
return parseState(query, LIST_QUERY_KEYS);
|
||||
}
|
||||
|
||||
function parseWorkflowDesignReturnState(
|
||||
query: LocationQuery,
|
||||
): WorkflowListRouteState {
|
||||
return parseState(query, RETURN_QUERY_KEYS);
|
||||
return parseListRouteState(query, workflowListRouteSchema);
|
||||
}
|
||||
|
||||
function buildWorkflowListRouteQuery(
|
||||
state: WorkflowListRouteState,
|
||||
): LocationQuery {
|
||||
return buildStateQuery(state, LIST_QUERY_KEYS);
|
||||
}
|
||||
|
||||
function buildWorkflowDesignReturnQuery(
|
||||
state: WorkflowListRouteState,
|
||||
): LocationQuery {
|
||||
return buildStateQuery(state, RETURN_QUERY_KEYS);
|
||||
): LocationQueryRaw {
|
||||
return buildListRouteStateQuery(state, workflowListRouteSchema);
|
||||
}
|
||||
|
||||
function mergeWorkflowListRouteQuery(
|
||||
query: LocationQuery,
|
||||
state: WorkflowListRouteState,
|
||||
): LocationQuery {
|
||||
const listQueryKeys = new Set<string>(Object.values(LIST_QUERY_KEYS));
|
||||
const nextQuery = Object.fromEntries(
|
||||
Object.entries(query).filter(([key]) => !listQueryKeys.has(key)),
|
||||
) as LocationQuery;
|
||||
return {
|
||||
...nextQuery,
|
||||
...buildWorkflowListRouteQuery(state),
|
||||
};
|
||||
): LocationQueryRaw {
|
||||
return mergeListRouteStateQuery(query, state, workflowListRouteSchema);
|
||||
}
|
||||
|
||||
export {
|
||||
buildWorkflowDesignReturnQuery,
|
||||
buildWorkflowListRouteQuery,
|
||||
mergeWorkflowListRouteQuery,
|
||||
parseWorkflowDesignReturnState,
|
||||
parseWorkflowListRouteState,
|
||||
workflowListRouteSchema,
|
||||
};
|
||||
export type { WorkflowListRouteState };
|
||||
|
||||
@@ -21,6 +21,7 @@ import { hasPermission } from '#/api/common/hasPermission';
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { navigateBackToList } from '#/router/list-return-context';
|
||||
import AgentApprovalSnapshotPreview from '#/views/system/approval/components/AgentApprovalSnapshotPreview.vue';
|
||||
import BotApprovalSnapshotPreview from '#/views/system/approval/components/BotApprovalSnapshotPreview.vue';
|
||||
import KnowledgeApprovalSnapshotPreview from '#/views/system/approval/components/KnowledgeApprovalSnapshotPreview.vue';
|
||||
@@ -121,21 +122,13 @@ async function loadDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function backToApprovalList() {
|
||||
const tab = String(route.query.tab || '');
|
||||
if (
|
||||
tab === 'flow' ||
|
||||
tab === 'pending' ||
|
||||
tab === 'processed' ||
|
||||
tab === 'initiated'
|
||||
) {
|
||||
void router.replace({
|
||||
path: '/sys/approval',
|
||||
query: { tab },
|
||||
});
|
||||
return;
|
||||
}
|
||||
void router.replace({ path: '/sys/approval' });
|
||||
async function backToApprovalList() {
|
||||
await navigateBackToList(
|
||||
router,
|
||||
route.query,
|
||||
['/sys/approval'],
|
||||
'/sys/approval',
|
||||
);
|
||||
}
|
||||
|
||||
async function submitApprovalAction(action: 'approve' | 'reject' | 'revoke') {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import type {
|
||||
ApprovalListRouteState,
|
||||
ApprovalStatus,
|
||||
ApprovalTabName,
|
||||
} from './approval-list-route-state';
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { useAccessStore } from '@easyflow/stores';
|
||||
@@ -26,14 +32,20 @@ import { hasPermission } from '#/api/common/hasPermission';
|
||||
import { api } from '#/api/request';
|
||||
import ListPageShell from '#/components/page/ListPageShell.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import {
|
||||
buildListRouteFullPath,
|
||||
mergeListRouteStateQuery,
|
||||
parseListRouteState,
|
||||
watchListRouteState,
|
||||
} from '#/composables/useListRouteState';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { withListReturnTo } from '#/router/list-return-context';
|
||||
|
||||
import { formatApprovalAccount } from './approval-format';
|
||||
import { approvalListRouteSchema } from './approval-list-route-state';
|
||||
import ApprovalFlowModal from './ApprovalFlowModal.vue';
|
||||
|
||||
type ApprovalTabName = 'flow' | 'initiated' | 'pending' | 'processed';
|
||||
|
||||
const RESOURCE_OPTIONS = [
|
||||
{ label: $t('approval.resource.agent'), value: 'AGENT' },
|
||||
{ label: $t('approval.resource.bot'), value: 'BOT' },
|
||||
@@ -94,9 +106,17 @@ const TAB_CONFIG = [
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const route = useRoute();
|
||||
const initialListState = parseListRouteState(
|
||||
route.query,
|
||||
approvalListRouteSchema,
|
||||
);
|
||||
const initialApprovalStatus = normalizeApprovalStatus(
|
||||
initialListState.tab,
|
||||
initialListState.status,
|
||||
);
|
||||
const flowQueryRef = ref<FormInstance>();
|
||||
const instanceQueryRef = ref<FormInstance>();
|
||||
const activeTab = ref<ApprovalTabName>('flow');
|
||||
const activeTab = ref<ApprovalTabName>(initialListState.tab);
|
||||
const flowModalRef = ref();
|
||||
const flowPageRef = ref();
|
||||
const pendingPageRef = ref();
|
||||
@@ -104,20 +124,28 @@ const processedPageRef = ref();
|
||||
const initiatedPageRef = ref();
|
||||
const pendingBadgeCount = ref(0);
|
||||
const approvalActionLoadingKey = ref('');
|
||||
const currentPageNumber = ref(initialListState.pageNumber);
|
||||
const currentPageSize = ref(initialListState.pageSize);
|
||||
|
||||
const flowQuery = ref({
|
||||
actionType: '',
|
||||
name: '',
|
||||
resourceType: '',
|
||||
status: '',
|
||||
actionType:
|
||||
initialListState.tab === 'flow' ? initialListState.actionType : '',
|
||||
name: initialListState.tab === 'flow' ? initialListState.name : '',
|
||||
resourceType:
|
||||
initialListState.tab === 'flow' ? initialListState.resourceType : '',
|
||||
status: initialListState.tab === 'flow' ? initialApprovalStatus : '',
|
||||
});
|
||||
|
||||
const instanceQuery = ref({
|
||||
actionType: '',
|
||||
keyword: '',
|
||||
resourceType: '',
|
||||
status: '',
|
||||
actionType:
|
||||
initialListState.tab === 'flow' ? '' : initialListState.actionType,
|
||||
keyword: initialListState.tab === 'flow' ? '' : initialListState.keyword,
|
||||
resourceType:
|
||||
initialListState.tab === 'flow' ? '' : initialListState.resourceType,
|
||||
status: initialListState.tab === 'flow' ? '' : initialApprovalStatus,
|
||||
});
|
||||
const appliedFlowQuery = ref({ ...flowQuery.value });
|
||||
const appliedInstanceQuery = ref({ ...instanceQuery.value });
|
||||
const pendingBadgeText = computed(() =>
|
||||
pendingBadgeCount.value > 99 ? '99+' : String(pendingBadgeCount.value),
|
||||
);
|
||||
@@ -156,32 +184,44 @@ const instanceStatusOptions = computed(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void syncRouteTab();
|
||||
});
|
||||
|
||||
watch(activeTab, () => {
|
||||
if (
|
||||
instanceQuery.value.status &&
|
||||
!instanceStatusOptions.value.some(
|
||||
(item) => item.value === instanceQuery.value.status,
|
||||
)
|
||||
) {
|
||||
instanceQuery.value.status = '';
|
||||
}
|
||||
});
|
||||
watch(
|
||||
[() => route.fullPath, visibleTabNames],
|
||||
[() => route.path, () => route.query.tab, visibleTabNames],
|
||||
async () => {
|
||||
await syncRouteTab();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watchListRouteState(route, approvalListRouteSchema, (state) => {
|
||||
if (state.tab !== activeTab.value || !hasTab(state.tab)) return;
|
||||
void restoreApprovalRouteState(state);
|
||||
});
|
||||
|
||||
function hasTab(name: ApprovalTabName) {
|
||||
return visibleTabNames.value.includes(name);
|
||||
}
|
||||
|
||||
function normalizeApprovalStatus(
|
||||
tab: ApprovalTabName,
|
||||
status: ApprovalStatus,
|
||||
): ApprovalStatus {
|
||||
if (tab === 'flow') {
|
||||
return FLOW_STATUS_OPTIONS.some((item) => item.value === status)
|
||||
? status
|
||||
: '';
|
||||
}
|
||||
if (tab === 'processed') {
|
||||
return PROCESSED_STATUS_OPTIONS.some((item) => item.value === status)
|
||||
? status
|
||||
: '';
|
||||
}
|
||||
if (tab === 'initiated') {
|
||||
return INITIATED_STATUS_OPTIONS.some((item) => item.value === status)
|
||||
? status
|
||||
: '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveTabNameByPath(path: string): ApprovalTabName {
|
||||
if (path.endsWith('/pending')) {
|
||||
return 'pending';
|
||||
@@ -223,7 +263,11 @@ async function syncRouteTab() {
|
||||
if (fallbackTab.name !== 'flow') {
|
||||
await router.replace({
|
||||
path: '/sys/approval',
|
||||
query: { tab: fallbackTab.name },
|
||||
query: mergeListRouteStateQuery(
|
||||
route.query,
|
||||
defaultApprovalListState(fallbackTab.name),
|
||||
approvalListRouteSchema,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -233,8 +277,13 @@ async function syncRouteTab() {
|
||||
if (visibleTabNames.value.includes(expectedTab)) {
|
||||
activeTab.value = expectedTab;
|
||||
} else {
|
||||
const fallbackQuery =
|
||||
fallbackTab?.name === 'flow' ? {} : { tab: fallbackTab?.name };
|
||||
const fallbackQuery = fallbackTab
|
||||
? mergeListRouteStateQuery(
|
||||
route.query,
|
||||
defaultApprovalListState(fallbackTab.name),
|
||||
approvalListRouteSchema,
|
||||
)
|
||||
: {};
|
||||
const currentQueryTab = String(route.query.tab || '');
|
||||
if (
|
||||
fallbackTab &&
|
||||
@@ -261,7 +310,12 @@ function handleTabChange(name: number | string) {
|
||||
return;
|
||||
}
|
||||
const target = visibleTabs.value.find((item) => item.name === name);
|
||||
const nextQuery = name === 'flow' ? {} : { tab: name };
|
||||
const nextTab = name as ApprovalTabName;
|
||||
const nextQuery = mergeListRouteStateQuery(
|
||||
route.query,
|
||||
defaultApprovalListState(nextTab),
|
||||
approvalListRouteSchema,
|
||||
);
|
||||
const currentQueryTab = String(route.query.tab || '');
|
||||
const currentTab = currentQueryTab || 'flow';
|
||||
if (!target || currentTab === name) {
|
||||
@@ -273,6 +327,167 @@ function handleTabChange(name: number | string) {
|
||||
});
|
||||
}
|
||||
|
||||
function initialPageNumberFor(tab: ApprovalTabName) {
|
||||
const state = parseListRouteState(route.query, approvalListRouteSchema);
|
||||
return state.tab === tab ? state.pageNumber : 1;
|
||||
}
|
||||
|
||||
function initialPageSizeFor(tab: ApprovalTabName) {
|
||||
const state = parseListRouteState(route.query, approvalListRouteSchema);
|
||||
return state.tab === tab ? state.pageSize : 10;
|
||||
}
|
||||
|
||||
function initialQueryFor(tab: ApprovalTabName) {
|
||||
const state = parseListRouteState(route.query, approvalListRouteSchema);
|
||||
if (state.tab !== tab) return {};
|
||||
const status = normalizeApprovalStatus(tab, state.status);
|
||||
if (tab === 'flow') {
|
||||
return {
|
||||
actionType: state.actionType || undefined,
|
||||
name: state.name || undefined,
|
||||
resourceType: state.resourceType || undefined,
|
||||
status: status || undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
actionType: state.actionType || undefined,
|
||||
keyword: state.keyword || undefined,
|
||||
resourceType: state.resourceType || undefined,
|
||||
status: status || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultApprovalListState(tab: ApprovalTabName) {
|
||||
return {
|
||||
actionType: '' as const,
|
||||
keyword: '',
|
||||
name: '',
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
resourceType: '' as const,
|
||||
status: '' as const,
|
||||
tab,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalPageRef(tab: ApprovalTabName) {
|
||||
if (tab === 'flow') return flowPageRef.value;
|
||||
if (tab === 'pending') return pendingPageRef.value;
|
||||
if (tab === 'processed') return processedPageRef.value;
|
||||
return initiatedPageRef.value;
|
||||
}
|
||||
|
||||
function approvalQueryParams(
|
||||
tab: ApprovalTabName,
|
||||
query: Partial<
|
||||
Record<
|
||||
'actionType' | 'keyword' | 'name' | 'resourceType' | 'status',
|
||||
string
|
||||
>
|
||||
>,
|
||||
) {
|
||||
if (tab === 'flow') {
|
||||
return {
|
||||
actionType: query.actionType || undefined,
|
||||
name: query.name || undefined,
|
||||
resourceType: query.resourceType || undefined,
|
||||
status: query.status || undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
actionType: query.actionType || undefined,
|
||||
keyword: query.keyword || undefined,
|
||||
resourceType: query.resourceType || undefined,
|
||||
status: query.status || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function restoreApprovalRouteState(state: ApprovalListRouteState) {
|
||||
const status = normalizeApprovalStatus(state.tab, state.status);
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
if (state.tab === 'flow') {
|
||||
const nextQuery = {
|
||||
actionType: state.actionType,
|
||||
name: state.name,
|
||||
resourceType: state.resourceType,
|
||||
status,
|
||||
};
|
||||
flowQuery.value = { ...nextQuery };
|
||||
appliedFlowQuery.value = { ...nextQuery };
|
||||
} else {
|
||||
const nextQuery = {
|
||||
actionType: state.actionType,
|
||||
keyword: state.keyword,
|
||||
resourceType: state.resourceType,
|
||||
status,
|
||||
};
|
||||
instanceQuery.value = { ...nextQuery };
|
||||
appliedInstanceQuery.value = { ...nextQuery };
|
||||
}
|
||||
await nextTick();
|
||||
const appliedQuery =
|
||||
state.tab === 'flow' ? appliedFlowQuery.value : appliedInstanceQuery.value;
|
||||
approvalPageRef(state.tab)?.restoreState?.({
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
queryParams: approvalQueryParams(state.tab, appliedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
function currentListState(
|
||||
pageNumber = currentPageNumber.value,
|
||||
pageSize = currentPageSize.value,
|
||||
) {
|
||||
if (activeTab.value === 'flow') {
|
||||
return {
|
||||
actionType: appliedFlowQuery.value.actionType,
|
||||
keyword: '',
|
||||
name: appliedFlowQuery.value.name,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
resourceType: appliedFlowQuery.value.resourceType,
|
||||
status: appliedFlowQuery.value.status,
|
||||
tab: activeTab.value,
|
||||
};
|
||||
}
|
||||
return {
|
||||
actionType: appliedInstanceQuery.value.actionType,
|
||||
keyword: appliedInstanceQuery.value.keyword,
|
||||
name: '',
|
||||
pageNumber,
|
||||
pageSize,
|
||||
resourceType: appliedInstanceQuery.value.resourceType,
|
||||
status: appliedInstanceQuery.value.status,
|
||||
tab: activeTab.value,
|
||||
};
|
||||
}
|
||||
|
||||
function currentListFullPath() {
|
||||
return buildListRouteFullPath(
|
||||
router,
|
||||
route.query,
|
||||
currentListState(),
|
||||
approvalListRouteSchema,
|
||||
);
|
||||
}
|
||||
|
||||
function handlePageStateChange(
|
||||
tab: ApprovalTabName,
|
||||
state: { pageNumber: number; pageSize: number },
|
||||
) {
|
||||
if (activeTab.value !== tab) return;
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
const query = mergeListRouteStateQuery(
|
||||
route.query,
|
||||
currentListState(state.pageNumber, state.pageSize),
|
||||
approvalListRouteSchema,
|
||||
);
|
||||
const target = router.resolve({ path: approvalListRouteSchema.path, query });
|
||||
if (target.fullPath !== route.fullPath) void router.replace(target);
|
||||
}
|
||||
|
||||
function reloadCurrentTab() {
|
||||
if (activeTab.value === 'flow') {
|
||||
flowPageRef.value?.reload();
|
||||
@@ -306,40 +521,53 @@ async function refreshPendingBadgeCount() {
|
||||
function searchFlow(formEl?: FormInstance) {
|
||||
formEl?.validate((valid) => {
|
||||
if (valid) {
|
||||
flowPageRef.value?.setQuery(flowQuery.value);
|
||||
appliedFlowQuery.value = { ...flowQuery.value };
|
||||
flowPageRef.value?.setQuery(
|
||||
approvalQueryParams('flow', appliedFlowQuery.value),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetFlow(formEl?: FormInstance) {
|
||||
formEl?.resetFields();
|
||||
flowQuery.value = {
|
||||
actionType: '',
|
||||
name: '',
|
||||
resourceType: '',
|
||||
status: '',
|
||||
};
|
||||
appliedFlowQuery.value = { ...flowQuery.value };
|
||||
formEl?.clearValidate();
|
||||
flowPageRef.value?.setQuery({});
|
||||
}
|
||||
|
||||
function searchInstance(formEl?: FormInstance) {
|
||||
if (!formEl) {
|
||||
currentInstancePageRef()?.setQuery(instanceQuery.value);
|
||||
appliedInstanceQuery.value = { ...instanceQuery.value };
|
||||
currentInstancePageRef()?.setQuery(
|
||||
approvalQueryParams(activeTab.value, appliedInstanceQuery.value),
|
||||
);
|
||||
return;
|
||||
}
|
||||
formEl?.validate((valid) => {
|
||||
if (valid) {
|
||||
currentInstancePageRef()?.setQuery(instanceQuery.value);
|
||||
appliedInstanceQuery.value = { ...instanceQuery.value };
|
||||
currentInstancePageRef()?.setQuery(
|
||||
approvalQueryParams(activeTab.value, appliedInstanceQuery.value),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetInstance(formEl?: FormInstance) {
|
||||
if (!formEl) {
|
||||
instanceQuery.value = {
|
||||
actionType: '',
|
||||
keyword: '',
|
||||
resourceType: '',
|
||||
status: '',
|
||||
};
|
||||
currentInstancePageRef()?.setQuery({});
|
||||
return;
|
||||
}
|
||||
formEl?.resetFields();
|
||||
instanceQuery.value = {
|
||||
actionType: '',
|
||||
keyword: '',
|
||||
resourceType: '',
|
||||
status: '',
|
||||
};
|
||||
appliedInstanceQuery.value = { ...instanceQuery.value };
|
||||
formEl?.clearValidate();
|
||||
currentInstancePageRef()?.setQuery({});
|
||||
}
|
||||
|
||||
@@ -471,6 +699,7 @@ function openInstanceDetail(row: any) {
|
||||
path: `/sys/approval/detail/${row.id}`,
|
||||
query: {
|
||||
tab: activeTab.value,
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -521,6 +750,7 @@ function formatApplicationReason(value?: null | string) {
|
||||
<ElTabPane
|
||||
v-if="hasTab('flow')"
|
||||
:label="$t('approval.tab.flow')"
|
||||
lazy
|
||||
name="flow"
|
||||
>
|
||||
<ListPageShell>
|
||||
@@ -597,7 +827,10 @@ function formatApplicationReason(value?: null | string) {
|
||||
<PageData
|
||||
ref="flowPageRef"
|
||||
page-url="/api/v1/approvalFlow/page"
|
||||
:page-size="10"
|
||||
:page-size="initialPageSizeFor('flow')"
|
||||
:initial-page-number="initialPageNumberFor('flow')"
|
||||
:initial-query-params="initialQueryFor('flow')"
|
||||
@state-change="handlePageStateChange('flow', $event)"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable
|
||||
@@ -702,7 +935,7 @@ function formatApplicationReason(value?: null | string) {
|
||||
</ListPageShell>
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane v-if="hasTab('pending')" name="pending">
|
||||
<ElTabPane v-if="hasTab('pending')" lazy name="pending">
|
||||
<template #label>
|
||||
<span class="approval-manage__tab-label">
|
||||
<span>{{ $t('approval.tab.pending') }}</span>
|
||||
@@ -771,7 +1004,10 @@ function formatApplicationReason(value?: null | string) {
|
||||
<PageData
|
||||
ref="pendingPageRef"
|
||||
page-url="/api/v1/approvalInstance/pendingPage"
|
||||
:page-size="10"
|
||||
:page-size="initialPageSizeFor('pending')"
|
||||
:initial-page-number="initialPageNumberFor('pending')"
|
||||
:initial-query-params="initialQueryFor('pending')"
|
||||
@state-change="handlePageStateChange('pending', $event)"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable
|
||||
@@ -888,6 +1124,7 @@ function formatApplicationReason(value?: null | string) {
|
||||
<ElTabPane
|
||||
v-if="hasTab('processed')"
|
||||
:label="$t('approval.tab.processed')"
|
||||
lazy
|
||||
name="processed"
|
||||
>
|
||||
<ListPageShell>
|
||||
@@ -969,7 +1206,10 @@ function formatApplicationReason(value?: null | string) {
|
||||
<PageData
|
||||
ref="processedPageRef"
|
||||
page-url="/api/v1/approvalInstance/processedPage"
|
||||
:page-size="10"
|
||||
:page-size="initialPageSizeFor('processed')"
|
||||
:initial-page-number="initialPageNumberFor('processed')"
|
||||
:initial-query-params="initialQueryFor('processed')"
|
||||
@state-change="handlePageStateChange('processed', $event)"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable
|
||||
@@ -1059,6 +1299,7 @@ function formatApplicationReason(value?: null | string) {
|
||||
<ElTabPane
|
||||
v-if="hasTab('initiated')"
|
||||
:label="$t('approval.tab.initiated')"
|
||||
lazy
|
||||
name="initiated"
|
||||
>
|
||||
<ListPageShell>
|
||||
@@ -1140,7 +1381,10 @@ function formatApplicationReason(value?: null | string) {
|
||||
<PageData
|
||||
ref="initiatedPageRef"
|
||||
page-url="/api/v1/approvalInstance/initiatedPage"
|
||||
:page-size="10"
|
||||
:page-size="initialPageSizeFor('initiated')"
|
||||
:initial-page-number="initialPageNumberFor('initiated')"
|
||||
:initial-query-params="initialQueryFor('initiated')"
|
||||
@state-change="handlePageStateChange('initiated', $event)"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
defineListRouteStateSchema,
|
||||
enumListRouteField,
|
||||
positiveIntegerListRouteField,
|
||||
stringListRouteField,
|
||||
} from '#/composables/useListRouteState';
|
||||
|
||||
type ApprovalTabName = 'flow' | 'initiated' | 'pending' | 'processed';
|
||||
type ApprovalActionType = '' | 'DELETE' | 'OFFLINE' | 'PUBLISH';
|
||||
type ApprovalResourceType = '' | 'AGENT' | 'BOT' | 'KNOWLEDGE' | 'WORKFLOW';
|
||||
type ApprovalStatus =
|
||||
| ''
|
||||
| 'APPROVED'
|
||||
| 'DISABLED'
|
||||
| 'ENABLED'
|
||||
| 'PENDING'
|
||||
| 'PROCESSING'
|
||||
| 'REJECTED'
|
||||
| 'REVOKED';
|
||||
|
||||
interface ApprovalListRouteState {
|
||||
actionType: ApprovalActionType;
|
||||
keyword: string;
|
||||
name: string;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
resourceType: ApprovalResourceType;
|
||||
status: ApprovalStatus;
|
||||
tab: ApprovalTabName;
|
||||
}
|
||||
|
||||
const approvalListRouteSchema =
|
||||
defineListRouteStateSchema<ApprovalListRouteState>({
|
||||
path: '/sys/approval',
|
||||
fields: {
|
||||
actionType: enumListRouteField(['DELETE', 'OFFLINE', 'PUBLISH'], ''),
|
||||
keyword: stringListRouteField(),
|
||||
name: stringListRouteField(),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [10, 20, 50, 100],
|
||||
defaultValue: 10,
|
||||
}),
|
||||
resourceType: enumListRouteField(
|
||||
['AGENT', 'BOT', 'KNOWLEDGE', 'WORKFLOW'],
|
||||
'',
|
||||
),
|
||||
status: enumListRouteField(
|
||||
[
|
||||
'APPROVED',
|
||||
'DISABLED',
|
||||
'ENABLED',
|
||||
'PENDING',
|
||||
'PROCESSING',
|
||||
'REJECTED',
|
||||
'REVOKED',
|
||||
],
|
||||
'',
|
||||
),
|
||||
tab: enumListRouteField(
|
||||
['flow', 'pending', 'processed', 'initiated'],
|
||||
'flow',
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
export { approvalListRouteSchema };
|
||||
export type { ApprovalListRouteState, ApprovalStatus, ApprovalTabName };
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
defineListRouteStateSchema,
|
||||
enumListRouteField,
|
||||
positiveIntegerListRouteField,
|
||||
stringListRouteField,
|
||||
} from '#/composables/useListRouteState';
|
||||
|
||||
interface SysFeedbackListRouteState {
|
||||
feedbackContent: string;
|
||||
feedbackType: '1' | '2' | '3' | '4' | '';
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
status: '0' | '1' | '2' | '3' | '';
|
||||
}
|
||||
|
||||
const sysFeedbackListRouteSchema =
|
||||
defineListRouteStateSchema<SysFeedbackListRouteState>({
|
||||
path: '/sys/sysFeedback',
|
||||
fields: {
|
||||
feedbackContent: stringListRouteField(),
|
||||
feedbackType: enumListRouteField(['1', '2', '3', '4'], ''),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [10, 20, 50, 100],
|
||||
defaultValue: 10,
|
||||
}),
|
||||
status: enumListRouteField(['0', '1', '2', '3'], ''),
|
||||
},
|
||||
});
|
||||
|
||||
export { sysFeedbackListRouteSchema };
|
||||
export type { SysFeedbackListRouteState };
|
||||
@@ -17,6 +17,7 @@ import { tryit } from 'radash';
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { navigateBackToList } from '#/router/list-return-context';
|
||||
|
||||
const feedbackTypeOptions = [
|
||||
{
|
||||
@@ -95,6 +96,14 @@ async function markStatus(_status: number) {
|
||||
}
|
||||
loading[key] = false;
|
||||
}
|
||||
async function backToFeedbackList() {
|
||||
await navigateBackToList(
|
||||
router,
|
||||
route.query,
|
||||
['/sys/sysFeedback'],
|
||||
'/sys/sysFeedback',
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -102,7 +111,7 @@ async function markStatus(_status: number) {
|
||||
<ElButton
|
||||
class="absolute left-5 top-5"
|
||||
:icon="ArrowLeft"
|
||||
@click="router.replace({ path: '/sys/sysFeedback' })"
|
||||
@click="backToFeedbackList"
|
||||
>
|
||||
{{ $t('button.back') }}
|
||||
</ElButton>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { IconifyIcon } from '@easyflow/icons';
|
||||
|
||||
@@ -26,8 +27,23 @@ import { api } from '#/api/request';
|
||||
import DictSelect from '#/components/dict/DictSelect.vue';
|
||||
import ListPageShell from '#/components/page/ListPageShell.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import {
|
||||
buildListRouteFullPath,
|
||||
mergeListRouteStateQuery,
|
||||
parseListRouteState,
|
||||
watchListRouteState,
|
||||
} from '#/composables/useListRouteState';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { withListReturnTo } from '#/router/list-return-context';
|
||||
|
||||
import { sysFeedbackListRouteSchema } from './sys-feedback-list-route-state';
|
||||
|
||||
const route = useRoute();
|
||||
const initialListState = parseListRouteState(
|
||||
route.query,
|
||||
sysFeedbackListRouteSchema,
|
||||
);
|
||||
|
||||
const feedbackTypeOptions = [
|
||||
{
|
||||
@@ -68,21 +84,49 @@ const statusType: Record<number, any> = {
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const formData = ref({
|
||||
feedbackType: '',
|
||||
status: '',
|
||||
feedbackContent: '',
|
||||
feedbackType: initialListState.feedbackType
|
||||
? Number(initialListState.feedbackType)
|
||||
: '',
|
||||
status: initialListState.status,
|
||||
feedbackContent: initialListState.feedbackContent,
|
||||
});
|
||||
const appliedFormData = ref({ ...formData.value });
|
||||
const pageDataRef = ref();
|
||||
const currentPageNumber = ref(initialListState.pageNumber);
|
||||
const currentPageSize = ref(initialListState.pageSize);
|
||||
watchListRouteState(route, sysFeedbackListRouteSchema, (state) => {
|
||||
const nextFormData = {
|
||||
feedbackContent: state.feedbackContent,
|
||||
feedbackType: state.feedbackType ? Number(state.feedbackType) : '',
|
||||
status: state.status,
|
||||
};
|
||||
formData.value = { ...nextFormData };
|
||||
appliedFormData.value = { ...nextFormData };
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
pageDataRef.value?.restoreState?.({
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
queryParams: {
|
||||
feedbackContent: state.feedbackContent || undefined,
|
||||
feedbackType: state.feedbackType || undefined,
|
||||
status: state.status || undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function search(formEl?: FormInstance) {
|
||||
formEl?.validate((valid) => {
|
||||
if (valid) {
|
||||
pageDataRef.value.setQuery(formData.value);
|
||||
appliedFormData.value = { ...formData.value };
|
||||
pageDataRef.value.setQuery(appliedFormData.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
function reset(formEl?: FormInstance) {
|
||||
formEl?.resetFields();
|
||||
formData.value = { feedbackContent: '', feedbackType: '', status: '' };
|
||||
appliedFormData.value = { ...formData.value };
|
||||
formEl?.clearValidate();
|
||||
pageDataRef.value.setQuery({});
|
||||
}
|
||||
function getFeedbackType(type: number) {
|
||||
@@ -96,9 +140,62 @@ async function markStatus(row: any, status: number) {
|
||||
});
|
||||
|
||||
if (res && res.errorCode === 0) {
|
||||
pageDataRef.value.setQuery({});
|
||||
pageDataRef.value.reload();
|
||||
}
|
||||
}
|
||||
function currentListState(
|
||||
pageNumber = currentPageNumber.value,
|
||||
pageSize = currentPageSize.value,
|
||||
) {
|
||||
return {
|
||||
feedbackContent: appliedFormData.value.feedbackContent,
|
||||
feedbackType: String(appliedFormData.value.feedbackType || '') as
|
||||
| '1'
|
||||
| '2'
|
||||
| '3'
|
||||
| '4'
|
||||
| '',
|
||||
pageNumber,
|
||||
pageSize,
|
||||
status: String(appliedFormData.value.status || '') as
|
||||
| '0'
|
||||
| '1'
|
||||
| '2'
|
||||
| '3'
|
||||
| '',
|
||||
};
|
||||
}
|
||||
function currentListFullPath() {
|
||||
return buildListRouteFullPath(
|
||||
router,
|
||||
route.query,
|
||||
currentListState(),
|
||||
sysFeedbackListRouteSchema,
|
||||
);
|
||||
}
|
||||
function handlePageStateChange(state: {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
const query = mergeListRouteStateQuery(
|
||||
route.query,
|
||||
currentListState(state.pageNumber, state.pageSize),
|
||||
sysFeedbackListRouteSchema,
|
||||
);
|
||||
const target = router.resolve({
|
||||
path: sysFeedbackListRouteSchema.path,
|
||||
query,
|
||||
});
|
||||
if (target.fullPath !== route.fullPath) void router.replace(target);
|
||||
}
|
||||
function openFeedbackDetail(row: any) {
|
||||
router.push({
|
||||
path: `/sys/sysFeedback/${row.id}`,
|
||||
query: withListReturnTo(currentListFullPath()),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -142,7 +239,14 @@ async function markStatus(row: any, status: number) {
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/sysUserFeedback/page"
|
||||
:page-size="10"
|
||||
:page-size="initialListState.pageSize"
|
||||
:initial-page-number="initialListState.pageNumber"
|
||||
:initial-query-params="{
|
||||
feedbackContent: initialListState.feedbackContent || undefined,
|
||||
feedbackType: initialListState.feedbackType || undefined,
|
||||
status: initialListState.status || undefined,
|
||||
}"
|
||||
@state-change="handlePageStateChange"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable border show-overflow-tooltip :data="pageList">
|
||||
@@ -203,7 +307,7 @@ async function markStatus(row: any, status: number) {
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
@click="router.push(`/sys/sysFeedback/${row.id}`)"
|
||||
@click="openFeedbackDetail(row)"
|
||||
>
|
||||
{{ $t('button.view') }}
|
||||
</ElButton>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { markRaw, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import {
|
||||
CaretRight,
|
||||
@@ -26,17 +25,47 @@ import { api } from '#/api/request';
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import ListPageShell from '#/components/page/ListPageShell.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import {
|
||||
buildListRouteFullPath,
|
||||
mergeListRouteStateQuery,
|
||||
parseListRouteState,
|
||||
watchListRouteState,
|
||||
} from '#/composables/useListRouteState';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { withListReturnTo } from '#/router/list-return-context';
|
||||
import { useDictStore } from '#/store';
|
||||
|
||||
import { sysJobListRouteSchema } from './sys-job-list-route-state';
|
||||
import SysJobModal from './SysJobModal.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const initialListState = parseListRouteState(
|
||||
route.query,
|
||||
sysJobListRouteSchema,
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
initDict();
|
||||
});
|
||||
|
||||
const pageDataRef = ref();
|
||||
const searchKeyword = ref(initialListState.keyword);
|
||||
const currentPageNumber = ref(initialListState.pageNumber);
|
||||
const currentPageSize = ref(initialListState.pageSize);
|
||||
watchListRouteState(route, sysJobListRouteSchema, (state) => {
|
||||
searchKeyword.value = state.keyword;
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
pageDataRef.value?.restoreState?.({
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
queryParams: {
|
||||
isQueryOr: true,
|
||||
jobName: state.keyword || undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
const saveDialog = ref();
|
||||
const dictStore = useDictStore();
|
||||
const headerButtons = [
|
||||
@@ -57,11 +86,14 @@ function initDict() {
|
||||
dictStore.fetchDictionary('misfirePolicy');
|
||||
}
|
||||
const handleSearch = (params: string) => {
|
||||
pageDataRef.value.setQuery({ jobName: params, isQueryOr: true });
|
||||
searchKeyword.value = params;
|
||||
pageDataRef.value.setQuery({
|
||||
jobName: params || undefined,
|
||||
isQueryOr: true,
|
||||
});
|
||||
};
|
||||
function reset(formEl?: FormInstance) {
|
||||
formEl?.resetFields();
|
||||
pageDataRef.value.setQuery({});
|
||||
function reloadCurrentList() {
|
||||
pageDataRef.value?.reload?.();
|
||||
}
|
||||
function showDialog(row: any) {
|
||||
saveDialog.value.openDialog({ ...row });
|
||||
@@ -80,7 +112,7 @@ function remove(row: any) {
|
||||
instance.confirmButtonLoading = false;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
reset();
|
||||
reloadCurrentList();
|
||||
done();
|
||||
}
|
||||
})
|
||||
@@ -105,7 +137,7 @@ function start(row: any) {
|
||||
instance.confirmButtonLoading = false;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
reset();
|
||||
reloadCurrentList();
|
||||
done();
|
||||
}
|
||||
});
|
||||
@@ -127,7 +159,7 @@ function stop(row: any) {
|
||||
instance.confirmButtonLoading = false;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
reset();
|
||||
reloadCurrentList();
|
||||
done();
|
||||
}
|
||||
});
|
||||
@@ -142,18 +174,52 @@ function toLogPage(row: any) {
|
||||
name: 'SysJobLog',
|
||||
query: {
|
||||
jobId: row.id,
|
||||
...withListReturnTo(currentListFullPath()),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function currentListFullPath() {
|
||||
return buildListRouteFullPath(
|
||||
router,
|
||||
route.query,
|
||||
{
|
||||
keyword: searchKeyword.value,
|
||||
pageNumber: currentPageNumber.value,
|
||||
pageSize: currentPageSize.value,
|
||||
},
|
||||
sysJobListRouteSchema,
|
||||
);
|
||||
}
|
||||
|
||||
function handlePageStateChange(state: {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
currentPageNumber.value = state.pageNumber;
|
||||
currentPageSize.value = state.pageSize;
|
||||
const query = mergeListRouteStateQuery(
|
||||
route.query,
|
||||
{
|
||||
keyword: searchKeyword.value,
|
||||
pageNumber: state.pageNumber,
|
||||
pageSize: state.pageSize,
|
||||
},
|
||||
sysJobListRouteSchema,
|
||||
);
|
||||
const target = router.resolve({ path: sysJobListRouteSchema.path, query });
|
||||
if (target.fullPath !== route.fullPath) void router.replace(target);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-6">
|
||||
<SysJobModal ref="saveDialog" @reload="reset" />
|
||||
<SysJobModal ref="saveDialog" @reload="reloadCurrentList" />
|
||||
<ListPageShell>
|
||||
<template #filters>
|
||||
<HeaderSearch
|
||||
:buttons="headerButtons"
|
||||
:initial-value="searchKeyword"
|
||||
@search="handleSearch"
|
||||
@button-click="showDialog({})"
|
||||
/>
|
||||
@@ -161,7 +227,13 @@ function toLogPage(row: any) {
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/sysJob/page"
|
||||
:page-size="10"
|
||||
:page-size="initialListState.pageSize"
|
||||
:initial-page-number="initialListState.pageNumber"
|
||||
:initial-query-params="{
|
||||
jobName: initialListState.keyword || undefined,
|
||||
isQueryOr: true,
|
||||
}"
|
||||
@state-change="handlePageStateChange"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable :data="pageList" border>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import DictSelect from '#/components/dict/DictSelect.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import { $t } from '#/locales';
|
||||
import { navigateBackToList } from '#/router/list-return-context';
|
||||
import { useDictStore } from '#/store';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -43,15 +44,20 @@ function reset(formEl: FormInstance | undefined) {
|
||||
formEl?.resetFields();
|
||||
pageDataRef.value.setQuery({});
|
||||
}
|
||||
async function backToJobList() {
|
||||
await navigateBackToList(
|
||||
router,
|
||||
$route.query,
|
||||
['/sys/sysJob'],
|
||||
'/sys/sysJob',
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="mb-3">
|
||||
<ElButton
|
||||
:icon="ArrowLeft"
|
||||
@click="router.replace({ path: '/sys/sysJob' })"
|
||||
>
|
||||
<ElButton :icon="ArrowLeft" @click="backToJobList">
|
||||
{{ $t('button.back') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
defineListRouteStateSchema,
|
||||
positiveIntegerListRouteField,
|
||||
stringListRouteField,
|
||||
} from '#/composables/useListRouteState';
|
||||
|
||||
interface SysJobListRouteState {
|
||||
keyword: string;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const sysJobListRouteSchema = defineListRouteStateSchema<SysJobListRouteState>({
|
||||
path: '/sys/sysJob',
|
||||
fields: {
|
||||
keyword: stringListRouteField(),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [10, 20, 50, 100],
|
||||
defaultValue: 10,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export { sysJobListRouteSchema };
|
||||
export type { SysJobListRouteState };
|
||||
Reference in New Issue
Block a user