发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
42 changed files with 2359 additions and 570 deletions
Showing only changes of commit 6bfd440214 - Show all commits

View File

@@ -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: '月报' },
});
});
});

View File

@@ -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,
});

View 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();
});
});

View 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 };

View File

@@ -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']");
});
});

View 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');
});
});

View 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,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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,
},
}),
});
};

View File

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

View File

@@ -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',
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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: '月报',

View File

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

View File

@@ -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') {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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