@peng_kai/kit 0.2.0-beta.24 → 0.2.0-beta.26

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.
@@ -0,0 +1,329 @@
1
+ <script lang="ts">
2
+ import { reactiveComputed } from '@vueuse/core';
3
+ import { cloneDeep, mapKeys, mapValues } from 'lodash-es';
4
+ import dayjs from 'dayjs';
5
+ import { Button, Card, CheckboxGroup, DatePicker, type DatePickerProps, Form, FormItem, Input, InputNumber, RadioGroup, RangePicker, Select, Textarea } from 'ant-design-vue';
6
+ import { computed, ref, toRef } from 'vue';
7
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
8
+ import { type ItemSchema, useAntdForm } from '../../../../antd';
9
+
10
+ interface IConfigDetail {
11
+ category_id: number
12
+ data_type: string
13
+ form_type: number
14
+ key: string
15
+ label: string
16
+ options: any
17
+ required: number
18
+ sort: number
19
+ status: number
20
+ summary: string
21
+ value: any
22
+ }
23
+
24
+ enum FormTypes {
25
+ /** 数字输入框 */
26
+ NUMBER_INPUT = 0,
27
+ /** 文本输入框 */
28
+ TEXT_INPUT = 1,
29
+ /** 多行文本输入框 */
30
+ TEXTAREA = 2,
31
+ /** 开关 */
32
+ SWITCH = 3,
33
+ /** 单选框 */
34
+ RADIO = 4,
35
+ /** 多选框 */
36
+ CHECKBOX = 5,
37
+ /** 单选下拉选择器 */
38
+ SINGLE_SELECT = 6,
39
+ /** 多选下拉选择器 */
40
+ MULTIPLE_SELECT = 7,
41
+ /** 单一日期选择器 */
42
+ SINGLE_DATE_PICKER = 13,
43
+ /** 日期范围选择器 */
44
+ RANGE_DATE_PICKER = 14,
45
+ }
46
+
47
+ const antdPropsResolvers: Record<number, (config: IConfigDetail) => IConfigDetail> = {
48
+ [FormTypes.SWITCH](config) {
49
+ const props: any = {};
50
+
51
+ if (config.options) {
52
+ props.options = config.options.split(',').map((item: any) => {
53
+ const [label, value] = item.split(':');
54
+ return { value, label };
55
+ });
56
+ }
57
+ else {
58
+ props.options = [{ value: '1', label: '是' }, { value: '0', label: '否' }];
59
+ }
60
+
61
+ config.options = props;
62
+
63
+ return config;
64
+ },
65
+ [FormTypes.RADIO](config) {
66
+ return this[FormTypes.SWITCH](config);
67
+ },
68
+ [FormTypes.CHECKBOX](config) {
69
+ const props: any = {};
70
+ props.options = config.options.split(',').map((item: any) => {
71
+ const [label, value] = item.split(':');
72
+ return { value, label };
73
+ });
74
+
75
+ config.value = config.value.split(',');
76
+ config.options = props;
77
+
78
+ return config;
79
+ },
80
+ [FormTypes.SINGLE_SELECT](config) {
81
+ return this[FormTypes.SWITCH](config);
82
+ },
83
+ [FormTypes.MULTIPLE_SELECT](config) {
84
+ return this[FormTypes.CHECKBOX](config);
85
+ },
86
+ [FormTypes.SINGLE_DATE_PICKER](config) {
87
+ const props: DatePickerProps = {};
88
+
89
+ if (config.options) {
90
+ const [minDate, maxDate] = (config.options || '/').split('/').map(dayjs);
91
+ props.disabledDate = (current) => {
92
+ return current.isBefore(minDate) || current.isAfter(maxDate);
93
+ };
94
+ }
95
+
96
+ config.value = config.value === '' ? undefined : dayjs(config.value);
97
+ config.options = props;
98
+
99
+ return config;
100
+ },
101
+ [FormTypes.RANGE_DATE_PICKER](config) {
102
+ const props: DatePickerProps = {};
103
+
104
+ if (config.options) {
105
+ const [minDate, maxDate] = (config.options || '/').split('/').map(dayjs);
106
+ props.disabledDate = (current) => {
107
+ return current.isBefore(minDate) || current.isAfter(maxDate);
108
+ };
109
+ }
110
+
111
+ if (config.value === '')
112
+ config.value = [undefined, undefined];
113
+ else
114
+ config.value = (config.value || '/').split('/').map(dayjs);
115
+
116
+ config.options = props;
117
+
118
+ return config;
119
+ },
120
+ };
121
+ const antdValueResolvers: Record<number, (value: any) => any> = {
122
+ [FormTypes.CHECKBOX](value) {
123
+ if (Array.isArray(value))
124
+ return value.join(',');
125
+
126
+ return value;
127
+ },
128
+ [FormTypes.MULTIPLE_SELECT](value) {
129
+ if (Array.isArray(value))
130
+ return value.join(',');
131
+
132
+ return value;
133
+ },
134
+ [FormTypes.SINGLE_DATE_PICKER](value) {
135
+ if (dayjs.isDayjs(value))
136
+ return value.format('YYYY-MM-DD');
137
+
138
+ return value;
139
+ },
140
+ [FormTypes.RANGE_DATE_PICKER](value) {
141
+ if (dayjs.isDayjs(value[0]) && dayjs.isDayjs(value[1]))
142
+ return [value[0].format('YYYY-MM-DD'), value[1].format('YYYY-MM-DD')].join('/');
143
+
144
+ return value;
145
+ },
146
+ };
147
+ </script>
148
+
149
+ <script setup lang="ts">
150
+ const props = defineProps<{
151
+ categoryApi: Api.Request
152
+ configApi: Api.Request
153
+ updateApi: Api.Request
154
+ }>();
155
+
156
+ const queryClient = useQueryClient();
157
+
158
+ /* 配置分类 */
159
+ const categoryId = ref(1);
160
+ const categoryQuerier = useQuery({
161
+ queryKey: [props.categoryApi.id],
162
+ queryFn: () => props.categoryApi(undefined),
163
+ });
164
+ const categoryList = computed(() => categoryQuerier.data.value?.map((item: any) => ({ key: String(item.type), tab: item.label })));
165
+
166
+ /* 当前分类下的配置 */
167
+ const configQuerier = useQuery<IConfigDetail[] | undefined>({
168
+ enabled: computed(() => !!categoryQuerier.data.value),
169
+ queryKey: [props.configApi.id, categoryId],
170
+ queryFn: () => props.configApi({ category_id: categoryId.value }),
171
+ });
172
+ const configList = computed(() => {
173
+ const list = cloneDeep(configQuerier.data.value);
174
+
175
+ if (!list?.length)
176
+ return;
177
+
178
+ for (let i = 0; i < list.length; i++) {
179
+ const config = { ...list[i] };
180
+ list[i] = antdPropsResolvers[config.form_type]?.(config) ?? config;
181
+ }
182
+
183
+ return list;
184
+ });
185
+ const configMap = computed(() => mapKeys(cloneDeep(configQuerier.data.value ?? []), 'key'));
186
+
187
+ /* 配置表单 */
188
+ const settingMutator = useMutation({
189
+ mutationKey: [props.updateApi.id],
190
+ mutationFn: props.updateApi.setDefaultConfig({ successMessage: '更新成功' }),
191
+ onSuccess() {
192
+ configQuerier.refetch();
193
+ queryClient.invalidateQueries({ queryKey: [props.configApi.id], exact: false });
194
+ },
195
+ });
196
+ const formSchema = reactiveComputed(() => {
197
+ const _configList = configList.value ?? [];
198
+ const entries = _configList.map(config => [
199
+ config.key,
200
+ {
201
+ value: cloneDeep(config.value),
202
+ rules: config.required ? [{ required: true }] : undefined,
203
+ },
204
+ ] as [string, ItemSchema]);
205
+ const schema = Object.fromEntries(entries);
206
+
207
+ return schema;
208
+ });
209
+ const settingForm = useAntdForm(formSchema, {
210
+ transform(state) {
211
+ return mapValues(state, (v, k) => {
212
+ const formType = configMap.value[k].form_type;
213
+ const resolver = antdValueResolvers[formType];
214
+ return resolver ? resolver(v) : v;
215
+ });
216
+ },
217
+ });
218
+
219
+ /** 提交表单内容 */
220
+ async function submitSetting() {
221
+ const body = await settingForm.$form.validate?.().catch(() => {});
222
+
223
+ if (body)
224
+ await settingMutator.mutateAsync({ requestBody: body });
225
+ }
226
+
227
+ /** 重置表单内容 */
228
+ function resetSetting() {
229
+ configList.value?.forEach(config => settingForm.state[config.key] = cloneDeep(config.value));
230
+ settingForm.$form.clearValidate?.();
231
+ }
232
+ </script>
233
+
234
+ <template>
235
+ <Card
236
+ class="antd-cover__actions-right-align"
237
+ :activeTabKey="String(categoryId)"
238
+ :tabList="categoryList"
239
+ :loading="configQuerier.isPending.value"
240
+ @tabChange="key => categoryId = Number(key)"
241
+ >
242
+ <Form
243
+ v-bind="settingForm.props"
244
+ class="ant-cover__col2-form"
245
+ layout="vertical"
246
+ :disabled="settingMutator.isPending.value"
247
+ >
248
+ <template v-for="item of configList" :key="item.key">
249
+ <FormItem v-bind="settingForm.itemProps[item.key]" :label="item.label" :extra="item.summary">
250
+ <slot
251
+ v-if="$slots[item.key]"
252
+ :name="item.key"
253
+ :state="toRef(settingForm.state, item.key)"
254
+ :config="item"
255
+ :orginConfig="configMap[item.key]"
256
+ />
257
+ <template v-else>
258
+ <slot v-if="item.form_type === FormTypes.NUMBER_INPUT" :name="FormTypes.NUMBER_INPUT">
259
+ <InputNumber v-model:value="settingForm.state[item.key]" class="w-full" />
260
+ </slot>
261
+
262
+ <slot v-else-if="item.form_type === FormTypes.TEXT_INPUT" :name="FormTypes.TEXT_INPUT">
263
+ <Input v-model:value="settingForm.state[item.key]" allowClear />
264
+ </slot>
265
+
266
+ <slot v-else-if="item.form_type === FormTypes.TEXTAREA" :name="FormTypes.TEXTAREA">
267
+ <Textarea v-model:value="settingForm.state[item.key]" :rows="4" />
268
+ </slot>
269
+
270
+ <slot v-else-if="item.form_type === FormTypes.SWITCH" :name="FormTypes.SWITCH">
271
+ <RadioGroup
272
+ v-model:value="settingForm.state[item.key]"
273
+ v-bind="item.options"
274
+ buttonStyle="solid"
275
+ optionType="button"
276
+ />
277
+ </slot>
278
+
279
+ <slot v-else-if="item.form_type === FormTypes.RADIO" :name="FormTypes.RADIO">
280
+ <RadioGroup
281
+ v-model:value="settingForm.state[item.key]"
282
+ buttonStyle="solid"
283
+ v-bind="item.options"
284
+ />
285
+ </slot>
286
+
287
+ <slot v-else-if="item.form_type === FormTypes.CHECKBOX" :name="FormTypes.CHECKBOX">
288
+ <CheckboxGroup v-model:value="settingForm.state[item.key]" v-bind="item.options" />
289
+ </slot>
290
+
291
+ <slot v-else-if="item.form_type === FormTypes.SINGLE_SELECT" :name="FormTypes.SINGLE_SELECT">
292
+ <Select v-model:value="settingForm.state[item.key]" v-bind="item.options" />
293
+ </slot>
294
+
295
+ <slot v-else-if="item.form_type === FormTypes.MULTIPLE_SELECT" :name="FormTypes.MULTIPLE_SELECT">
296
+ <Select v-model:value="settingForm.state[item.key]" v-bind="item.options" mode="multiple" />
297
+ </slot>
298
+
299
+ <slot v-else-if="item.form_type === FormTypes.SINGLE_DATE_PICKER" :name="FormTypes.SINGLE_DATE_PICKER">
300
+ <DatePicker v-model:value="settingForm.state[item.key]" v-bind="item.options" />
301
+ </slot>
302
+
303
+ <slot v-else-if="item.form_type === FormTypes.RANGE_DATE_PICKER" :name="FormTypes.RANGE_DATE_PICKER">
304
+ <RangePicker v-model:value="settingForm.state[item.key]" v-bind="item.options" />
305
+ </slot>
306
+
307
+ <span v-else class="text-red">没有找到预设 form_type:{{ item.form_type }},可以通过 {{ item.key }} 插槽自定义</span>
308
+ </template>
309
+ </FormItem>
310
+ </template>
311
+ </Form>
312
+
313
+ <template #actions>
314
+ <Button :disabled="settingMutator.isPending.value" @click="resetSetting()">
315
+ 重置
316
+ </Button>
317
+ <Button type="primary" :loading="settingMutator.isPending.value" @click="submitSetting()">
318
+ 提交
319
+ </Button>
320
+ </template>
321
+ </Card>
322
+ </template>
323
+
324
+ <style lang="scss" scoped>
325
+ .ant-cover__col2-form :deep(.ant-form-item-label > label) {
326
+ // padding-left: 0.5em;
327
+ // border-left: 2px solid #2361d0;
328
+ }
329
+ </style>
@@ -1,13 +1,13 @@
1
- import Hash from './src/Hash.vue';
2
- import Amount from './src/Amount.vue';
3
- import Datetime from './src/Datetime.vue';
4
- import Duration from './src/Duration.vue';
5
-
6
- export { createTagGetter } from './src/createTagGetter';
7
-
8
- export const Text = {
9
- Hash,
10
- Amount,
11
- Datetime,
12
- Duration,
13
- };
1
+ import Hash from './src/Hash.vue';
2
+ import Amount from './src/Amount.vue';
3
+ import Datetime from './src/Datetime.vue';
4
+ import Duration from './src/Duration.vue';
5
+
6
+ export { createTagGetter } from './src/createTagGetter';
7
+
8
+ export const Text = {
9
+ Hash,
10
+ Amount,
11
+ Datetime,
12
+ Duration,
13
+ };
@@ -1,121 +1,121 @@
1
- <script lang="ts">
2
- import { computed } from 'vue';
3
- import bigNumber from 'bignumber.js';
4
- import isNil from 'lodash-es/isNil';
5
-
6
- export const config = {
7
- aboveColor: '#52c41a',
8
- belowColor: '#ff4d4f',
9
- };
10
-
11
- /**
12
- * 当 symbol 为以下值时,使用预设的 Logo
13
- */
14
- const presetSymbols: Record<string, string> = {
15
- USDT: 'https://api.iconify.design/cryptocurrency-color:usdt.svg',
16
- TRX: 'https://api.iconify.design/cryptocurrency-color:trx.svg',
17
- USDC: 'https://api.iconify.design/cryptocurrency-color:usdc.svg',
18
- ETH: 'https://api.iconify.design/cryptocurrency-color:eth.svg',
19
- BNB: 'https://api.iconify.design/cryptocurrency-color:bnb.svg',
20
- BUSD: 'https://assets.coingecko.com/coins/images/9576/large/BUSD.png',
21
- MATIC: 'https://api.iconify.design/cryptocurrency-color:matic.svg',
22
- SOL: 'https://api.iconify.design/cryptocurrency-color:sol.svg',
23
- };
24
- </script>
25
-
26
- <script setup lang="ts">
27
- const props = withDefaults(
28
- defineProps<{
29
- /** 金额 */
30
- amount?: string | number
31
- /** 符号Logo, 可以是币种Logo的URL、法定货币符号($) */
32
- symbol?: string
33
- /** 精度 */
34
- precision?: number
35
- /** 单位,如币种名称 */
36
- unit?: string
37
- /** 保留小数的位数 */
38
- fractionDigits?: number
39
- /** 是否填充 0 */
40
- padZero?: boolean
41
- /** 金额是否红/绿显示 */
42
- colorful?: boolean
43
- /** 是否是大约数 */
44
- approx?: boolean
45
- }>(),
46
- {
47
- padZero: false,
48
- fractionDigits: 18,
49
- colorful: false,
50
- },
51
- );
52
-
53
- const amountText = computed(() => {
54
- const _amount = props.amount;
55
-
56
- if (isNil(_amount))
57
- return '-';
58
-
59
- let bn = bigNumber(_amount);
60
- bn = !isNil(props.precision) ? bn.dividedBy(10 ** props.precision) : bn;
61
- let bnt = bn.toFormat(props.fractionDigits);
62
- bnt = props.padZero ? bnt : bnt.replace(/\.?0+$/, '');
63
-
64
- return bnt;
65
- });
66
- const amountColor = computed(() => {
67
- const num = Number.parseFloat(props.amount as string);
68
-
69
- if (!props.colorful || (Number.isNaN(num) ? true : num === 0))
70
- return '';
71
-
72
- return num > 0 ? config.aboveColor : (num < 0 ? config.belowColor : '');
73
- });
74
- const symbol = computed(() => presetSymbols[props.symbol!] ?? props.symbol ?? '');
75
- </script>
76
-
77
- <template>
78
- <div class="amount-wrapper" :style="{ '--amount-color': amountColor }">
79
- <!-- 约等于 -->
80
- <span v-if="props.approx" class="color-$amount-color">≈</span>
81
-
82
- <!-- 符号 -->
83
- <img v-if="symbol.startsWith('http')" class="symbol-logo" :src="symbol">
84
-
85
- <!-- 图片Logo -->
86
- <span v-else class="color-$amount-color">{{ symbol }}</span> <!-- 文本Logo,如法定币种符号(¥、$) -->
87
-
88
- <!-- 金额 -->
89
- <span class="color-$amount-color amount">{{ amountText }}</span>
90
-
91
- <!-- 单位 -->
92
- <span v-if="props.unit" class="unit">{{ props.unit }}</span>
93
- </div>
94
- </template>
95
-
96
- <style lang="scss">
97
- .amount-wrapper {
98
- display: flex;
99
- align-items: center;
100
-
101
- .symbol-logo {
102
- display: block;
103
- width: 1.1em;
104
- height: 1.1em;
105
- margin-right: 0.2em;
106
- }
107
-
108
- .amount {
109
- font-family: 'dinm';
110
- }
111
-
112
- .currency-name {
113
- margin-left: 0.2em;
114
- }
115
-
116
- .unit {
117
- display: inline-block;
118
- margin-left: 0.2em;
119
- }
120
- }
121
- </style>
1
+ <script lang="ts">
2
+ import { computed } from 'vue';
3
+ import bigNumber from 'bignumber.js';
4
+ import isNil from 'lodash-es/isNil';
5
+
6
+ export const config = {
7
+ aboveColor: '#52c41a',
8
+ belowColor: '#ff4d4f',
9
+ };
10
+
11
+ /**
12
+ * 当 symbol 为以下值时,使用预设的 Logo
13
+ */
14
+ const presetSymbols: Record<string, string> = {
15
+ USDT: 'https://api.iconify.design/cryptocurrency-color:usdt.svg',
16
+ TRX: 'https://api.iconify.design/cryptocurrency-color:trx.svg',
17
+ USDC: 'https://api.iconify.design/cryptocurrency-color:usdc.svg',
18
+ ETH: 'https://api.iconify.design/cryptocurrency-color:eth.svg',
19
+ BNB: 'https://api.iconify.design/cryptocurrency-color:bnb.svg',
20
+ BUSD: 'https://assets.coingecko.com/coins/images/9576/large/BUSD.png',
21
+ MATIC: 'https://api.iconify.design/cryptocurrency-color:matic.svg',
22
+ SOL: 'https://api.iconify.design/cryptocurrency-color:sol.svg',
23
+ };
24
+ </script>
25
+
26
+ <script setup lang="ts">
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ /** 金额 */
30
+ amount?: string | number
31
+ /** 符号Logo, 可以是币种Logo的URL、法定货币符号($) */
32
+ symbol?: string
33
+ /** 精度 */
34
+ precision?: number
35
+ /** 单位,如币种名称 */
36
+ unit?: string
37
+ /** 保留小数的位数 */
38
+ fractionDigits?: number
39
+ /** 是否填充 0 */
40
+ padZero?: boolean
41
+ /** 金额是否红/绿显示 */
42
+ colorful?: boolean
43
+ /** 是否是大约数 */
44
+ approx?: boolean
45
+ }>(),
46
+ {
47
+ padZero: false,
48
+ fractionDigits: 18,
49
+ colorful: false,
50
+ },
51
+ );
52
+
53
+ const amountText = computed(() => {
54
+ const _amount = props.amount;
55
+
56
+ if (isNil(_amount))
57
+ return '-';
58
+
59
+ let bn = bigNumber(_amount);
60
+ bn = !isNil(props.precision) ? bn.dividedBy(10 ** props.precision) : bn;
61
+ let bnt = bn.toFormat(props.fractionDigits);
62
+ bnt = props.padZero ? bnt : bnt.replace(/\.?0+$/, '');
63
+
64
+ return bnt;
65
+ });
66
+ const amountColor = computed(() => {
67
+ const num = Number.parseFloat(props.amount as string);
68
+
69
+ if (!props.colorful || (Number.isNaN(num) ? true : num === 0))
70
+ return '';
71
+
72
+ return num > 0 ? config.aboveColor : (num < 0 ? config.belowColor : '');
73
+ });
74
+ const symbol = computed(() => presetSymbols[props.symbol!] ?? props.symbol ?? '');
75
+ </script>
76
+
77
+ <template>
78
+ <div class="amount-wrapper" :style="{ '--amount-color': amountColor }">
79
+ <!-- 约等于 -->
80
+ <span v-if="props.approx" class="color-$amount-color">≈</span>
81
+
82
+ <!-- 符号 -->
83
+ <img v-if="symbol.startsWith('http')" class="symbol-logo" :src="symbol">
84
+
85
+ <!-- 图片Logo -->
86
+ <span v-else class="color-$amount-color">{{ symbol }}</span> <!-- 文本Logo,如法定币种符号(¥、$) -->
87
+
88
+ <!-- 金额 -->
89
+ <span class="color-$amount-color amount">{{ amountText }}</span>
90
+
91
+ <!-- 单位 -->
92
+ <span v-if="props.unit" class="unit">{{ props.unit }}</span>
93
+ </div>
94
+ </template>
95
+
96
+ <style lang="scss">
97
+ .amount-wrapper {
98
+ display: flex;
99
+ align-items: center;
100
+
101
+ .symbol-logo {
102
+ display: block;
103
+ width: 1.1em;
104
+ height: 1.1em;
105
+ margin-right: 0.2em;
106
+ }
107
+
108
+ .amount {
109
+ font-family: 'dinm';
110
+ }
111
+
112
+ .currency-name {
113
+ margin-left: 0.2em;
114
+ }
115
+
116
+ .unit {
117
+ display: inline-block;
118
+ margin-left: 0.2em;
119
+ }
120
+ }
121
+ </style>
@@ -1,26 +1,26 @@
1
- <script setup lang="ts">
2
- import { computed } from 'vue';
3
-
4
- const props = defineProps<{
5
- seconds: number
6
- }>();
7
-
8
- const formattedDuration = computed(() => {
9
- const days = Math.floor(props.seconds / (3600 * 24));
10
- const hours = Math.floor((props.seconds % (3600 * 24)) / 3600);
11
- const minutes = Math.floor((props.seconds % 3600) / 60);
12
- const seconds = props.seconds % 60;
13
- let formattedDuration = '';
14
-
15
- days >= 1 && (formattedDuration += `${Math.floor(days)}天 `);
16
- hours >= 1 && (formattedDuration += `${Math.floor(hours)}小时 `);
17
- minutes >= 1 && (formattedDuration += `${Math.floor(minutes)}分钟 `);
18
- seconds >= 1 && (formattedDuration += `${Math.floor(seconds)}秒`);
19
-
20
- return formattedDuration;
21
- });
22
- </script>
23
-
24
- <template>
25
- <span>{{ formattedDuration }}</span>
26
- </template>
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+
4
+ const props = defineProps<{
5
+ seconds: number
6
+ }>();
7
+
8
+ const formattedDuration = computed(() => {
9
+ const days = Math.floor(props.seconds / (3600 * 24));
10
+ const hours = Math.floor((props.seconds % (3600 * 24)) / 3600);
11
+ const minutes = Math.floor((props.seconds % 3600) / 60);
12
+ const seconds = props.seconds % 60;
13
+ let formattedDuration = '';
14
+
15
+ days >= 1 && (formattedDuration += `${Math.floor(days)}天 `);
16
+ hours >= 1 && (formattedDuration += `${Math.floor(hours)}小时 `);
17
+ minutes >= 1 && (formattedDuration += `${Math.floor(minutes)}分钟 `);
18
+ seconds >= 1 && (formattedDuration += `${Math.floor(seconds)}秒`);
19
+
20
+ return formattedDuration;
21
+ });
22
+ </script>
23
+
24
+ <template>
25
+ <span>{{ formattedDuration }}</span>
26
+ </template>