Skip to content

Commit cd65eb7

Browse files
fix(app-shell): an object_extension list row names and opens the object it extends (#29608)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbCMKMmBM3vWYAtbM4KFCXQ4)_ Fixes #29569 Clause-②: no Metadata-protocol: none The two lines are copied from the claim (comment 6587248382, amended by 6587714478). ## 维护者速读 - 改了什么:`object_extension` 类型在元数据列表里改用 `extend`(被扩展对象的名字)作为标识键。Studio「全部元数据」里,`.custom` 定制包的扩展行现在显示 `showcase_task` 这样的对象名,列头叫「对象」,点开即进入这条扩展;搜索框和定制标记也按 `extend` 查找。 - 为什么改:扩展条目按目标对象名存储,正文只有 `extend`、没有 `name`(`ObjectExtensionSchema` 拒收 `name`,服务端原样返回)。列表读的是 `name`,所以名字栏显示「—」,链接指向 `…/object_extension/(unnamed-0)`,打开是空页。服务端没有问题,名字可以从返回的 `extend` 得到。 - 风险与回滚:只是给一个类型登记列表配置(`default-schemas.ts` 里一条 `registerMetadataResource`),外加 en/zh 各一个列头词条。其它类型的行不变,有对照用例钉住。回滚即撤销本 PR。 - 先看:`ui/packages/app-shell/src/views/metadata-admin/default-schemas.ts`(新登记的 `object_extension`);新测试 `ResourceListPage.objectExtensionName-29569.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/ResourceListPage.objectExtensionName-29569.test.tsx`,预期 6 个用例全过;删掉 `primaryKey: 'extend',` 那一行时 5 个转红,只有对照用例保持绿。 - 需要你做的:无,席位落地。 ## What changes - **`ui/packages/app-shell/src/views/metadata-admin/default-schemas.ts`**: `registerDefaultMetadataSchemas()` registers `object_extension` with `primaryKey: 'extend'` and `searchableFields: ['extend', 'label', 'description']`. This is the per-type list declaration `email_template` already makes there (`bundleMemberField`). `ResourceListPage` reads `config.primaryKey` for the name cell, the row link, the row test id and the customization mark, so all four now use `extend`. - **`ui/packages/i18n/src/namespaces/studio/en.ts` / `zh.ts`**: `engine.list.col.extend` ("Object" / "对象"). The list localises a default column header by `engine.list.col.KEY`, so without it the header read the raw key "extend". These two files are in scope by the claim amendment (6587714478). - The Label column is unchanged: it shows the extension's own `label` (the spec's "Override label for the extended object"), not the target's label. ## Tests - **New `metadata-admin/ResourceListPage.objectExtensionName-29569.test.tsx`**, over a client serving the item as the card measured it, `{ extend: 'showcase_task', fields, _packageId: 'com.example.showcase.custom' }`: - cross-package leg (`/studio/~all/metadata/object_extension`): the row shows `showcase_task` under an "Object" header, no `(unnamed-N)` row exists, and its link is `/studio/~all/metadata/object_extension/showcase_task?package=com.example.showcase.custom`; clicking it lands there; - the base's area (`/studio/com.example.showcase/metadata/object_extension`): the row is found by `extend`, carries the pair's "Added" mark, and links to the extension; - the mark is looked up by `extend`: a read listing `(object_extension, showcase_task)` as an override marks that row "Customized", and the other extension stays "Added"; - search matches `extend`; the Label column shows the extension's own label; - CONTROL: a `page` row still names, links and heads its column by `name`. - **Ablation:** with `primaryKey: 'extend',` deleted through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `89d62fcc` → `70bfea6f`), 5 of 6 go red; the control stays green. Restored: blob equals HEAD, `git diff HEAD` empty. - Related ui tests (every test file naming `default-schemas` / `registerDefaultMetadataSchemas`, `engine.list.col` or the studio namespace, plus `packages/i18n/`): 64/64 files (524 tests) and 156/156 files (2451 passed, 13 skipped). - After merging `main` (head `04cbc4f7`): the 12 `ResourceListPage*` / `metadataAreaHost*` test files, 95/95. - `turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n`: 66/66 tasks (tests included through `tsconfig.test.json`). ESLint on the four changed files: exit 0, no output. ## Acceptance notes - **Live, before and after** (showcase `objectstack dev --seed-admin --fresh` + the Vite console on source, at `e832ddb1`). The extension was authored through the doors Studio uses: `POST /api/v1/packages/com.example.showcase/ensure-customization`, then `PUT /api/v1/meta/object_extension/showcase_task?package=com.example.showcase.custom`. The list read answered `{"extend":"showcase_task","fields":{…},"_packageId":"com.example.showcase.custom"}`. - Before: `~all` row text "— — — Artifact", link `…/object_extension/(unnamed-0)?package=com.example.showcase.custom`, which opened an empty "(unnamed-0)" item page. The base's area showed "— Added — — Artifact" with the same link. - After: `~all` row "showcase_task — — Artifact", link `…/object_extension/showcase_task?package=com.example.showcase.custom`, which opens the item page with `Extend: showcase_task` and its `fields`. The base's area shows "showcase_task Added — — Artifact". - The eight screenshots (list and opened item, both legs, before and after) are in the project's files under `issue-29569/`; the relay cannot attach images to a PR body. - Noted, not filed: the source column says "Artifact" for this stored, runtime-written row. It is outside this card's surface and is not a name defect. ## Verification Gate readings at head `20add007` (the fix and changeset); the changeset, sdui-manifest, nul-byte and published-files gates re-run at `04cbc4f7` after the merge of `main`, all exit 0. - `node scripts/pm/dispatch-gates.mjs --ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm --dir ui exec vitest run scripts/` (locked): 170 files passed, 2 skipped; 4972 tests passed. - ui static checks (`check:phantom-deps`, `check:spec-symbols`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys`, `check:designer-field-key-parity`, `check:metadata-write-doors`): all exit 0. - Repo-wide lint and the full ui suites are CI's. <details><summary>The 34 derived gate families, each with its exit code</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jUb9XMCo8G5TmhSAe4 --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUb9XMCo8G5TmhSAe4)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0a4e74e commit cd65eb7

6 files changed

Lines changed: 269 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): an `object_extension` list row names and opens the object it extends
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
An `object_extension` item is stored under the name of the object it extends, and its body carries `extend` and no `name` (`ObjectExtensionSchema` refuses one). The metadata list read `name`, so in Studio's All metadata area a customization package's extension row showed "—" in its name cell and linked to `…/object_extension/(unnamed-0)`, an empty page. The type now declares `extend` as its identity key: the row shows the extended object (for example `showcase_task`) under an "Object" header, its link opens that extension in its own package, the search box matches it, and its customization mark is looked up by it. Rows of every other type are unchanged.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29569 — an `object_extension` row names, links and marks the
5+
* object it extends.
6+
*
7+
* Measured on the card: the pair `com.example.showcase.custom` extended
8+
* `showcase_task`, and the list read served the item as stored,
9+
* `{ extend: 'showcase_task', fields, _packageId }`, with no `name`
10+
* (`ObjectExtensionSchema` refuses one; the row is stored under the target's
11+
* name). The list read `name`, so Studio's All metadata area drew the row as
12+
* "—" and linked it to `…/object_extension/(unnamed-0)`, an empty page. The
13+
* type now declares `extend` as its identity key (`default-schemas.ts`).
14+
*
15+
* Pinned, each against a control:
16+
*
17+
* - in the cross-package leg the row shows `showcase_task` under an "Object"
18+
* header and links to that extension in its own package;
19+
* - in the base's area the row is found by `extend`, carries the pair's
20+
* "Added" mark, and its link addresses the extension, never `(unnamed-N)`;
21+
* - the mark is looked up by `extend`: a read listing the extension as an
22+
* override marks the row "Customized";
23+
* - the search box matches `extend`;
24+
* - the Label column shows the extension's own label;
25+
* - CONTROL: a row of another type (`page`) still names and links by `name`.
26+
*/
27+
28+
import '@testing-library/jest-dom/vitest';
29+
import * as React from 'react';
30+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
31+
import { render, screen, cleanup, within, fireEvent } from '@testing-library/react';
32+
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
33+
// The switch column waits on `audienceAnchorsOf`'s dynamic import of this exact
34+
// specifier (see `ResourceListPage.environmentScope-7611.test.tsx`).
35+
import '@objectstack/spec/identity';
36+
import { MetadataAreaHostContext, type MetadataAreaHost } from './metadataAreaHost';
37+
38+
const BASE = 'com.example.showcase';
39+
const PAIR = 'com.example.showcase.custom';
40+
41+
/** What the list read serves: the extension body as stored, no `name`. */
42+
const LISTS: Record<string, Record<string, unknown>[]> = {
43+
object_extension: [
44+
{
45+
extend: 'showcase_task',
46+
label: 'Work items',
47+
fields: { custom_note__c: { type: 'text', label: 'Custom note' } },
48+
_packageId: PAIR,
49+
},
50+
{ extend: 'showcase_project', fields: { budget_code__c: { type: 'text' } }, _packageId: PAIR },
51+
],
52+
page: [{ name: 'task_board', label: 'Task board', _packageId: PAIR }],
53+
};
54+
55+
/**
56+
* The package list as served: the code-provided base and its writable pair, a
57+
* project package, which the cross-package leg's list scopes to.
58+
*/
59+
const PACKAGES = [
60+
{ manifest: { id: BASE, name: 'Showcase', scope: 'installed' } },
61+
{ manifest: { id: PAIR, name: 'Showcase (custom)' } },
62+
];
63+
64+
/** Stable singleton — the page's load effect keys on the client identity. */
65+
const CLIENT = {
66+
list: async (type: string) => (type === 'package' ? PACKAGES : (LISTS[type] ?? [])),
67+
};
68+
69+
const entry = (type: string, label: string) => ({
70+
type,
71+
label,
72+
domain: 'data',
73+
allowEnvironmentOverlay: false,
74+
allowRuntimeCreate: true,
75+
});
76+
const ENTRIES = [entry('object_extension', 'Object Extension'), entry('page', 'Page')];
77+
78+
vi.mock('./useMetadata', async (importOriginal) => ({
79+
...(await importOriginal<Record<string, unknown>>()),
80+
useMetadataClient: () => CLIENT,
81+
useMetadataTypes: () => ({ loading: false, error: null, entries: ENTRIES }),
82+
}));
83+
vi.mock('./i18n', async (importOriginal) => ({
84+
...(await importOriginal<Record<string, unknown>>()),
85+
useMetadataLocale: () => 'en-US',
86+
}));
87+
vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({
88+
...(await importOriginal<Record<string, unknown>>()),
89+
useCanAuthorMetadata: () => true,
90+
}));
91+
92+
import { MetadataResourceListPage } from './ResourceListPage';
93+
import { registerDefaultMetadataSchemas } from './default-schemas';
94+
import { t } from './i18n';
95+
96+
registerDefaultMetadataSchemas();
97+
98+
const EMPTY_COUNTS = { overrides: 0, extensions: 0, switchedOff: 0, contributions: 0 };
99+
/** The base's read as served on the card: the extension's field, no override. */
100+
const SERVED_READ = {
101+
packageId: BASE,
102+
overrides: [],
103+
extensions: [{ object: 'showcase_task', kind: 'field', name: 'custom_note__c' }],
104+
switchedOff: [],
105+
contributions: [],
106+
counts: { ...EMPTY_COUNTS, extensions: 1 },
107+
};
108+
let read: Record<string, unknown> = SERVED_READ;
109+
110+
const fetchMock = vi.fn(async (url: string) => {
111+
const json = (status: number, body: unknown) =>
112+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
113+
const u = String(url);
114+
if (u === '/api/v1/packages') {
115+
return json(200, { success: true, data: { packages: [{ manifest: { id: BASE, name: 'Showcase', scope: 'installed' }, writable: false }] } });
116+
}
117+
if (/^\/api\/v1\/packages\/[^/]+\/customizations$/.test(u)) return json(200, { success: true, data: read });
118+
if (u.startsWith('/api/v1/data/sys_metadata_activation')) {
119+
return json(200, { object: 'sys_metadata_activation', records: [], hasMore: false });
120+
}
121+
return json(404, { success: false });
122+
});
123+
124+
beforeEach(() => {
125+
read = SERVED_READ;
126+
fetchMock.mockClear();
127+
vi.stubGlobal('fetch', fetchMock);
128+
});
129+
afterEach(() => {
130+
cleanup();
131+
vi.unstubAllGlobals();
132+
});
133+
134+
const area = (packageId: string | null): MetadataAreaHost => ({
135+
packageId,
136+
readOnly: packageId !== null,
137+
basePath: `/studio/${packageId ?? '~all'}/metadata`,
138+
});
139+
140+
function Where() {
141+
const loc = useLocation();
142+
return <div data-testid="where">{loc.pathname + loc.search}</div>;
143+
}
144+
145+
function mountList(host: MetadataAreaHost, type: string, search = '') {
146+
return render(
147+
<MetadataAreaHostContext.Provider value={host}>
148+
<MemoryRouter initialEntries={[`${host.basePath}/${type}${search}`]}>
149+
<Routes>
150+
<Route path={`${host.basePath}/${type}`} element={<MetadataResourceListPage type={type} />} />
151+
<Route path="*" element={<Where />} />
152+
</Routes>
153+
</MemoryRouter>
154+
</MetadataAreaHostContext.Provider>,
155+
);
156+
}
157+
158+
const linkOf = (row: HTMLElement) => within(row).getAllByRole('link')[0]!;
159+
const marksOf = (row: HTMLElement) =>
160+
within(row)
161+
.queryAllByTestId('customization-mark')
162+
.map((m) => m.getAttribute('data-kind'));
163+
164+
describe('an object_extension row names, links and marks the object it extends (objectstack#29569)', () => {
165+
it('the cross-package leg shows `extend` under an "Object" header and links to that extension', async () => {
166+
mountList(area(null), 'object_extension');
167+
const row = await screen.findByTestId('metadata-row-showcase_task');
168+
169+
const cells = within(row).getAllByRole('cell');
170+
expect(cells[0]).toHaveTextContent('showcase_task');
171+
expect(cells[0]).not.toHaveTextContent('—');
172+
expect(screen.queryByTestId(/^metadata-row-\(unnamed-/)).toBeNull();
173+
const headers = screen.getAllByRole('columnheader').map((h) => h.textContent?.trim());
174+
expect(headers[0]).toBe(t('engine.list.col.extend', 'en-US'));
175+
expect(t('engine.list.col.extend', 'en-US')).toBe('Object');
176+
177+
const href = linkOf(row).getAttribute('href') ?? '';
178+
expect(href).toBe(`/studio/~all/metadata/object_extension/showcase_task?package=${encodeURIComponent(PAIR)}`);
179+
fireEvent.click(linkOf(row));
180+
expect(screen.getByTestId('where')).toHaveTextContent(
181+
`/studio/~all/metadata/object_extension/showcase_task?package=${PAIR}`,
182+
);
183+
});
184+
185+
it("in the base's area the pair's row is found by `extend`, marked Added, and opens that extension", async () => {
186+
mountList(area(BASE), 'object_extension', `?package=${BASE}`);
187+
const row = await screen.findByTestId('metadata-row-showcase_task');
188+
await within(row).findByTestId('customization-mark');
189+
190+
expect(within(row).getAllByRole('cell')[0]).toHaveTextContent('showcase_task');
191+
expect(marksOf(row)).toEqual(['added']);
192+
expect(linkOf(row).getAttribute('href')).toBe(
193+
`/studio/${BASE}/metadata/object_extension/showcase_task?package=${encodeURIComponent(PAIR)}`,
194+
);
195+
});
196+
197+
it('the mark is looked up by `extend`: a read listing the extension as an override marks it Customized', async () => {
198+
read = {
199+
...SERVED_READ,
200+
overrides: [{ type: 'object_extension', name: 'showcase_task' }],
201+
counts: { ...EMPTY_COUNTS, overrides: 1, extensions: 1 },
202+
};
203+
mountList(area(BASE), 'object_extension', `?package=${BASE}`);
204+
const row = await screen.findByTestId('metadata-row-showcase_task');
205+
await within(row).findByTestId('customization-mark');
206+
207+
expect(marksOf(row)).toEqual(['customized']);
208+
// CONTROL: the other extension the read does not list stays "Added".
209+
expect(marksOf(screen.getByTestId('metadata-row-showcase_project'))).toEqual(['added']);
210+
});
211+
212+
it('the search box matches `extend`', async () => {
213+
mountList(area(null), 'object_extension');
214+
await screen.findByTestId('metadata-row-showcase_task');
215+
216+
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'project' } });
217+
expect(screen.getByTestId('metadata-row-showcase_project')).toBeInTheDocument();
218+
expect(screen.queryByTestId('metadata-row-showcase_task')).toBeNull();
219+
});
220+
221+
it("the Label column shows the extension's own label", async () => {
222+
mountList(area(null), 'object_extension');
223+
const row = await screen.findByTestId('metadata-row-showcase_task');
224+
225+
expect(within(row).getAllByRole('cell')[1]).toHaveTextContent('Work items');
226+
expect(within(screen.getByTestId('metadata-row-showcase_project')).getAllByRole('cell')[1]).toHaveTextContent('—');
227+
});
228+
229+
it('CONTROL: a row of another type still names and links by `name`', async () => {
230+
mountList(area(null), 'page');
231+
const row = await screen.findByTestId('metadata-row-task_board');
232+
233+
expect(within(row).getAllByRole('cell')[0]).toHaveTextContent('task_board');
234+
expect(linkOf(row).getAttribute('href')).toBe(
235+
`/studio/~all/metadata/page/task_board?package=${encodeURIComponent(PAIR)}`,
236+
);
237+
const headers = screen.getAllByRole('columnheader').map((h) => h.textContent?.trim());
238+
expect(headers[0]).toBe(t('engine.list.col.name', 'en-US'));
239+
});
240+
});

‎ui/packages/app-shell/src/views/metadata-admin/conditionScope.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ export const CONDITION_SCOPE_BY_METADATA_TYPE = {
147147
job: 'none',
148148
mapping: 'none',
149149
object: 'none',
150+
/** objectstack#29569 registers `object_extension`; `ObjectExtensionSchema` declares no condition key. */
151+
object_extension: 'none',
150152
position: 'none',
151153
report: 'none',
152154
skill: 'none',

‎ui/packages/app-shell/src/views/metadata-admin/default-schemas.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,4 +362,16 @@ export function registerDefaultMetadataSchemas(): void {
362362
// objectstack#26649 — two locales of one template are two members of one
363363
// name; the list tells them apart by `locale` and opens each by it.
364364
registerMetadataResource({ type: 'email_template', bundleMemberField: 'locale' });
365+
366+
// objectstack#29569 (ADR-0146 D10) — an `object_extension` is stored under
367+
// the name of the object it extends, and its body carries `extend` and no
368+
// `name` (`ObjectExtensionSchema` refuses one; the list read serves the body
369+
// as stored). Its identity key is therefore `extend`: the list's name cell,
370+
// its row link, its search and its customization mark all read it, so a row
371+
// shows and opens the extended object instead of "—" and `(unnamed-N)`.
372+
registerMetadataResource({
373+
type: 'object_extension',
374+
primaryKey: 'extend',
375+
searchableFields: ['extend', 'label', 'description'],
376+
});
365377
}

‎ui/packages/i18n/src/namespaces/studio/en.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3620,6 +3620,8 @@ const STUDIO_EN: Record<string, string> = {
36203620
'engine.list.col.label': 'Label',
36213621
'engine.list.col.source': 'Source',
36223622
'engine.list.col.object': 'Object',
3623+
// objectstack#29569 — an object_extension lists by the object it extends.
3624+
'engine.list.col.extend': 'Object',
36233625
'engine.list.col.type': 'Type',
36243626
'engine.list.col.description': 'Description',
36253627
'engine.list.col.sharedWith': 'Shared with',

‎ui/packages/i18n/src/namespaces/studio/zh.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3218,6 +3218,8 @@ const STUDIO_ZH: Record<string, string> = {
32183218
'engine.list.col.label': '标签',
32193219
'engine.list.col.source': '来源',
32203220
'engine.list.col.object': '对象',
3221+
// objectstack#29569 — an object_extension lists by the object it extends.
3222+
'engine.list.col.extend': '对象',
32213223
'engine.list.col.type': '类型',
32223224
'engine.list.col.description': '描述',
32233225
'engine.list.col.sharedWith': '共享给',

0 commit comments

Comments
 (0)