dvlprsh-dcql-test 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/dist/index.d.mts +135 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.js +341 -0
- package/dist/index.mjs +314 -0
- package/package.json +59 -0
- package/src/credentials/credential.ts +12 -0
- package/src/credentials/sdjwtvc.credential.ts +208 -0
- package/src/dcql.ts +170 -0
- package/src/index.ts +4 -0
- package/src/match.ts +88 -0
- package/src/test/dcql.spec.ts +295 -0
- package/src/test/index.spec.ts +7 -0
- package/src/test/pathMatch.spec.ts +163 -0
- package/src/test/sdjwtvc-credentials.spec.ts +259 -0
- package/src/type.ts +65 -0
- package/tsconfig.json +9 -0
- package/vitest.config.mts +4 -0
package/src/dcql.ts
ADDED
@@ -0,0 +1,170 @@
|
|
1
|
+
import { CredentialBase } from './credentials/credential';
|
2
|
+
import { SdJwtVcCredential } from './credentials/sdjwtvc.credential';
|
3
|
+
import { Claims, CredentialSet, rawDCQL } from './type';
|
4
|
+
|
5
|
+
/**
|
6
|
+
* This class represent DCQL query data structure
|
7
|
+
*/
|
8
|
+
export class DCQL {
|
9
|
+
private _credentials: CredentialBase[] = [];
|
10
|
+
private _credential_sets?: CredentialSet[];
|
11
|
+
|
12
|
+
constructor({
|
13
|
+
credentials,
|
14
|
+
credential_sets,
|
15
|
+
}: {
|
16
|
+
credentials?: CredentialBase[];
|
17
|
+
credential_sets?: CredentialSet[];
|
18
|
+
}) {
|
19
|
+
this._credentials = credentials ?? [];
|
20
|
+
this._credential_sets = credential_sets;
|
21
|
+
}
|
22
|
+
|
23
|
+
addCredential(credential: CredentialBase) {
|
24
|
+
this._credentials.push(credential);
|
25
|
+
return this;
|
26
|
+
}
|
27
|
+
|
28
|
+
addCredentialSet(credential_set: CredentialSet) {
|
29
|
+
if (!this._credential_sets) {
|
30
|
+
this._credential_sets = [];
|
31
|
+
}
|
32
|
+
this._credential_sets.push(credential_set);
|
33
|
+
return this;
|
34
|
+
}
|
35
|
+
|
36
|
+
serialize(): rawDCQL {
|
37
|
+
return {
|
38
|
+
credentials: this._credentials.map((c) => c.serialize()),
|
39
|
+
credential_sets: this._credential_sets,
|
40
|
+
};
|
41
|
+
}
|
42
|
+
|
43
|
+
static parse(raw: rawDCQL): DCQL {
|
44
|
+
const credentials = raw.credentials.map((c) => {
|
45
|
+
if (c.format === 'dc+sd-jwt') {
|
46
|
+
return SdJwtVcCredential.parseSdJwtCredential(c);
|
47
|
+
}
|
48
|
+
throw new Error('Invalid credential format');
|
49
|
+
});
|
50
|
+
|
51
|
+
return new DCQL({
|
52
|
+
credentials,
|
53
|
+
credential_sets: raw.credential_sets,
|
54
|
+
});
|
55
|
+
}
|
56
|
+
|
57
|
+
/**
|
58
|
+
* Match credentials against an array of data records according to section 6.4.2 rules.
|
59
|
+
*
|
60
|
+
* If credential_sets is not provided, all credentials in credentials are requested.
|
61
|
+
* Otherwise, the Verifier requests credentials satisfying:
|
62
|
+
* - All required credential sets (where required is true or omitted)
|
63
|
+
* - Optionally, any other credential sets
|
64
|
+
*
|
65
|
+
* @param dataRecords Array of data records to match against
|
66
|
+
* @returns Object containing match result and matched credentials with their claims
|
67
|
+
*/
|
68
|
+
match(dataRecords: Record<string, unknown>[]): {
|
69
|
+
match: boolean;
|
70
|
+
matchedCredentials?: Array<{
|
71
|
+
credential: Record<string, unknown>;
|
72
|
+
matchedClaims: Claims[];
|
73
|
+
dataIndex: number;
|
74
|
+
}>;
|
75
|
+
} {
|
76
|
+
// No credentials to match
|
77
|
+
if (this._credentials.length === 0) {
|
78
|
+
return { match: false };
|
79
|
+
}
|
80
|
+
|
81
|
+
// Results array to collect all matches
|
82
|
+
const allMatches: Array<{
|
83
|
+
credential: Record<string, unknown>;
|
84
|
+
dcqlCredential: CredentialBase;
|
85
|
+
matchedClaims: Claims[];
|
86
|
+
dataIndex: number;
|
87
|
+
}> = [];
|
88
|
+
|
89
|
+
// Check each credential against each data record
|
90
|
+
this._credentials.forEach((credential) => {
|
91
|
+
// Try to match the credential against each data record
|
92
|
+
dataRecords.forEach((data, index) => {
|
93
|
+
const result = credential.match(data);
|
94
|
+
|
95
|
+
// If there's a match, add it to our results
|
96
|
+
if (result.match) {
|
97
|
+
allMatches.push({
|
98
|
+
credential: data,
|
99
|
+
dcqlCredential: credential,
|
100
|
+
matchedClaims: result.matchedClaims,
|
101
|
+
dataIndex: index,
|
102
|
+
});
|
103
|
+
}
|
104
|
+
});
|
105
|
+
});
|
106
|
+
|
107
|
+
// If no credentials matched any data, return no match
|
108
|
+
if (allMatches.length === 0) {
|
109
|
+
return { match: false };
|
110
|
+
}
|
111
|
+
|
112
|
+
// If credential_sets is not defined, return all matched credentials
|
113
|
+
if (!this._credential_sets || this._credential_sets.length === 0) {
|
114
|
+
return {
|
115
|
+
match: true,
|
116
|
+
matchedCredentials: allMatches,
|
117
|
+
};
|
118
|
+
}
|
119
|
+
|
120
|
+
// Handle credential sets if they exist
|
121
|
+
const matchedIds = new Set(
|
122
|
+
allMatches.map((match) => {
|
123
|
+
const serialized = match.dcqlCredential.serialize();
|
124
|
+
return serialized.id;
|
125
|
+
}),
|
126
|
+
);
|
127
|
+
|
128
|
+
// First, separate required credential sets
|
129
|
+
const requiredSets = this._credential_sets.filter(
|
130
|
+
(set) => set.required === undefined || set.required === true,
|
131
|
+
);
|
132
|
+
|
133
|
+
// Check if all required sets are satisfied
|
134
|
+
const satisfiedRequiredSets = requiredSets.every((set) =>
|
135
|
+
this.isCredentialSetSatisfied(set, matchedIds),
|
136
|
+
);
|
137
|
+
|
138
|
+
// If any required set is not satisfied, we can't match
|
139
|
+
if (!satisfiedRequiredSets) {
|
140
|
+
return { match: false };
|
141
|
+
}
|
142
|
+
|
143
|
+
// We've satisfied all required sets, return all matched credentials
|
144
|
+
return {
|
145
|
+
match: true,
|
146
|
+
matchedCredentials: allMatches.map((matches) => {
|
147
|
+
return {
|
148
|
+
credential: matches.credential,
|
149
|
+
matchedClaims: matches.matchedClaims,
|
150
|
+
dataIndex: matches.dataIndex,
|
151
|
+
};
|
152
|
+
}),
|
153
|
+
};
|
154
|
+
}
|
155
|
+
|
156
|
+
/**
|
157
|
+
* Check if a credential set is satisfied by the matched credential IDs
|
158
|
+
* A credential set is satisfied if at least one of its options is satisfied
|
159
|
+
*/
|
160
|
+
private isCredentialSetSatisfied(
|
161
|
+
set: CredentialSet,
|
162
|
+
matchedIds: Set<string>,
|
163
|
+
): boolean {
|
164
|
+
// A set is satisfied if at least one of its options is satisfied
|
165
|
+
return set.options.some((option) => {
|
166
|
+
// An option is satisfied if all credential IDs in the option are matched
|
167
|
+
return option.every((credentialId) => matchedIds.has(credentialId));
|
168
|
+
});
|
169
|
+
}
|
170
|
+
}
|
package/src/index.ts
ADDED
package/src/match.ts
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
/**
|
2
|
+
* Process a claims path pointer
|
3
|
+
* @param path The claims path pointer array
|
4
|
+
* @param data The credential data
|
5
|
+
* @returns Array of selected JSON elements
|
6
|
+
* @throws Error if processing should be aborted according to the specification
|
7
|
+
*/
|
8
|
+
export const pathMatch = (
|
9
|
+
path: Array<string | number | null>,
|
10
|
+
data: any,
|
11
|
+
): any[] => {
|
12
|
+
// Start with the root element
|
13
|
+
let selectedElements: any[] = [data];
|
14
|
+
|
15
|
+
// Process the path from left to right
|
16
|
+
for (const component of path) {
|
17
|
+
const nextSelectedElements: any[] = [];
|
18
|
+
|
19
|
+
// Process each currently selected element
|
20
|
+
for (const element of selectedElements) {
|
21
|
+
// String: select the element with the key in the currently selected elements
|
22
|
+
if (typeof component === 'string') {
|
23
|
+
if (
|
24
|
+
element === null ||
|
25
|
+
typeof element !== 'object' ||
|
26
|
+
Array.isArray(element)
|
27
|
+
) {
|
28
|
+
// According to spec: "If any of the currently selected element(s) is not an object,
|
29
|
+
// abort processing and return an error."
|
30
|
+
throw new Error(
|
31
|
+
'Path component requires object but found non-object element',
|
32
|
+
);
|
33
|
+
}
|
34
|
+
|
35
|
+
if (component in element) {
|
36
|
+
nextSelectedElements.push(element[component]);
|
37
|
+
}
|
38
|
+
}
|
39
|
+
// null: select all elements of the currently selected arrays
|
40
|
+
else if (component === null) {
|
41
|
+
if (element === null || !Array.isArray(element)) {
|
42
|
+
// According to spec: "If any of the currently selected element(s) is not an array,
|
43
|
+
// abort processing and return an error."
|
44
|
+
throw new Error(
|
45
|
+
'Null path component requires array but found non-array element',
|
46
|
+
);
|
47
|
+
}
|
48
|
+
|
49
|
+
nextSelectedElements.push(...element);
|
50
|
+
}
|
51
|
+
// number: select the element at the index in the currently selected arrays
|
52
|
+
else if (
|
53
|
+
typeof component === 'number' &&
|
54
|
+
component >= 0 &&
|
55
|
+
Number.isInteger(component)
|
56
|
+
) {
|
57
|
+
if (element === null || !Array.isArray(element)) {
|
58
|
+
// According to spec: "If any of the currently selected element(s) is not an array,
|
59
|
+
// abort processing and return an error."
|
60
|
+
throw new Error(
|
61
|
+
'Numeric path component requires array but found non-array element',
|
62
|
+
);
|
63
|
+
}
|
64
|
+
// If the index does not exist in a selected array, remove that array from the selection
|
65
|
+
if (component < element.length) {
|
66
|
+
nextSelectedElements.push(element[component]);
|
67
|
+
}
|
68
|
+
}
|
69
|
+
// Invalid component type
|
70
|
+
else {
|
71
|
+
// According to spec: "If the component is anything else, abort processing and return an error."
|
72
|
+
throw new Error(`Invalid path component: ${component}`);
|
73
|
+
}
|
74
|
+
}
|
75
|
+
|
76
|
+
// If no elements were selected, abort processing
|
77
|
+
if (nextSelectedElements.length === 0) {
|
78
|
+
// According to spec: "If the set of elements currently selected is empty,
|
79
|
+
// abort processing and return an error."
|
80
|
+
throw new Error('No elements selected after processing path component');
|
81
|
+
}
|
82
|
+
|
83
|
+
// Update the selected elements for the next iteration
|
84
|
+
selectedElements = nextSelectedElements;
|
85
|
+
}
|
86
|
+
|
87
|
+
return selectedElements;
|
88
|
+
};
|
@@ -0,0 +1,295 @@
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
2
|
+
import { DCQL } from '../dcql';
|
3
|
+
import { SdJwtVcCredential } from '../credentials/sdjwtvc.credential';
|
4
|
+
import { Claims, CredentialSet, rawDCQL } from '../type';
|
5
|
+
|
6
|
+
describe('DCQL', () => {
|
7
|
+
it('should create an instance', () => {
|
8
|
+
const dcql = new DCQL({});
|
9
|
+
expect(dcql).toBeDefined();
|
10
|
+
});
|
11
|
+
|
12
|
+
describe('addCredential', () => {
|
13
|
+
it('should add a credential', () => {
|
14
|
+
const dcql = new DCQL({});
|
15
|
+
const credential = new SdJwtVcCredential('test-id', 'test-vct');
|
16
|
+
|
17
|
+
const result = dcql.addCredential(credential);
|
18
|
+
|
19
|
+
// Should return this for chaining
|
20
|
+
expect(result).toBe(dcql);
|
21
|
+
|
22
|
+
// Verify it was added by checking serialized output
|
23
|
+
const serialized = dcql.serialize();
|
24
|
+
expect(serialized.credentials).toHaveLength(1);
|
25
|
+
expect(serialized.credentials[0].id).toBe('test-id');
|
26
|
+
});
|
27
|
+
|
28
|
+
it('should add multiple credentials', () => {
|
29
|
+
const dcql = new DCQL({});
|
30
|
+
const credential1 = new SdJwtVcCredential('test-id-1', 'test-vct-1');
|
31
|
+
const credential2 = new SdJwtVcCredential('test-id-2', 'test-vct-2');
|
32
|
+
|
33
|
+
dcql.addCredential(credential1).addCredential(credential2);
|
34
|
+
|
35
|
+
const serialized = dcql.serialize();
|
36
|
+
expect(serialized.credentials).toHaveLength(2);
|
37
|
+
expect(serialized.credentials[0].id).toBe('test-id-1');
|
38
|
+
expect(serialized.credentials[1].id).toBe('test-id-2');
|
39
|
+
});
|
40
|
+
});
|
41
|
+
|
42
|
+
describe('addCredentialSet', () => {
|
43
|
+
it('should add a credential set', () => {
|
44
|
+
const dcql = new DCQL({});
|
45
|
+
const credentialSet: CredentialSet = {
|
46
|
+
options: [['test-id-1'], ['test-id-2']],
|
47
|
+
required: true,
|
48
|
+
};
|
49
|
+
|
50
|
+
const result = dcql.addCredentialSet(credentialSet);
|
51
|
+
|
52
|
+
// Should return this for chaining
|
53
|
+
expect(result).toBe(dcql);
|
54
|
+
|
55
|
+
// Verify it was added by checking serialized output
|
56
|
+
const serialized = dcql.serialize();
|
57
|
+
expect(serialized.credential_sets).toHaveLength(1);
|
58
|
+
expect(serialized.credential_sets?.[0].options).toHaveLength(2);
|
59
|
+
expect(serialized.credential_sets?.[0].required).toBe(true);
|
60
|
+
});
|
61
|
+
|
62
|
+
it('should initialize credential_sets array if undefined', () => {
|
63
|
+
const dcql = new DCQL({});
|
64
|
+
const credentialSet: CredentialSet = {
|
65
|
+
options: [['test-id-1']],
|
66
|
+
};
|
67
|
+
|
68
|
+
dcql.addCredentialSet(credentialSet);
|
69
|
+
|
70
|
+
const serialized = dcql.serialize();
|
71
|
+
expect(serialized.credential_sets).toBeDefined();
|
72
|
+
expect(serialized.credential_sets).toHaveLength(1);
|
73
|
+
});
|
74
|
+
});
|
75
|
+
|
76
|
+
describe('parse', () => {
|
77
|
+
it('should parse valid raw DCQL with sd-jwt-vc credential', () => {
|
78
|
+
const rawDcql: rawDCQL = {
|
79
|
+
credentials: [
|
80
|
+
{
|
81
|
+
id: 'test-id',
|
82
|
+
format: 'dc+sd-jwt',
|
83
|
+
meta: { vct_values: ['test-vct'] },
|
84
|
+
multiple: true,
|
85
|
+
trusted_authorities: [{ type: 'aki', value: ['test-authority'] }],
|
86
|
+
require_cryptographic_holder_binding: true,
|
87
|
+
claims: [{ path: ['$.vc.credentialSubject.firstName'] }],
|
88
|
+
},
|
89
|
+
],
|
90
|
+
};
|
91
|
+
|
92
|
+
const dcql = DCQL.parse(rawDcql);
|
93
|
+
const serialized = dcql.serialize();
|
94
|
+
|
95
|
+
expect(serialized.credentials).toHaveLength(1);
|
96
|
+
expect(serialized.credentials[0]).toEqual(rawDcql.credentials[0]);
|
97
|
+
});
|
98
|
+
|
99
|
+
it('should parse DCQL with credential sets', () => {
|
100
|
+
const rawDcql: rawDCQL = {
|
101
|
+
credentials: [
|
102
|
+
{
|
103
|
+
id: '0',
|
104
|
+
format: 'dc+sd-jwt',
|
105
|
+
meta: {
|
106
|
+
vct_values: [
|
107
|
+
'eu.europa.ec.eudi.pid.1',
|
108
|
+
'urn:eu.europa.ec.eudi:pid:1',
|
109
|
+
],
|
110
|
+
},
|
111
|
+
claims: [
|
112
|
+
{
|
113
|
+
path: ['family_name'],
|
114
|
+
id: 'family_name',
|
115
|
+
},
|
116
|
+
{
|
117
|
+
path: ['given_name'],
|
118
|
+
id: 'given_name',
|
119
|
+
},
|
120
|
+
],
|
121
|
+
},
|
122
|
+
],
|
123
|
+
credential_sets: [
|
124
|
+
{
|
125
|
+
options: [['0']],
|
126
|
+
purpose: 'PID (sd-jwt-vc) - first_name and given_name',
|
127
|
+
},
|
128
|
+
],
|
129
|
+
};
|
130
|
+
|
131
|
+
const dcql = DCQL.parse(rawDcql);
|
132
|
+
const serialized = dcql.serialize();
|
133
|
+
|
134
|
+
expect(serialized.credentials).toHaveLength(1);
|
135
|
+
expect(serialized.credential_sets).toHaveLength(1);
|
136
|
+
expect(serialized.credential_sets![0].options).toEqual([['0']]);
|
137
|
+
});
|
138
|
+
});
|
139
|
+
|
140
|
+
describe('serialize', () => {
|
141
|
+
it('should serialize empty DCQL', () => {
|
142
|
+
const dcql = new DCQL({});
|
143
|
+
const serialized = dcql.serialize();
|
144
|
+
|
145
|
+
expect(serialized).toEqual({
|
146
|
+
credentials: [],
|
147
|
+
credential_sets: undefined,
|
148
|
+
});
|
149
|
+
});
|
150
|
+
|
151
|
+
it('should serialize DCQL with credentials and credential sets', () => {
|
152
|
+
const dcql = new DCQL({});
|
153
|
+
const credential = new SdJwtVcCredential('test-id', ['test-vct']);
|
154
|
+
const credentialSet: CredentialSet = {
|
155
|
+
options: [['test-id']],
|
156
|
+
required: false,
|
157
|
+
};
|
158
|
+
|
159
|
+
dcql.addCredential(credential).addCredentialSet(credentialSet);
|
160
|
+
|
161
|
+
const serialized = dcql.serialize();
|
162
|
+
expect(serialized).toEqual({
|
163
|
+
credentials: [credential.serialize()],
|
164
|
+
credential_sets: [credentialSet],
|
165
|
+
});
|
166
|
+
});
|
167
|
+
});
|
168
|
+
|
169
|
+
describe('match', () => {
|
170
|
+
it('empty data', () => {
|
171
|
+
const rawDcql: rawDCQL = {
|
172
|
+
credentials: [
|
173
|
+
{
|
174
|
+
id: 'cred-1',
|
175
|
+
format: 'dc+sd-jwt',
|
176
|
+
meta: { vct_values: ['vct-1'] },
|
177
|
+
},
|
178
|
+
{
|
179
|
+
id: 'cred-2',
|
180
|
+
format: 'dc+sd-jwt',
|
181
|
+
meta: { vct_values: ['vct-2'] },
|
182
|
+
},
|
183
|
+
],
|
184
|
+
credential_sets: [
|
185
|
+
{
|
186
|
+
options: [['cred-1'], ['cred-2']],
|
187
|
+
required: true,
|
188
|
+
},
|
189
|
+
],
|
190
|
+
};
|
191
|
+
const dcql = DCQL.parse(rawDcql);
|
192
|
+
const result = dcql.match([]);
|
193
|
+
expect(result).toEqual({ match: false });
|
194
|
+
});
|
195
|
+
|
196
|
+
it('test 1', () => {
|
197
|
+
const rawDcql: rawDCQL = {
|
198
|
+
credentials: [
|
199
|
+
{
|
200
|
+
id: 'cred-1',
|
201
|
+
format: 'dc+sd-jwt',
|
202
|
+
meta: { vct_values: ['vct-1'] },
|
203
|
+
},
|
204
|
+
{
|
205
|
+
id: 'cred-2',
|
206
|
+
format: 'dc+sd-jwt',
|
207
|
+
meta: { vct_values: ['vct-2'] },
|
208
|
+
},
|
209
|
+
],
|
210
|
+
credential_sets: [
|
211
|
+
{
|
212
|
+
options: [['cred-1'], ['cred-2']],
|
213
|
+
required: true,
|
214
|
+
},
|
215
|
+
],
|
216
|
+
};
|
217
|
+
const dcql = DCQL.parse(rawDcql);
|
218
|
+
const result = dcql.match([{ vct: 'vct-1', name: 'name-1' }]);
|
219
|
+
expect(result).toEqual({
|
220
|
+
match: true,
|
221
|
+
matchedCredentials: [
|
222
|
+
{
|
223
|
+
credential: { vct: 'vct-1', name: 'name-1' },
|
224
|
+
matchedClaims: [],
|
225
|
+
dataIndex: 0,
|
226
|
+
},
|
227
|
+
],
|
228
|
+
});
|
229
|
+
});
|
230
|
+
|
231
|
+
it('Should match with animo credential', () => {
|
232
|
+
const dataRecords = {
|
233
|
+
vct: 'eu.europa.ec.eudi.pid.1',
|
234
|
+
given_name: 'Erika',
|
235
|
+
family_name: 'Mustermann',
|
236
|
+
};
|
237
|
+
|
238
|
+
const rawDcql: rawDCQL = {
|
239
|
+
credentials: [
|
240
|
+
{
|
241
|
+
id: '0',
|
242
|
+
format: 'dc+sd-jwt',
|
243
|
+
meta: {
|
244
|
+
vct_values: [
|
245
|
+
'eu.europa.ec.eudi.pid.1',
|
246
|
+
'urn:eu.europa.ec.eudi:pid:1',
|
247
|
+
],
|
248
|
+
},
|
249
|
+
claims: [
|
250
|
+
{
|
251
|
+
path: ['family_name'],
|
252
|
+
id: 'family_name',
|
253
|
+
},
|
254
|
+
{
|
255
|
+
path: ['given_name'],
|
256
|
+
id: 'given_name',
|
257
|
+
},
|
258
|
+
],
|
259
|
+
},
|
260
|
+
],
|
261
|
+
credential_sets: [
|
262
|
+
{
|
263
|
+
options: [['0']],
|
264
|
+
purpose: 'PID (sd-jwt-vc) - first_name and given_name',
|
265
|
+
},
|
266
|
+
],
|
267
|
+
};
|
268
|
+
const dcql = DCQL.parse(rawDcql);
|
269
|
+
const result = dcql.match([dataRecords]);
|
270
|
+
expect(result).toEqual({
|
271
|
+
match: true,
|
272
|
+
matchedCredentials: [
|
273
|
+
{
|
274
|
+
credential: {
|
275
|
+
vct: 'eu.europa.ec.eudi.pid.1',
|
276
|
+
family_name: 'Mustermann',
|
277
|
+
given_name: 'Erika',
|
278
|
+
},
|
279
|
+
matchedClaims: [
|
280
|
+
{
|
281
|
+
id: 'family_name',
|
282
|
+
path: ['family_name'],
|
283
|
+
},
|
284
|
+
{
|
285
|
+
id: 'given_name',
|
286
|
+
path: ['given_name'],
|
287
|
+
},
|
288
|
+
],
|
289
|
+
dataIndex: 0,
|
290
|
+
},
|
291
|
+
],
|
292
|
+
});
|
293
|
+
});
|
294
|
+
});
|
295
|
+
});
|