mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(web-shell): address scheduled task reference review
This commit is contained in:
parent
ee9fbf22fb
commit
7c3efc899f
3 changed files with 280 additions and 77 deletions
|
|
@ -15,6 +15,10 @@ if (!Element.prototype.scrollIntoView) {
|
|||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
if (!document.execCommand) {
|
||||
document.execCommand = () => true;
|
||||
}
|
||||
|
||||
interface MockTask {
|
||||
id: string;
|
||||
name: string | null;
|
||||
|
|
@ -144,6 +148,14 @@ function findFrequencySelect(): HTMLSelectElement | undefined {
|
|||
);
|
||||
}
|
||||
|
||||
function deferred<T = unknown>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
|
|
@ -244,6 +256,24 @@ describe('ScheduledTasksDialog editing', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not render filesystem paths as skill tags', async () => {
|
||||
await mount([
|
||||
baseTask({ prompt: 'check /var/log and then /agent-reproduce-align' }),
|
||||
]);
|
||||
|
||||
click(document.querySelector('[aria-label="Edit"]'));
|
||||
|
||||
const tags = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-prompt-tag-serialized]'),
|
||||
);
|
||||
expect(tags.map((tag) => tag.dataset.promptTagSerialized)).toEqual([
|
||||
'/agent-reproduce-align',
|
||||
]);
|
||||
expect(document.querySelector('[role="textbox"]')?.textContent).toContain(
|
||||
'/var/log',
|
||||
);
|
||||
});
|
||||
|
||||
it('inserts extension, skill, and MCP references into the created prompt', async () => {
|
||||
actions.createScheduledTask.mockResolvedValue(baseTask({}));
|
||||
|
||||
|
|
@ -316,6 +346,125 @@ describe('ScheduledTasksDialog editing', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('escapes skill names when inserting references', async () => {
|
||||
actions.createScheduledTask.mockResolvedValue(baseTask({}));
|
||||
await mount([]);
|
||||
actions.loadSkillsStatus.mockResolvedValue({
|
||||
skills: [
|
||||
{
|
||||
kind: 'skill',
|
||||
name: 'review task',
|
||||
description: 'Review code',
|
||||
level: 'project',
|
||||
modelInvocable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
click(findButton('New scheduled task'));
|
||||
click(findButtonContaining('Skills'));
|
||||
await flush();
|
||||
click(findButtonContaining('review task'));
|
||||
await flush();
|
||||
|
||||
click(findButton('Create'));
|
||||
await flush();
|
||||
|
||||
expect(actions.createScheduledTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '/review\\ task',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the latest reference picker results when loads resolve out of order', async () => {
|
||||
await mount([]);
|
||||
const extensions = deferred();
|
||||
const skills = deferred();
|
||||
actions.loadExtensionsStatus.mockReturnValueOnce(extensions.promise);
|
||||
actions.loadSkillsStatus.mockReturnValueOnce(skills.promise);
|
||||
|
||||
click(findButton('New scheduled task'));
|
||||
click(findButtonContaining('Extensions'));
|
||||
click(findButtonContaining('Skills'));
|
||||
|
||||
skills.resolve({
|
||||
skills: [
|
||||
{
|
||||
kind: 'skill',
|
||||
name: 'review',
|
||||
description: 'Review code',
|
||||
level: 'project',
|
||||
modelInvocable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await flush();
|
||||
expect(findButtonContaining('review')).toBeTruthy();
|
||||
|
||||
extensions.resolve({
|
||||
extensions: [
|
||||
{
|
||||
id: 'ext-1',
|
||||
name: 'alibabacloud-compute-suite',
|
||||
displayName: 'Alibaba Cloud',
|
||||
description: '',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/ext',
|
||||
capabilities: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
await flush();
|
||||
expect(findButtonContaining('review')).toBeTruthy();
|
||||
expect(findButtonContaining('alibabacloud-compute-suite')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops plain text only into the prompt editor', async () => {
|
||||
await mount([]);
|
||||
click(findButton('New scheduled task'));
|
||||
const prompt = document.querySelector<HTMLElement>('[role="textbox"]');
|
||||
if (!prompt) throw new Error('prompt editor not found');
|
||||
const execCommand = vi.spyOn(document, 'execCommand').mockReturnValue(true);
|
||||
const drop = new Event('drop', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(drop, 'dataTransfer', {
|
||||
value: {
|
||||
getData: (type: string) =>
|
||||
type === 'text/plain' ? 'plain text' : '<b>html</b>',
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
prompt.dispatchEvent(drop);
|
||||
});
|
||||
|
||||
expect(execCommand).toHaveBeenCalledWith('insertText', false, 'plain text');
|
||||
execCommand.mockRestore();
|
||||
});
|
||||
|
||||
it('caps contenteditable input at the scheduled-task prompt limit', async () => {
|
||||
actions.createScheduledTask.mockResolvedValue(baseTask({}));
|
||||
await mount([]);
|
||||
click(findButton('New scheduled task'));
|
||||
const prompt = document.querySelector<HTMLElement>('[role="textbox"]');
|
||||
if (!prompt) throw new Error('prompt editor not found');
|
||||
|
||||
act(() => {
|
||||
prompt.textContent = 'x'.repeat(100_001);
|
||||
prompt.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
click(findButton('Create'));
|
||||
await flush();
|
||||
|
||||
expect(actions.createScheduledTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: 'x'.repeat(100_000),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not preserve empty contenteditable leftovers before an inserted reference', async () => {
|
||||
actions.createScheduledTask.mockResolvedValue(baseTask({}));
|
||||
|
||||
|
|
|
|||
|
|
@ -116,9 +116,10 @@ const MAX_SET_TIMEOUT_MS = 2_147_483_647;
|
|||
// back to the slow lane so a permanently-stuck task can't spin a 1 Hz GET loop.
|
||||
const PAST_DUE_FAST_RELOADS = 3;
|
||||
const OVERDUE_RELOAD_INTERVAL_MS = 30_000;
|
||||
const MAX_PROMPT_LENGTH = 100_000;
|
||||
const AT_REFERENCE_UNSAFE_CHARS = /[^\p{L}\p{N}_./-]/gu;
|
||||
const PROMPT_REFERENCE_TOKEN =
|
||||
/(^|[\s])(@(?:ext|mcp):(?:\\.|[^\s])+|\/[^\s/]+)/gu;
|
||||
/(^|[\s])(@(?:ext|mcp):(?:\\.|[^\s])+|\/(?:\\.|[^\s/])+)(?=$|\s)/gu;
|
||||
const REFERENCE_PICKER_THEME_VARS = [
|
||||
'--background',
|
||||
'--foreground',
|
||||
|
|
@ -176,7 +177,7 @@ function promptReferenceItemFromToken(
|
|||
};
|
||||
}
|
||||
if (token.startsWith('/')) {
|
||||
const label = token.slice(1);
|
||||
const label = unescapeReferenceText(token.slice(1));
|
||||
if (!label) return null;
|
||||
return {
|
||||
id: `skill:${label}`,
|
||||
|
|
@ -246,6 +247,24 @@ function setPromptEditorText(root: HTMLElement, text: string) {
|
|||
});
|
||||
}
|
||||
|
||||
function normalizePromptEditor(root: HTMLElement): string {
|
||||
let next = textFromPromptEditor(root);
|
||||
if (next.length > MAX_PROMPT_LENGTH) {
|
||||
next = next.slice(0, MAX_PROMPT_LENGTH);
|
||||
setPromptEditorText(root, next);
|
||||
} else if (next.trim().length === 0) {
|
||||
clearPromptEditor(root);
|
||||
next = '';
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function insertPlainPromptText(root: HTMLElement, text: string) {
|
||||
const remaining = MAX_PROMPT_LENGTH - textFromPromptEditor(root).length;
|
||||
if (remaining <= 0) return;
|
||||
document.execCommand('insertText', false, text.slice(0, remaining));
|
||||
}
|
||||
|
||||
function makePromptTagElement(item: PromptReferenceItem): HTMLElement {
|
||||
const tag = document.createElement('span');
|
||||
tag.className = styles.promptTag;
|
||||
|
|
@ -309,12 +328,14 @@ function insertPromptTagElement(root: HTMLElement, item: PromptReferenceItem) {
|
|||
|
||||
function PromptReferenceEditor({
|
||||
value,
|
||||
label,
|
||||
placeholder,
|
||||
onChange,
|
||||
insertItem,
|
||||
onInserted,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
insertItem: PromptReferenceItem | null;
|
||||
|
|
@ -349,21 +370,27 @@ function PromptReferenceEditor({
|
|||
className={styles.promptEditor}
|
||||
contentEditable
|
||||
role="textbox"
|
||||
aria-label={label}
|
||||
aria-multiline="true"
|
||||
aria-placeholder={placeholder}
|
||||
onInput={(event) => {
|
||||
let next = textFromPromptEditor(event.currentTarget);
|
||||
if (next.trim().length === 0) {
|
||||
clearPromptEditor(event.currentTarget);
|
||||
next = '';
|
||||
}
|
||||
const next = normalizePromptEditor(event.currentTarget);
|
||||
lastAppliedValueRef.current = next;
|
||||
onChange(next);
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
insertPlainPromptText(
|
||||
event.currentTarget,
|
||||
event.clipboardData.getData('text/plain'),
|
||||
);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
insertPlainPromptText(
|
||||
event.currentTarget,
|
||||
event.dataTransfer.getData('text/plain'),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{value.trim().length === 0 && (
|
||||
|
|
@ -432,6 +459,7 @@ export function ScheduledTasksDialog({
|
|||
// create/toggle/delete's reload must not overwrite the newer list with
|
||||
// stale data. Only the latest reload is allowed to apply its result.
|
||||
const reloadSeqRef = useRef(0);
|
||||
const referenceLoadSeqRef = useRef(0);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
|
|
@ -541,10 +569,22 @@ export function ScheduledTasksDialog({
|
|||
});
|
||||
}, []);
|
||||
|
||||
const resetReferenceState = useCallback(() => {
|
||||
referenceLoadSeqRef.current += 1;
|
||||
setReferenceKind(null);
|
||||
setReferenceItems([]);
|
||||
setReferenceLoading(false);
|
||||
setReferenceError(null);
|
||||
setPendingPromptTag(null);
|
||||
setReferencePickerPosition(null);
|
||||
setReferencePickerThemeVars({});
|
||||
}, []);
|
||||
|
||||
const loadReferences = useCallback(
|
||||
async (kind: PromptTagKind) => {
|
||||
const seq = ++referenceLoadSeqRef.current;
|
||||
if (referenceKind === kind) {
|
||||
setReferenceKind(null);
|
||||
resetReferenceState();
|
||||
return;
|
||||
}
|
||||
updateReferencePickerPosition();
|
||||
|
|
@ -553,54 +593,58 @@ export function ScheduledTasksDialog({
|
|||
setReferenceError(null);
|
||||
setReferenceItems([]);
|
||||
try {
|
||||
let items: PromptReferenceItem[];
|
||||
if (kind === 'extension') {
|
||||
const status = await actions.loadExtensionsStatus();
|
||||
setReferenceItems(
|
||||
(status.extensions ?? [])
|
||||
.filter((extension) => extension.isActive)
|
||||
.map((extension) => ({
|
||||
id: extension.id || extension.name,
|
||||
kind,
|
||||
label: extension.name,
|
||||
description: extensionDescription(extension),
|
||||
insertText: `@ext:${escapeAtReferenceText(extension.name)} `,
|
||||
})),
|
||||
);
|
||||
items = (status.extensions ?? [])
|
||||
.filter((extension) => extension.isActive)
|
||||
.map((extension) => ({
|
||||
id: extension.id || extension.name,
|
||||
kind,
|
||||
label: extension.name,
|
||||
description: extensionDescription(extension),
|
||||
insertText: `@ext:${escapeAtReferenceText(extension.name)} `,
|
||||
}));
|
||||
} else if (kind === 'skill') {
|
||||
const status = await actions.loadSkillsStatus();
|
||||
setReferenceItems(
|
||||
(status.skills ?? [])
|
||||
.filter((skill) => skill.modelInvocable)
|
||||
.map((skill) => ({
|
||||
id: skill.name,
|
||||
kind,
|
||||
label: skill.name,
|
||||
description: skillDescription(skill),
|
||||
insertText: `/${skill.name} `,
|
||||
})),
|
||||
);
|
||||
items = (status.skills ?? [])
|
||||
.filter((skill) => skill.modelInvocable)
|
||||
.map((skill) => ({
|
||||
id: skill.name,
|
||||
kind,
|
||||
label: skill.name,
|
||||
description: skillDescription(skill),
|
||||
insertText: `/${escapeAtReferenceText(skill.name)} `,
|
||||
}));
|
||||
} else {
|
||||
const status = await actions.loadMcpStatus();
|
||||
setReferenceItems(
|
||||
(status.servers ?? [])
|
||||
.filter((server) => !server.disabled)
|
||||
.map((server) => ({
|
||||
id: server.name,
|
||||
kind,
|
||||
label: server.name,
|
||||
description: mcpDescription(server),
|
||||
insertText: `@mcp:${escapeAtReferenceText(server.name)} `,
|
||||
})),
|
||||
);
|
||||
items = (status.servers ?? [])
|
||||
.filter((server) => !server.disabled)
|
||||
.map((server) => ({
|
||||
id: server.name,
|
||||
kind,
|
||||
label: server.name,
|
||||
description: mcpDescription(server),
|
||||
insertText: `@mcp:${escapeAtReferenceText(server.name)} `,
|
||||
}));
|
||||
}
|
||||
if (!mountedRef.current || seq !== referenceLoadSeqRef.current) return;
|
||||
setReferenceItems(items);
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return;
|
||||
if (!mountedRef.current || seq !== referenceLoadSeqRef.current) return;
|
||||
setReferenceError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (mountedRef.current) setReferenceLoading(false);
|
||||
if (mountedRef.current && seq === referenceLoadSeqRef.current) {
|
||||
setReferenceLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[actions, referenceKind, updateReferencePickerPosition],
|
||||
[
|
||||
actions,
|
||||
referenceKind,
|
||||
resetReferenceState,
|
||||
updateReferencePickerPosition,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -612,13 +656,13 @@ export function ScheduledTasksDialog({
|
|||
const picker = referencePickerRef.current;
|
||||
if (popover?.contains(event.target as Node)) return;
|
||||
if (picker?.contains(event.target as Node)) return;
|
||||
setReferenceKind(null);
|
||||
resetReferenceState();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setReferenceKind(null);
|
||||
resetReferenceState();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -634,7 +678,7 @@ export function ScheduledTasksDialog({
|
|||
window.removeEventListener('resize', handleReposition);
|
||||
window.removeEventListener('scroll', handleReposition, true);
|
||||
};
|
||||
}, [referenceKind, updateReferencePickerPosition]);
|
||||
}, [referenceKind, resetReferenceState, updateReferencePickerPosition]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setName('');
|
||||
|
|
@ -644,12 +688,8 @@ export function ScheduledTasksDialog({
|
|||
setFormError(null);
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setReferenceKind(null);
|
||||
setReferenceItems([]);
|
||||
setReferenceError(null);
|
||||
setPendingPromptTag(null);
|
||||
setReferencePickerPosition(null);
|
||||
}, []);
|
||||
resetReferenceState();
|
||||
}, [resetReferenceState]);
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setEditingId(null);
|
||||
|
|
@ -658,30 +698,25 @@ export function ScheduledTasksDialog({
|
|||
setBuilder(DEFAULT_BUILDER);
|
||||
setRunMode('shared');
|
||||
setFormError(null);
|
||||
setReferenceKind(null);
|
||||
setReferenceItems([]);
|
||||
setReferenceError(null);
|
||||
setPendingPromptTag(null);
|
||||
setReferencePickerPosition(null);
|
||||
resetReferenceState();
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
}, [resetReferenceState]);
|
||||
|
||||
const openEdit = useCallback((task: DaemonScheduledTask) => {
|
||||
setEditingId(task.id);
|
||||
setName(task.name ?? '');
|
||||
setPrompt(task.prompt);
|
||||
// Reverse the cron back onto the pickers; an expression the pickers can't
|
||||
// represent lands in the `custom` field, never silently rewritten.
|
||||
setBuilder(parseCronToBuilder(task.cron));
|
||||
setRunMode(task.runMode ?? 'shared');
|
||||
setFormError(null);
|
||||
setReferenceKind(null);
|
||||
setReferenceItems([]);
|
||||
setReferenceError(null);
|
||||
setPendingPromptTag(null);
|
||||
setReferencePickerPosition(null);
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
const openEdit = useCallback(
|
||||
(task: DaemonScheduledTask) => {
|
||||
setEditingId(task.id);
|
||||
setName(task.name ?? '');
|
||||
setPrompt(task.prompt);
|
||||
// Reverse the cron back onto the pickers; an expression the pickers can't
|
||||
// represent lands in the `custom` field, never silently rewritten.
|
||||
setBuilder(parseCronToBuilder(task.cron));
|
||||
setRunMode(task.runMode ?? 'shared');
|
||||
setFormError(null);
|
||||
resetReferenceState();
|
||||
setShowForm(true);
|
||||
},
|
||||
[resetReferenceState],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const cron = buildCron(builder);
|
||||
|
|
@ -693,6 +728,14 @@ export function ScheduledTasksDialog({
|
|||
setFormError(t('scheduledTasks.error.emptyPrompt'));
|
||||
return;
|
||||
}
|
||||
if (prompt.length > MAX_PROMPT_LENGTH) {
|
||||
setFormError(
|
||||
t('scheduledTasks.error.promptTooLong', {
|
||||
max: MAX_PROMPT_LENGTH,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
|
|
@ -852,6 +895,8 @@ export function ScheduledTasksDialog({
|
|||
<div
|
||||
ref={referencePickerRef}
|
||||
className={styles.referencePicker}
|
||||
role="listbox"
|
||||
aria-label={t('scheduledTasks.referencePicker')}
|
||||
style={
|
||||
{
|
||||
...referencePickerThemeVars,
|
||||
|
|
@ -878,6 +923,8 @@ export function ScheduledTasksDialog({
|
|||
key={`${item.kind}:${item.id}`}
|
||||
type="button"
|
||||
className={styles.referenceItem}
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setPendingPromptTag(item);
|
||||
|
|
@ -965,6 +1012,7 @@ export function ScheduledTasksDialog({
|
|||
</span>
|
||||
<PromptReferenceEditor
|
||||
value={prompt}
|
||||
label={t('scheduledTasks.prompt')}
|
||||
placeholder={t('scheduledTasks.promptPlaceholder')}
|
||||
onChange={setPrompt}
|
||||
insertItem={pendingPromptTag}
|
||||
|
|
|
|||
|
|
@ -679,6 +679,7 @@ const EN: Messages = {
|
|||
'scheduledTasks.reference.extension': 'Extensions',
|
||||
'scheduledTasks.reference.skill': 'Skills',
|
||||
'scheduledTasks.reference.mcp': 'MCP',
|
||||
'scheduledTasks.referencePicker': 'Reference picker',
|
||||
'scheduledTasks.reference.loading': 'Loading…',
|
||||
'scheduledTasks.reference.empty': 'No available items.',
|
||||
'scheduledTasks.frequency': 'Frequency',
|
||||
|
|
@ -715,6 +716,8 @@ const EN: Messages = {
|
|||
'scheduledTasks.cancel': 'Cancel',
|
||||
'scheduledTasks.error.invalidSchedule': 'Invalid schedule',
|
||||
'scheduledTasks.error.emptyPrompt': 'Prompt is required',
|
||||
'scheduledTasks.error.promptTooLong': (v) =>
|
||||
`Prompt exceeds ${v?.max ?? 100_000}-character limit`,
|
||||
'scheduledTasks.error.toggleFailed': 'Failed to update task',
|
||||
'scheduledTasks.error.deleteFailed': 'Failed to delete task',
|
||||
'scheduledTasks.edit': 'Edit',
|
||||
|
|
@ -2374,6 +2377,7 @@ const ZH: Messages = {
|
|||
'scheduledTasks.reference.extension': '扩展',
|
||||
'scheduledTasks.reference.skill': '技能',
|
||||
'scheduledTasks.reference.mcp': 'MCP',
|
||||
'scheduledTasks.referencePicker': '引用选择器',
|
||||
'scheduledTasks.reference.loading': '加载中…',
|
||||
'scheduledTasks.reference.empty': '暂无可选项。',
|
||||
'scheduledTasks.frequency': '频率',
|
||||
|
|
@ -2409,6 +2413,8 @@ const ZH: Messages = {
|
|||
'scheduledTasks.cancel': '取消',
|
||||
'scheduledTasks.error.invalidSchedule': '计划无效',
|
||||
'scheduledTasks.error.emptyPrompt': '提示词不能为空',
|
||||
'scheduledTasks.error.promptTooLong': (v) =>
|
||||
`提示词超过 ${v?.max ?? 100_000} 字符限制`,
|
||||
'scheduledTasks.error.toggleFailed': '更新任务失败',
|
||||
'scheduledTasks.error.deleteFailed': '删除任务失败',
|
||||
'scheduledTasks.edit': '编辑',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue