Commit d9ca3115 by 王立鹏

Merge branch 'feature/20331-【YAYA文化岛】优化点整理' into 'master'

Feature/20331 【yaya文化岛】优化点整理

See merge request !4
parents c56852a3 b1614d17
......@@ -33,10 +33,12 @@
"dayjs": "^1.11.19",
"element-plus": "^2.11.5",
"inquirer": "^13.0.2",
"notivue": "^2.4.5",
"pinia": "^3.0.3",
"ssh2": "^1.17.0",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
"vue-router": "^4.6.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@iconify-json/ep": "^1.2.3",
......
......@@ -41,6 +41,9 @@ importers:
inquirer:
specifier: ^13.0.2
version: 13.0.2(@types/node@22.18.12)
notivue:
specifier: ^2.4.5
version: 2.4.5(defu@6.1.4)
pinia:
specifier: ^3.0.3
version: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
......@@ -53,6 +56,9 @@ importers:
vue-router:
specifier: ^4.6.3
version: 4.6.3(vue@3.5.22(typescript@5.9.3))
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies:
'@iconify-json/ep':
specifier: ^1.2.3
......@@ -1369,6 +1375,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
......@@ -1587,6 +1597,10 @@ packages:
caniuse-lite@1.0.30001751:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chalk@1.1.3:
resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
engines: {node: '>=0.10.0'}
......@@ -1622,6 +1636,10 @@ packages:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
collection-visit@1.0.0:
resolution: {integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==}
engines: {node: '>=0.10.0'}
......@@ -2159,6 +2177,10 @@ packages:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fragment-cache@0.2.1:
resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==}
engines: {node: '>=0.10.0'}
......@@ -2860,6 +2882,20 @@ packages:
normalize-wheel-es@1.2.0:
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
notivue@2.4.5:
resolution: {integrity: sha512-7yBdaKesUZIwdcQP3nv1oWYyisI2bURkZ+D9KfLgeNqguHUzkQ1WdhGcTj59PBZa8mqa1/K5Mh8YsphSToMKcQ==}
peerDependencies:
'@nuxt/kit': '>=3.5.0'
'@nuxt/schema': '>=3.5.0'
defu: '>=6'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
'@nuxt/schema':
optional: true
defu:
optional: true
npm-normalize-package-bin@4.0.0:
resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==}
engines: {node: ^18.17.0 || >=20.5.0}
......@@ -3450,6 +3486,10 @@ packages:
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
engines: {node: '>=0.10.0'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
ssh2@1.17.0:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
......@@ -3930,10 +3970,18 @@ packages:
wildcard@1.1.2:
resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
......@@ -3950,6 +3998,11 @@ packages:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
......@@ -5328,6 +5381,8 @@ snapshots:
acorn@8.15.0: {}
adler-32@1.3.1: {}
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
......@@ -5555,6 +5610,11 @@ snapshots:
caniuse-lite@1.0.30001751: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chalk@1.1.3:
dependencies:
ansi-styles: 2.2.1
......@@ -5603,6 +5663,8 @@ snapshots:
clone@2.1.2: {}
codepage@1.15.0: {}
collection-visit@1.0.0:
dependencies:
map-visit: 1.0.0
......@@ -6245,6 +6307,8 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
frac@1.1.2: {}
fragment-cache@0.2.1:
dependencies:
map-cache: 0.2.2
......@@ -6894,6 +6958,10 @@ snapshots:
normalize-wheel-es@1.2.0: {}
notivue@2.4.5(defu@6.1.4):
optionalDependencies:
defu: 6.1.4
npm-normalize-package-bin@4.0.0: {}
npm-run-all2@8.0.4:
......@@ -7516,6 +7584,10 @@ snapshots:
dependencies:
extend-shallow: 3.0.2
ssf@0.11.2:
dependencies:
frac: 1.1.2
ssh2@1.17.0:
dependencies:
asn1: 0.2.6
......@@ -8119,8 +8191,12 @@ snapshots:
wildcard@1.1.2: {}
wmf@1.0.2: {}
word-wrap@1.2.5: {}
word@0.3.0: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
......@@ -8143,6 +8219,16 @@ snapshots:
dependencies:
is-wsl: 3.1.0
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xml-name-validator@4.0.0: {}
y18n@5.0.8: {}
......
<template>
<Notivue v-slot="item">
<NotivueSwipe :item="item">
<Notification :item="item" :theme="pastelTheme"> </Notification>
</NotivueSwipe>
</Notivue>
<el-config-provider :locale="locale">
<router-view v-slot="{ Component }">
<component :is="Component" />
......@@ -8,6 +13,7 @@
<script setup lang="ts">
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { initWxConfig } from '@/utils/wxUtil/initWXConfig'
import { Notivue, NotivueSwipe, Notification, pastelTheme } from 'notivue'
const locale = ref(zhCn)
......
......@@ -17,6 +17,7 @@ import type {
SearchMoreVideoParams,
SearchMoreVideoItemDto,
SecondCommentItemDto,
UpdateArticleRecommendAndSortDto,
} from './types'
import type { BackendServicePageResult, PageSearchParams } from '@/utils/request/types'
......@@ -207,11 +208,11 @@ export const addOrCancelCommentLike = (commentId: number | string) => {
/**
* 修改文章的是否推荐/置顶字段
*/
export const updateArticleRecommend = (articleId: number) => {
export const updateArticleRecommendAndSort = (data: UpdateArticleRecommendAndSortDto) => {
return service.request({
url: `/api/cultureArticle/isRecommend?articleId=${articleId}`,
url: `/api/cultureArticle/isRecommend`,
method: 'POST',
data: {},
data,
})
}
......
import { ArticleTypeEnum, ReleaseStatusTypeEnum, BooleanFlag, SendTypeEnum } from '@/constants'
import {
ArticleTypeEnum,
ReleaseStatusTypeEnum,
BooleanFlag,
SendTypeEnum,
VideoPositionEnum,
} from '@/constants'
import type { PageSearchParams } from '@/utils/request/types'
/**
......@@ -9,6 +15,7 @@ export interface ArticleSearchParams extends PageSearchParams {
sortLogic?: number
title?: string
questionFocus?: number
status?: BooleanFlag
}
/**
......@@ -193,6 +200,10 @@ export interface ArticleItemDto {
sendTime: string
tagIdList: number[]
pushList: { valueId: string; valueType: number; valueName: string }[]
videoLocation: VideoPositionEnum
articleVideoUrl: string
region: string
recommendSort: number
}
/**
......@@ -318,6 +329,7 @@ export interface AddCommentDto {
articleId: number | string
content: string
pid?: number | string
imgUrl?: string
}
/**
......@@ -351,6 +363,7 @@ export interface CommentItemDto {
showComment: boolean
isExpand: boolean
childNum: number
imgUrl: string
}
/**
......@@ -423,3 +436,12 @@ export interface SearchMoreVideoItemDto {
* 获取问吧二级评论的返回参数
*/
export type SecondCommentItemDto = CommentItemDto
/**
* 修改文章推荐和排序
*/
export interface UpdateArticleRecommendAndSortDto {
recommendSort: number
isRecommend: BooleanFlag
articleId: number
}
import service from '@/utils/request/index'
import { getBackendAuctionList } from '@/api/backend'
import type { AuctionItemDetailDto } from './types'
// 前台限时竞拍相关接口
/**
* 限时竞拍前台列表
*/
export const getAuctionList = getBackendAuctionList
/**
* 获取用户某个商品的拍卖次数是否超过限制
*/
export const getUserAuctionCount = (id: number) => {
return service.request<boolean>({
url: `api/cultureAuctionItems/isOver`,
method: 'POST',
data: {
id,
},
})
}
/**
* 获取某个商品的竞拍信息详情
*/
export const getAuctionDetail = (id: number) => {
return service.request<AuctionItemDetailDto>({
url: `api/cultureAuctionItems/getAuctionItemDetail?id=${id}`,
method: 'POST',
})
}
/**
* 参与竞价
*/
export const participateAuction = (id: number, bidPrice: number) => {
return service.request({
url: `api/cultureAuctionItems/auction`,
method: 'POST',
data: {
id,
bidPrice,
},
})
}
import type { BackendAuctionListItemDto } from '@/api/backend'
export type AuctionItemDto = BackendAuctionListItemDto
export type AuctionItemDetailDto = {
bidLimit: number
canBid: boolean
cannotBidReason: string
createUserName: string
createdAt: number
currentBidderId: number
currentPrice: number
description: string
endTime: number
id: number
imageUrl: string
isDisplay: number
minIncrement: number
name: string
quantity: number
specification: string
startTime: number
startingPrice: number
status: number
statusDesc: string
totalBids: number
type: number
typeDesc: string
userBidCount: number
}
import service from '@/utils/request/index'
import type {
BackendAuctionListSearchParams,
BackendAuctionListItemDto,
BackendAddOrUpdateAuctionItemDto,
BackendAuctionRecordItemDto,
BackendLotteryPrizeListSearchParams,
BackendLotteryPrizeListItemDto,
BackendAddOrUpdateLotteryPrizeDto,
BackendAddOrUpdateLotteryConfigDto,
BackendLotteryConfigDto,
} from './types'
import type { BackendServicePageResult } from '@/utils/request/types'
import { BooleanFlag } from '@/constants'
// 后台管理限时竞拍相关接口
/**
* 限时竞拍后台列表
*/
export const getBackendAuctionList = (data: BackendAuctionListSearchParams) => {
return service.request<BackendServicePageResult<BackendAuctionListItemDto>>({
url: 'api/cultureAuctionItems/listByPage',
method: 'POST',
data,
})
}
/**
* 新增或者编辑竞拍物品
*/
export const addOrUpdateAuctionItem = (data: BackendAddOrUpdateAuctionItemDto) => {
return service.request({
url: 'api/cultureAuctionItems/addOrUpdateAuctionItem',
method: 'POST',
data,
})
}
/**
* 删除竞拍物品数据
*/
export const deleteAuction = (idList: number[]) => {
return service.request({
url: 'api/cultureAuctionItems/deleteAuction',
method: 'POST',
data: { idList },
})
}
/**
* 批量修改展示竞拍
*/
export const batchUpdateShowAuction = (data: { idList: number[]; isDisplay: BooleanFlag }) => {
return service.request({
url: 'api/cultureAuctionItems/changeAuctionDisplay',
method: 'POST',
data,
})
}
/**
* 获取竞拍记录
*/
export const getAuctionRecord = (data: BackendAuctionRecordItemDto) => {
return service.request<BackendServicePageResult<BackendAuctionRecordItemDto>>({
url: `api/cultureAuctionItems/listBidRecord`,
method: 'POST',
data,
})
}
// 限时抽奖相关的后台接口
/**
* 获取限时抽奖物品列表
*/
export const getLotteryPrizeList = (data: BackendLotteryPrizeListSearchParams) => {
return service.request<BackendServicePageResult<BackendLotteryPrizeListItemDto>>({
url: `/api/cultureLotteryPrizes/listPrizesByPage`,
method: 'POST',
data,
})
}
// 新增或修改奖品池数据
export const addOrUpdateLotteryPrize = (data: BackendAddOrUpdateLotteryPrizeDto) => {
return service.request({
url: `/api/cultureLotteryPrizes/addOrUpdateLotteryPrizes`,
method: 'POST',
data,
})
}
/**
* 删除奖品池数据
*/
export const deleteLotteryPrize = (idList: number[]) => {
return service.request({
url: `/api/cultureLotteryPrizes/deleteLotteryPrizes`,
method: 'POST',
data: { idList },
})
}
/**
* 获取抽奖配置详情
*/
export const getLotteryConfigDetail = () => {
return service.request<BackendLotteryConfigDto>({
url: `/api/cultureLotteryPrizes/getConfigBackgroundDetail`,
method: 'POST',
})
}
/**
* 修改抽奖配置
*/
export const setLotteryConfig = (data: BackendAddOrUpdateLotteryConfigDto) => {
return service.request({
url: `/api/cultureLotteryPrizes/dealConfig`,
method: 'POST',
data,
})
}
import type { PageSearchParams } from '@/utils/request/types'
import { BooleanFlag, AuctionStatusEnum } from '@/constants'
export interface BackendAuctionListSearchParams extends PageSearchParams {
name?: string
status?: AuctionStatusEnum
}
export interface BackendAuctionListItemDto extends PageSearchParams {
bidLimit: number
canBid: number
cannotBidReason: string
createAt: number
createUserName: string
currentBidderId: number
currentPrice: number
description: string
endTime: number
id: number
imageUrl: string
isDisplay: number
minIncrement: number
name: string
quantity: number
specification: string
startTime: number
startingPrice: number
status: AuctionStatusEnum
statusDesc: string
totalBids: number
type: number
typeDesc: string
userBidCount: number
// 拍卖剩余时间 几天 几个小时 几分钟 几秒
remainingTime: string
}
export interface BackendAddOrUpdateAuctionItemDto {
id?: number
name: string
quantity: number
specification: string
description: string
startingPrice: number
minIncrement: number
bidLimit: number
startTime: string
endTime: string
isDisplay: BooleanFlag
imageUrl: string
}
export interface BackendAuctionRecordItemDto extends PageSearchParams {
id: number
bidPrice: number
bidTime: number
isHighest: number
itemId: number
status: number
userId: number
userName: string
}
export interface BackendLotteryPrizeListSearchParams extends PageSearchParams {
isCurrent?: BooleanFlag
}
export interface BackendLotteryPrizeListItemDto extends PageSearchParams {
id: number
name: string
}
export interface BackendAddOrUpdateLotteryPrizeDto {
id?: number
name: string
isCurrent: BooleanFlag
imageUrl: string
}
export interface BackendAddOrUpdateLotteryConfigDto {
id?: number
startDate: string
endDate: string
autoCloseWeekend: BooleanFlag
registrationStartHour: string
registrationEndHour: string
registrationFee: number
}
export type BackendLotteryConfigDto = {
autoCloseWeekend: BooleanFlag
createdAt: number
endDate: number
id: number
isJoin: BooleanFlag
number: number
prizesImg: string
prizesName: string
registrationEndHour: number
registrationFee: number
registrationStartHour: number
startDate: number
status: BooleanFlag
updatedAt: number
winner: number
inRegistrationTime: BooleanFlag
registrationTimeDesc: string
} | null
......@@ -4,6 +4,7 @@ import type {
AuditCaseDto,
BackendCaseListItemDto,
CaseListSearchParams,
BackendEditCaseDto,
} from './types'
import type { BackendServicePageResult } from '@/utils/request/types'
// 后台管理案例库相关接口
......@@ -51,3 +52,35 @@ export const changeUsageStatus = (data: ChangeUsageStatusDto) => {
data,
})
}
/**
* 导入excel的接口
*/
export const importCaseExcel = (file: File, onProgress?: (progress: number) => void) => {
const formData = new FormData()
formData.append('file', file)
return service.request<BackendCaseListItemDto[]>({
url: '/api/cultureCase/import',
method: 'POST',
data: formData,
onUploadProgress: (progressEvent) => {
const progress = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1))
if (onProgress) {
onProgress(progress)
}
},
})
}
/**
* 后台编辑案例库
*/
export const backendEditCase = (data: BackendEditCaseDto) => {
return service.request({
url: '/api/cultureCase/addOrUpdateCase',
method: 'POST',
data,
})
}
import type { TagItemDto } from '@/api/case/types'
import { UsageStatusEnum, AuditStatusEnum } from '@/constants'
import type { PageSearchParams } from '@/utils/request/types'
......@@ -7,21 +8,25 @@ export interface CaseListSearchParams extends PageSearchParams {
title?: string
}
type TagItem = {
id: number
title: string
}
export interface BackendCaseListItemDto {
caseType: string
content: string
createTime: number
createUser: number
cultureKeywordMain: string
cultureKeywordSecond: string[]
cultureRelation: number
cultureKeywordMain: TagItem[]
cultureKeywordSecond: TagItem[]
cultureRelation: string
dataSources: number
depIdList: string[]
depNameList: string[]
deptId: string
deptName: string
id: number
integrity: number
integrity: string
isAudit: number
isDelete: number
isDispose: number
......@@ -29,18 +34,31 @@ export interface BackendCaseListItemDto {
mainScene: string
minorScene: string
number: string
sceneKeywordMain: string
sceneKeywordSecond: string[]
sceneKeywordMain: TagItem[]
sceneKeywordSecond: TagItem[]
sourceProject: string
sourceTime: number
sourceType: string
sourceUser: number
title: string
yearKeywordMain: string
yearKeywordSecond: string[]
yearKeywordMain: TagItem[]
yearKeywordSecond: TagItem[]
sourceUserName: string
sourceUserWorkNo: string
}
export interface BackendEditCaseDto {
id: number
sourceProject: string
sourceType: string
caseType: string
integrity: string
cultureRelation: string
title: string
content: string
deptId: string
deptName: string
tagRelationDtoList: TagItemDto[]
}
export interface ChangeUsageStatusDto {
id: number
......
import type { GoodsDistributionTypeEnum } from '@/constants'
import type { PageSearchParams } from '@/utils/request/types'
export interface BackendColumnSearchParams extends PageSearchParams {
type: string
......@@ -24,4 +25,5 @@ export interface BackendColumnListItemDto {
status: number
title: string
type: string
sourceType: GoodsDistributionTypeEnum
}
......@@ -4,10 +4,11 @@ export * from './carousel'
export * from './columnSettings'
export * from './case'
export * from './shop'
export * from './activity'
// 类型
export * from './tags/types'
export * from './carousel/types'
export * from './columnSettings/types'
export * from './case/types'
export * from './shop/types'
export * from './activity/types'
......@@ -6,6 +6,7 @@ import type {
BackendShopListSearchParams,
} from './types'
import type { BackendServicePageResult } from '@/utils/request/types'
import { GoodsDistributionTypeEnum } from '@/constants'
// 后台管理 积分商城相关接口
/**
......@@ -53,10 +54,26 @@ export const getBackendExchangeList = (data: BackendExchangeListSearchParams) =>
/**
* 发放 取消发放
*/
export const issueProduct = (data: { id: number; status: number; memo?: string }) => {
export const issueProduct = (data: {
id: number
status: number
memo?: string
sourceType: GoodsDistributionTypeEnum
}) => {
return service.request({
url: `/api/culture/shop/order/issueProduct`,
method: 'POST',
data,
})
}
/**
* 商品分发页导出数据
*/
export const exportShopItemList = (data: BackendShopListSearchParams) => {
return service.request<BackendShopItemDto[]>({
url: '/api/culture/shop/order/background/productExport',
method: 'POST',
data,
})
}
import type { PageSearchParams } from '@/utils/request/types'
import { BooleanFlag, ShopGoodsTypeEnum } from '@/constants'
import { GoodsDistributionTypeEnum, ShopGoodsTypeEnum } from '@/constants'
export interface BackendShopListSearchParams extends PageSearchParams {
name?: string
itemType?: ShopGoodsTypeEnum
......@@ -7,19 +7,24 @@ export interface BackendShopListSearchParams extends PageSearchParams {
enable?: 0 | 1
}
export interface BackendShopItemDto {
id?: number
sort: number
enable: BooleanFlag
createTime: number
deliveryInfo: string
id: number
imageUrl: string
isDelete: null
issueTime: number
issuerId: number
issuerName: string
itemId: number
itemName: string
itemType: ShopGoodsTypeEnum
name: string
memo: string
num: number
price: number
region: string
title: string
description: string
imgUrl: string
stock: number
sourceType: GoodsDistributionTypeEnum
status: number
userId: number
userName: string
}
export interface AddOrUpdateShopItemDto {
......@@ -32,7 +37,7 @@ export interface AddOrUpdateShopItemDto {
status: number
}
export interface BackendExchangeListSearchParams extends PageSearchParams {
source: string
sourceType?: GoodsDistributionTypeEnum
status?: 0 | 1 | 2
itemName?: string
}
/**
* 添加或更新案例库DTO
*/
import { TagTypeEnum, TagLevelEnum, BooleanFlag, ReleaseStatusTypeEnum } from '@/constants'
import {
TagTypeEnum,
TagLevelEnum,
BooleanFlag,
ReleaseStatusTypeEnum,
AuditStatusEnum,
} from '@/constants'
type TagItemDto = {
tagId: number
......@@ -27,8 +33,9 @@ export interface CaseDetailDto {
depIdList: number[]
depNameList: string[]
isSync: BooleanFlag
cultureKeywordMain: string
cultureKeywordSub: string[]
sceneKeywordMain: string
sceneKeywordSub: string[]
cultureKeywordMain: { id: string; title: string }[]
cultureKeywordSecond: { id: string; title: string }[]
sceneKeywordMain: { id: string; title: string }[]
sceneKeywordSecond: { id: string; title: string }[]
isAudit: AuditStatusEnum
}
import service from '@/utils/request'
import type { BackendLotteryConfigDto } from '../backend/activity/types'
import type { SearchLotteryRecordParams, UserLotteryRecordItemDto } from './types'
import type { BackendServicePageResult } from '@/utils/request/types'
// 每日抽奖相关接口
/**
* 获取用户当前每日抽奖相关信息 返回null 不显示
*/
export const getUserDailyLotteryInfo = () => {
return service.request<BackendLotteryConfigDto>({
url: `/api/cultureLotteryPrizes/getLotteryConfigDetail`,
method: 'POST',
})
}
/**
* 用户参与抽奖
*/
export const userJoinLottery = () => {
return service.request({
url: `/api/cultureLotteryPrizes/participate`,
method: 'POST',
})
}
/**
* 获取用户抽奖记录列表
*/
export const getUserLotteryRecordList = (data: SearchLotteryRecordParams) => {
return service.request<BackendServicePageResult<UserLotteryRecordItemDto>>({
url: `/api/personalCenter/listLotteryPage`,
method: 'POST',
data,
})
}
import type { BackendLotteryConfigDto } from '@/api/backend/activity/types'
import type { PageSearchParams } from '@/utils/request/types'
import type { BooleanFlag } from '@/constants/enums'
export type DailyLotteryInfo = BackendLotteryConfigDto
export interface SearchLotteryRecordParams extends PageSearchParams {
lotteryId: number
}
export interface UserLotteryRecordItemDto {
activityDateRange: string
configId: number
endDate: number
feePaid: number
isWin: BooleanFlag
prizeId: number
prizeImage: string
prizeName: string
prizeStatus: number
recordId: number
registrationTime: number
registrationTimeStr: string
startDate: number
winDate: number
winDateStr: string
winId: number
isLotteryDone: BooleanFlag
}
......@@ -29,7 +29,7 @@ export interface UserAccountDataDto {
}
/**
* 用户信息记录
* 用户信息记录 -- 当日是否签到 是否观看过cg 服务器时间
*/
export interface UserRecordDataDto {
actionType: null
......@@ -41,11 +41,14 @@ export interface UserRecordDataDto {
incrText: null
isDelete: null
isIncr: null
isSign: BooleanFlag
relationId: null
remark: null
scoreAyabi: null
scoreExp: null
subType: null
userId: null
isSign: BooleanFlag
isView: BooleanFlag
isViewGuide: BooleanFlag
currentTime: number
}
// 企业文化接口
export * from './task'
export * from './sign'
export * from './article'
export * from './shop'
export * from './tag'
export * from './article'
export * from './user'
export * from './case'
export * from './home'
......@@ -14,10 +12,12 @@ export * from './login'
export * from './article'
export * from './online'
export * from './otherUserPage'
export * from './auction'
export * from './dailyLottery'
export * from './launchCampaign'
// 导出类型
export * from './task/types'
export * from './shop/types'
export * from './article/types'
export * from './tag/types'
export * from './article/types'
export * from './user/types'
......@@ -29,3 +29,6 @@ export * from './login/types'
export * from './article/types'
export * from './online/types'
export * from './otherUserPage/types'
export * from './auction/types'
export * from './dailyLottery/types'
export * from './launchCampaign/types'
import service from '@/utils/request/index'
import type { SurveySubmitData } from './types'
import type { UserLaunchCampaignStatusDataDto } from './types'
// 项目首次上线相关的活动接口
/**
* 用户是否观看首页CG引导
*/
export const getUserLaunchCampaignStatus = () => {
return service.request<UserLaunchCampaignStatusDataDto>({
url: '/api/yaCulture/confirm/viewData',
method: 'POST',
data: {},
})
}
/**
* 标记用户已经观看过CG 或 普通动画引导
*/
export const markUserGuide = (pageKey: 'isView' | 'isViewGuide') => {
return service.request({
url: '/api/yaCulture/confirm/view',
method: 'POST',
data: { pageKey },
})
}
/**
* 用户点击相关奖励按钮 true: 第一次进入 跳出相关动画 false: 已经进入过 不跳出动画
*/
export const markUserClickedRewardButton = (pageKey: string) => {
return service.request({
url: '/api/yaCulture/first/view',
method: 'POST',
data: { pageKey },
})
}
/**
* 获取活动配置(双倍亚币、活动期间等)
* TODO: 对接后端真实接口
*/
export const getActivityConfig = () => {
return service.request({
url: '/api/culture/activity/config',
method: 'GET',
})
}
/**
* 获取CG引导观看状态
* TODO: 对接后端真实接口
*/
export const getCgGuideStatus = () => {
return service.request({
url: '/api/culture/activity/cg-guide/status',
method: 'GET',
})
}
/**
* 标记CG引导已观看 + 领取奖励(碎片+1, 亚币+2)
* TODO: 对接后端真实接口
*/
export const completeCgGuide = () => {
return service.request({
url: '/api/culture/activity/cg-guide/complete',
method: 'POST',
})
}
/**
* 领取碎片页面奖励(碎片+1, 亚币+2)
* TODO: 对接后端真实接口
*/
export const claimFragmentReward = (pageKey: string) => {
return service.request({
url: '/api/culture/activity/fragment/claim',
method: 'POST',
data: { pageKey },
})
}
/**
* 获取碎片奖励状态(某页面是否已领取)
* TODO: 对接后端真实接口
*/
export const getFragmentRewardStatus = (pageKey: string) => {
return service.request({
url: '/api/culture/activity/fragment/status',
method: 'GET',
params: { pageKey },
})
}
/**
* 获取满意度问卷配置
* TODO: 对接后端真实接口
*/
export const getSurveyConfig = () => {
return service.request({
url: '/api/culture/activity/survey/config',
method: 'GET',
})
}
/**
* 提交满意度问卷
* TODO: 对接后端真实接口
*/
export const submitSurvey = (data: SurveySubmitData) => {
return service.request({
url: '/api/culture/activity/survey/submit',
method: 'POST',
data,
})
}
import type { BooleanFlag } from '@/constants'
/** 用户是否观看首页CG引导等相关数据 */
export interface UserLaunchCampaignStatusDataDto {
/** 是否已观看CG */
isView: BooleanFlag
/** 服务器时间 */
currentTime: number
/** 是否已观看普通动画引导 */
isViewGuide: BooleanFlag
}
/** 活动配置信息 */
export interface ActivityConfigDto {
/** 是否处于活动期间 */
isActivityPeriod: boolean
/** 签到是否双倍亚币 */
isDoubleYabi: boolean
/** 双倍亚币倍率 */
doubleMultiplier: number
/** 活动开始时间 */
activityStartTime: string
/** 活动结束时间 */
activityEndTime: string
}
/** CG引导状态 */
export interface CgGuideStatusDto {
/** 是否已观看CG */
hasWatchedCg: boolean
}
/** 碎片奖励记录 */
export interface FragmentRewardDto {
/** 碎片数量 */
fragmentCount: number
/** 亚币数量 */
yabiCount: number
/** 当前页面是否已领取 */
hasClaimedThisPage: boolean
}
/** 满意度问卷配置 */
export interface SurveyConfigDto {
/** 是否在调研期内(上线4周内) */
isInSurveyPeriod: boolean
/** 触发阈值(点击次数) */
clickThreshold: number
/** 用户是否已提交过问卷 */
hasSubmitted: boolean
}
/** 问卷提交数据 */
export interface SurveySubmitData {
/** 满意度评分 1-5 */
rating: number
/** 最喜欢的功能(多选) */
favoriteFeatures: string[]
/** 改进建议 */
suggestion: string
}
/** 碎片奖励可触发的页面 key */
export type RewardPageKey =
| 'askTab'
| 'pointsStore'
| 'auction'
| 'yaTab'
| 'userPage'
| 'articleDetail'
......@@ -69,3 +69,16 @@ export const getWxSignature = (url: string) => {
},
})
}
/**
* 获取新的token
*/
export const refreshTokenApi = (refreshToken: string) => {
return service.request<LoginResponseDto>({
url: '/api/auth/refreshToken',
method: 'POST',
data: {
refreshToken,
},
})
}
......@@ -30,6 +30,7 @@ export interface LoginResponseDto {
hiddenAvatar: string
hiddenName: string
signature: string
refreshToken: string
}
export interface GetWxSignatureResponseDto {
......
......@@ -41,7 +41,22 @@ export interface ExchangeGoodsRecordSearchParams extends PageSearchParams {
* yabi信息对象类型
*/
export interface YaBiData {
actionType: null
actionTypeText: null
createdAt: null
createdTimeText: null
currentValue: number
id: null
incrText: null
isDelete: null
isIncr: null
isSign: null
relationId: null
remark: null
scoreAyabi: null
scoreExp: null
subType: null
userId: null
}
/**
......@@ -97,5 +112,6 @@ export interface ExchangeYabiRecordItemDto {
*/
export interface ExchangeYabiRecordSearchParams extends PageSearchParams {
type: ShopGoodsTypeEnum
dateRange: [number, number]
startTime?: number
endTime?: number
}
......@@ -21,6 +21,8 @@ import type {
AuditComplaintDto,
ComplaintListSearchParams,
SelfCaseSearchParams,
SelfAuctionRecordSearchParams,
SelfAuctionRecordItemDto,
} from './types'
import type { BackendServicePageResult } from '@/utils/request/types'
......@@ -166,3 +168,14 @@ export const auditComplaint = (data: AuditComplaintDto) => {
data,
})
}
/**
* 获取我的竞拍记录-分页
*/
export const getSelfAuctionRecord = (data: SelfAuctionRecordSearchParams) => {
return service.request<BackendServicePageResult<SelfAuctionRecordItemDto>>({
url: '/api/personalCenter/listBidRecord',
method: 'POST',
data,
})
}
import {
ActivityTypeEnum,
ArticleTypeEnum,
AuditStatusEnum,
BooleanFlag,
......@@ -111,6 +112,7 @@ export interface UpdateUserInfoDto {
// 审核列表查询参数
export interface AuditListSearchParams extends PageSearchParams {
isAudit: AuditStatusEnum
type: ArticleTypeEnum | ''
}
// 待审核列表item
......@@ -323,3 +325,22 @@ export interface AuditComplaintDto {
status: AuditStatusEnum
remark?: string
}
/**
* 我的竞拍记录搜索参数
*/
export interface SelfAuctionRecordSearchParams extends PageSearchParams {
id?: number
type?: ActivityTypeEnum
}
/**
* 我的竞拍记录item
*/
export interface SelfAuctionRecordItemDto {
auctionId: number
auctionName: string
auctionTime: number
auctionType: number
auctionValue: number
}
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1772186745618" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8530" id="mx_n_1772186745619" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256"><path d="M547.4304 630.3744v344.8832c0 9.216 3.6864 17.8176 10.4448 24.3712 6.5536 6.3488 15.5648 10.0352 24.9856 10.0352h248.0128c39.1168 0 70.8608-30.9248 70.8608-69.0176V630.3744c0-9.216-3.6864-18.0224-10.4448-24.3712a35.84 35.84 0 0 0-24.9856-10.0352H582.8608c-9.4208 0-18.432 3.6864-24.9856 10.0352-6.5536 6.3488-10.4448 15.1552-10.4448 24.3712z m-106.2912-34.4064H157.4912c-9.4208 0-18.432 3.6864-24.9856 10.0352-6.5536 6.3488-10.4448 15.1552-10.4448 24.3712v310.4768c0 38.0928 31.744 69.0176 70.8608 69.0176h248.0128c9.4208 0 18.432-3.6864 24.9856-10.0352 6.5536-6.3488 10.4448-15.1552 10.4448-24.3712V630.3744c0-9.216-3.6864-18.0224-10.4448-24.3712-6.5536-6.5536-15.36-10.24-24.7808-10.0352z m460.8-344.8832h-70.2464a140.9024 140.9024 0 0 0 32.5632-67.584c8.192-46.4896-7.168-94.0032-41.1648-126.976a151.61344 151.61344 0 0 0-174.8992-25.1904c-29.4912 15.1552-51.8144 40.5504-66.1504 69.8368L512 246.5792l-70.656-146.432c-11.8784-24.3712-29.2864-46.08-52.4288-61.0304a151.51104 151.51104 0 0 0-188.0064 17.6128A141.80352 141.80352 0 0 0 159.744 183.5008c4.5056 24.9856 15.7696 48.128 32.5632 67.584H122.0608c-39.1168 0-70.8608 30.9248-70.8608 68.8128v138.0352c0 38.0928 31.744 69.0176 70.8608 69.0176h319.0784c9.4208 0 18.432-3.6864 24.9856-10.0352 6.5536-6.3488 10.24-15.1552 10.4448-24.3712v-136.8064c0-17.2032 12.0832-33.1776 29.4912-35.84 10.24-1.6384 20.8896 1.024 28.8768 7.7824 7.9872 6.5536 12.4928 16.1792 12.6976 26.4192v138.6496c0 9.216 3.6864 17.8176 10.4448 24.3712 6.5536 6.5536 15.5648 10.0352 24.9856 10.0352h319.0784c39.1168 0 70.8608-30.9248 70.8608-69.0176v-138.0352c-0.2048-38.2976-31.9488-69.0176-71.0656-69.0176z m-575.6928 0l-53.248-24.3712c-22.7328-10.0352-38.912-30.72-43.4176-55.0912-4.5056-24.3712 3.6864-49.152 21.504-66.1504 18.0224-17.6128 43.4176-25.3952 67.9936-20.8896 24.9856 4.096 46.08 19.8656 56.7296 42.1888l60.0064 124.3136h-109.568z m468.1728-79.4624a74.60864 74.60864 0 0 1-43.4176 55.0912l-53.248 24.3712h-109.568l60.0064-124.3136a75.9808 75.9808 0 0 1 56.7296-42.1888c24.7808-4.5056 50.176 3.2768 67.9936 20.8896 18.0224 17.2032 26.0096 41.984 21.504 66.1504z" p-id="8531" fill="#ffffff"></path></svg>
\ No newline at end of file
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1653463274928" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="23443" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M514.286299 2.505864h3.565417V895.785974h-3.565417z" fill="#EDA639" p-id="23444"></path><path d="M69.430967 447.365225h893.276081v3.55736H69.430967z" fill="#EDA639" p-id="23445"></path><path d="M517.811429 452.578389m-430.932074 0a430.932074 430.932074 0 1 0 861.864148 0 430.932074 430.932074 0 1 0-861.864148 0Z" fill="#FFE770" p-id="23446"></path><path d="M520.027225 493.522274v160.145663" fill="#FFE770" p-id="23447"></path><path d="M520.027225 675.024184a21.352218 21.352218 0 0 1-21.352218-21.356247v-160.145663a21.352218 21.352218 0 0 1 42.708465 0v160.145663a21.352218 21.352218 0 0 1-21.356247 21.356247z" fill="#6E6E96" p-id="23448"></path><path d="M300.917211 313.051716l62.279988-62.279988 62.279988 62.279988" fill="#FFE770" p-id="23449"></path><path d="M425.477187 334.403934a21.299845 21.299845 0 0 1-15.099644-6.252574L363.197199 280.971016 316.016855 328.15136a21.352218 21.352218 0 1 1-30.199288-30.199288l62.279988-62.279988a21.352218 21.352218 0 0 1 30.199288 0l62.279988 62.279988a21.352218 21.352218 0 0 1-15.099644 36.451862z" fill="#6E6E96" p-id="23450"></path><path d="M610.536457 313.051716l62.284017-62.279988 62.279988 62.279988" fill="#FFE770" p-id="23451"></path><path d="M735.100462 334.403934a21.299845 21.299845 0 0 1-15.099644-6.252574l-47.180344-47.180344-47.184373 47.180344a21.356247 21.356247 0 0 1-30.195259-30.199288l62.279988-62.279988a21.352218 21.352218 0 0 1 30.195259 0l62.279989 62.279988a21.352218 21.352218 0 0 1-15.095616 36.451862z" fill="#6E6E96" p-id="23452"></path><path d="M519.624353 45.947556c-3.831313 0-7.65054 0.068488-11.449623 0.173235 203.095851 5.785242 365.960901 172.231825 365.960901 376.72967 0 208.159953-168.746981 376.898877-376.906934 376.898877-208.155924 0-376.902905-168.738924-376.902906-376.898877a377.615989 377.615989 0 0 1 12.061989-94.924709 406.368966 406.368966 0 0 0-19.523179 124.777527c0 224.645477 182.114276 406.755723 406.759752 406.755724s406.755723-182.110247 406.755724-406.755724c0-224.645477-182.110247-406.755723-406.755724-406.755723z" fill="#FF9900" opacity=".24" p-id="23453"></path><path d="M517.811429 901.305321c-103.779837 0-204.98935-36.262512-284.979593-102.095831-78.906517-64.947001-133.733373-155.508606-154.380564-255.005914a451.740415 451.740415 0 0 1-9.366775-91.629216C69.084497 205.14647 270.383539 3.847428 517.811429 3.847428c247.42789 0 448.726932 201.299042 448.726932 448.726932 0 247.42789-201.299042 448.730961-448.726932 448.730961z m0-861.864149c-227.803993 0-413.137216 185.333223-413.137216 413.137217 0 28.43068 2.900679 56.821072 8.621461 84.397663 19.003474 91.576843 69.487369 174.951211 142.153398 234.757565 73.636951 60.604041 166.813196 93.981989 262.366386 93.981988 227.808022 0 413.137216-185.333223 413.137216-413.137216S745.619451 39.441172 517.811429 39.441172z" fill="#6E6E96" p-id="23454"></path><path d="M809.635818 752.069432m-135.542268 0a135.542268 135.542268 0 1 0 271.084536 0 135.542268 135.542268 0 1 0-271.084536 0Z" fill="#FFE770" p-id="23455"></path><path d="M809.635818 905.406558c-84.546726 0-153.337126-68.786372-153.337126-153.341154 0-84.546726 68.786372-153.337126 153.337126-153.337126s153.341155 68.786372 153.341155 153.337126c-0.004029 84.554783-68.7904 153.341155-153.341155 153.341154z m0-271.084536c-64.926857 0-117.74741 52.816524-117.74741 117.74741s52.816524 117.751439 117.74741 117.751439 117.751439-52.816524 117.751439-117.751439c-0.004029-64.930886-52.820553-117.74741-117.751439-117.74741z" fill="#6E6E96" p-id="23456"></path><path d="M218.864263 752.069432m-135.542268 0a135.542268 135.542268 0 1 0 271.084536 0 135.542268 135.542268 0 1 0-271.084536 0Z" fill="#FFE770" p-id="23457"></path><path d="M218.864263 905.406558c-84.550754 0-153.337126-68.786372-153.337126-153.341154 0-84.546726 68.786372-153.337126 153.337126-153.337126s153.337126 68.786372 153.337126 153.337126c0 84.554783-68.786372 153.341155-153.337126 153.341154z m0-271.084536c-64.926857 0-117.74741 52.816524-117.74741 117.74741s52.820553 117.751439 117.74741 117.751439 117.751439-52.816524 117.751439-117.751439c-0.004029-64.930886-52.824581-117.74741-117.751439-117.74741z" fill="#6E6E96" p-id="23458"></path><path d="M305.248085 455.213172a61.824743 28.845638 0 1 0 123.649486 0 61.824743 28.845638 0 1 0-123.649486 0Z" fill="#FF0000" opacity=".18" p-id="23459"></path><path d="M615.403151 455.213172a61.820714 28.845638 0 1 0 123.641429 0 61.820714 28.845638 0 1 0-123.641429 0Z" fill="#FF0000" opacity=".18" p-id="23460"></path><path d="M410.079418 993.43007h193.378577a16.114881 16.114881 0 0 0 0-32.229763h-193.378577a16.114881 16.114881 0 0 0 0 32.229763z" fill="#6E6E96" opacity=".29" p-id="23461"></path><path d="M675.435114 993.43007h56.402085a16.114881 16.114881 0 0 0 0-32.229763h-56.402085a16.114881 16.114881 0 0 0 0 32.229763z" fill="#6E6E96" opacity=".17" p-id="23462"></path><path d="M332.993882 961.200307h-44.315924a16.114881 16.114881 0 0 0 0 32.229763h44.315924a16.114881 16.114881 0 0 0 0-32.229763z" fill="#6E6E96" opacity=".17" p-id="23463"></path></svg>
\ No newline at end of file
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770017871834" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1986" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256"><path d="M515.9424 512.768m-450.816 0a450.816 450.816 0 1 0 901.632 0 450.816 450.816 0 1 0-901.632 0Z" fill="#FFF1DC" p-id="1987"></path><path d="M516.4544 251.1872c69.4784 0 138.9568-0.1024 208.4352 0.0512 50.4832 0.1024 74.5472 23.9616 74.5472 73.8816 0.0512 124.9792 0.0512 250.0096 0 374.9888 0 49.8176-24.2176 74.0864-74.5984 74.1888-138.9568 0.1536-277.9136 0.1536-416.8192 0-52.0192-0.0512-75.4176-23.4496-75.4688-75.1616-0.1536-124.3648-0.1536-248.7296 0-373.0944 0.0512-51.3536 23.7568-74.7008 75.52-74.8032 69.4272-0.1536 138.9056-0.0512 208.384-0.0512z m-110.5408 281.5488c-36.864 48.896-72.2944 95.8976-109.6704 145.4592h439.6544c-48.0256-65.2288-94.0032-127.7952-141.2096-191.8976-37.632 48.1792-73.472 94.0544-110.0288 140.9024-26.9824-32.3584-51.968-62.4128-78.7456-94.464zM371.5072 439.296c32.768 0.1536 57.7024-24.4736 57.5488-56.9344-0.1536-30.976-25.4976-56.7808-56.1664-57.1904-31.3856-0.4608-58.4192 25.6512-58.7264 56.7296-0.3584 32.2048 24.6272 57.1904 57.344 57.3952z" fill="#FC7032" p-id="1988"></path><path d="M516.4544 251.1872c-69.4784 0-138.9568-0.1024-208.4352 0.0512-51.7632 0.1024-75.4688 23.4496-75.52 74.8032-0.1536 124.3648-0.1536 248.7296 0 373.0944 0.0512 51.712 23.4496 75.1104 75.4688 75.1616 85.0432 0.1024 170.1376 0 255.1808 0a452.77184 452.77184 0 0 0 98.7136-96.0512H296.192c37.376-49.5616 72.8064-96.5632 109.6704-145.4592 26.7264 32.0512 51.7632 62.1056 78.7456 94.464 36.608-46.848 72.448-92.7232 110.0288-140.9024 34.2016 46.4384 67.7376 92.1088 101.888 138.496 35.7888-64.6144 56.1664-138.9568 56.1664-218.0096 0-54.6816-9.728-107.0592-27.5456-155.5456h-0.3584c-69.376-0.2048-138.8544-0.1024-208.3328-0.1024zM371.5072 439.296c-32.7168-0.1536-57.7024-25.1904-57.3952-57.3952 0.3072-31.0784 27.3408-57.1904 58.7264-56.7296 30.6688 0.4608 56.0128 26.2144 56.1664 57.1904 0.2048 32.4096-24.7296 57.088-57.4976 56.9344z" fill="#FF7E3E" p-id="1989"></path><path d="M309.4528 678.1952h-13.1584c37.376-49.5616 72.8064-96.5632 109.6704-145.4592 18.6368 22.3744 36.5056 43.776 54.7328 65.6384 101.8368-81.7664 167.3728-206.7968 168.7552-347.2384-37.632 0-75.3152 0.0512-112.9472 0.0512-69.4784 0-138.9568-0.1024-208.4352 0.0512-51.7632 0.1024-75.4688 23.4496-75.52 74.8032-0.1536 122.7776-0.1024 245.504 0 368.2816a448.6656 448.6656 0 0 0 76.9024-16.128z m63.4368-353.024c30.6688 0.4608 56.0128 26.2144 56.1664 57.1904 0.1536 32.4096-24.7808 57.088-57.5488 56.9344-32.7168-0.1536-57.7024-25.1904-57.3952-57.3952 0.3072-31.0784 27.392-57.1904 58.7776-56.7296z" fill="#FF9552" p-id="1990"></path><path d="M351.232 435.6608c-22.1696-7.9872-37.376-28.672-37.12-53.8112 0.3072-31.0784 27.3408-57.1904 58.7264-56.7296 22.784 0.3072 42.5984 14.6432 51.3536 34.7136a451.1744 451.1744 0 0 0 61.0816-108.6976c-59.0848 0-118.1696-0.0512-177.2544 0.0512-51.7632 0.1024-75.4688 23.4496-75.52 74.8032-0.0512 59.9552 0 119.9104 0 179.8656A448.06656 448.06656 0 0 0 351.232 435.6608z" fill="#FFA56A" p-id="1991"></path></svg>
\ No newline at end of file
......@@ -9,25 +9,25 @@
</el-dropdown>
</template>
<script setup lang="ts">
<script setup lang="tsx">
import { addComplaint } from '@/api'
import type { ArticleItemDto } from '@/api/article/types'
import { push } from 'notivue'
import { useMessageBox } from '@/hooks'
const { prompt } = useMessageBox()
const { articleDetail } = defineProps<{
articleDetail: ArticleItemDto
}>()
const handleMore = async (command: string) => {
if (command === '举报') {
const { value } = await ElMessageBox.prompt('请输入举报原因', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /^\S+$/,
inputPlaceholder: '请输入举报原因',
inputErrorMessage: '举报原因不能为空',
const reason = await prompt({
title: '请输入要举报原因',
type: 'warning',
})
addComplaint({ articleId: articleDetail.id, reason: value })
ElMessage.success('举报成功')
addComplaint({ articleId: articleDetail.id, reason })
push.success('举报成功')
}
}
</script>
<template>
<div
class="absolute -left-12 top-2 z-10 flex flex-col items-center bg-white rounded-lg shadow-md shadow-black/6 border border-gray-100 cursor-pointer hover:shadow-lg hover:border-indigo-200 transition-all duration-300 group w-9 h-9 hover:h-18 overflow-hidden"
@click="handleBack"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-4.5 h-4.5 shrink-0 mt-2.25 text-gray-400 group-hover:text-indigo-500 transition-colors duration-200"
>
<polyline points="15 18 9 12 15 6" />
</svg>
<span
class="text-12px text-indigo-500 whitespace-nowrap opacity-0 group-hover:opacity-100 translate-y-(-1) group-hover:translate-y-0 transition-all duration-300 mt-1.5 tracking-[0.3em] leading-relaxed writing-vertical"
>
返回
</span>
</div>
</template>
<script setup lang="ts">
const { backHomePage = false } = defineProps<{
backHomePage?: boolean
}>()
const router = useRouter()
const handleBack = () => {
if (backHomePage) return router.push('/')
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
</script>
<style scoped>
.writing-vertical {
writing-mode: vertical-rl;
}
</style>
// 评论框的组件 用于评论的输入 以及 表情 图片的输入 以及 发表按钮
<script setup lang="ts">
import RichTextarea from '../RichTextarea/index.vue'
import UploadImgIcon from '../UploadImgIcon/index.vue'
import UploadEmojiIcon from '../UploadEmojiIcon/index.vue'
import { useUploadImg } from '@/hooks'
import type { IEmoji } from '@/utils/emoji/type'
interface CommentBoxProps {
textAreaHeight?: number
placeholder?: string
}
const { textAreaHeight = 55, placeholder = '请输入内容' } = defineProps<CommentBoxProps>()
const inputStr = defineModel<string>('inputText', { required: true })
const imgStrs = defineModel<string>('inputImg', { required: true })
const { uploadPercent, imgList, handleFileChange, handleDeleteImg } = useUploadImg(imgStrs)
const richTextareaRef = useTemplateRef<InstanceType<typeof RichTextarea>>('richTextareaRef')
const handleSelectEmoji = async (emoji: IEmoji) => {
const textarea = richTextareaRef.value?.getTextarea()
if (!textarea) return
// 当选中一段文本时 这俩值是不一样
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = inputStr.value
// 插入内容(你可以是 [微笑],也可以是 😀)
inputStr.value = value.slice(0, start) + emoji.name + value.slice(end)
// 插入后把光标放到表情后面
await nextTick()
textarea.focus()
textarea.selectionStart = textarea.selectionEnd = start + emoji.name.length
}
defineExpose({
focus: async () => {
await nextTick()
richTextareaRef.value?.getTextarea()?.focus()
},
})
</script>
<template>
<div>
<RichTextarea
ref="richTextareaRef"
v-model="inputStr"
:imgList="imgList"
:uploadPercent="uploadPercent"
@deleteImg="handleDeleteImg"
:height="textAreaHeight"
:placeholder="placeholder"
/>
<div class="flex justify-between items-center mt-3">
<div class="flex items-center gap-2">
<UploadImgIcon @fileChange="handleFileChange" />
<UploadEmojiIcon @selectEmoji="handleSelectEmoji" />
</div>
<div>
<!-- 插槽 用于插入 发表按钮 -->
<slot name="submit" />
</div>
</div>
</div>
</template>
<style scoped></style>
......@@ -9,30 +9,25 @@
<div class="flex gap-3">
<!-- 用户头像 -->
<el-avatar :size="40" :src="userInfo.hiddenAvatar" />
<!-- 评论输入框 -->
<el-input
v-model="commentContent"
type="textarea"
:rows="4"
placeholder="写下你的评论..."
maxlength="500"
show-word-limit
<!-- 删除按钮 -->
<CommentBox
class="flex-1"
/>
:textAreaHeight="100"
v-model:inputText="commentStr"
v-model:inputImg="commentImgStr"
>
<template #submit>
<el-button
:disabled="isDisabled"
:loading="loading"
type="primary"
@click="handleSubmit"
class="px-6 py-2 bg-blue-500 hover:bg-blue-600 rounded-lg text-white text-sm font-medium shadow-sm hover:shadow-md transition-all duration-200"
>发表</el-button
>
</template>
</CommentBox>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<el-button @click="handleClose" class="rounded-lg">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
class="px-6 py-2 bg-blue-500 hover:bg-blue-600 rounded-lg text-white text-sm font-medium shadow-sm hover:shadow-md transition-all duration-200"
>发表</el-button
>
</div>
</template>
</el-dialog>
</template>
......@@ -40,48 +35,60 @@
import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia'
import { addComment } from '@/api'
import CommentBox from '../CommentBox/index.vue'
import { push } from 'notivue'
const emit = defineEmits<{
(e: 'commentSuccess'): void
}>()
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
// 弹窗显示状态
const visible = ref(false)
// 评论内容
const commentContent = ref('')
const commentStr = ref('')
const commentImgStr = ref('')
const loading = ref(false)
const isDisabled = computed(() => !commentStr.value.trim() || loading.value)
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
let articleId = 0
// 暴露 open 方法
const open = (id: number) => {
articleId = id
visible.value = true
commentContent.value = ''
commentStr.value = ''
commentImgStr.value = ''
}
// 关闭弹窗
const handleClose = () => {
visible.value = false
commentContent.value = ''
commentStr.value = ''
commentImgStr.value = ''
}
// 提交评论
const handleSubmit = async () => {
if (!commentContent.value.trim()) {
ElMessage.warning('请输入评论内容')
return
}
loading.value = true
// TODO: 这里处理提交逻辑
await addComment({
articleId: articleId,
content: commentContent.value,
})
console.log('评论内容:', commentContent.value)
ElMessage.success('评论发表成功')
handleClose()
emit('commentSuccess')
try {
await addComment({
articleId: articleId,
content: commentStr.value,
imgUrl: commentImgStr.value,
})
console.log('评论内容:', commentStr.value)
push.success('评论发表成功')
handleClose()
emit('commentSuccess')
} catch (error) {
console.log(error)
} finally {
loading.value = false
}
}
// 暴露方法给父组件
......@@ -89,7 +96,3 @@ defineExpose({
open,
})
</script>
<style scoped>
/* 如果需要额外样式可以在这里添加 */
</style>
<!-- 导出excel按钮组件 后端直接返回纯数据 前端根据数据处理生成excel -->
<script setup lang="tsx" generic="T, K">
import { exportExcel, type ExportColumn } from '@/utils'
import type { BackendServiceResult } from '@/utils/request/types'
const {
api,
searchParams,
fileName = '导出数据',
columns,
} = defineProps<{
api: (params: T) => Promise<BackendServiceResult<K[]>>
searchParams: T
columns: ExportColumn<K>[]
fileName: string
}>()
const loading = ref(false)
const handleExport = async () => {
loading.value = true
try {
const { data } = await api(searchParams)
loading.value = false
exportExcel({
data,
columns,
fileName,
})
} catch (error) {
console.log(error)
} finally {
loading.value = false
}
}
</script>
<template>
<el-button :loading="loading" type="primary" @click="handleExport">
<el-icon v-show="!loading"><IEpDownload /></el-icon>
导出
</el-button>
</template>
<!-- 导入excel按钮组件 前端传二进制文件 -->
<script setup lang="tsx" generic="T">
import type { BackendServiceResult } from '@/utils/request/types'
import { push } from 'notivue'
const props = defineProps<{
api: (file: File, onProgress?: (progress: number) => void) => Promise<BackendServiceResult<T[]>>
}>()
const emit = defineEmits<{
success: []
error: [errorList: T[]]
}>()
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
const uploadProgress = ref(0)
const handleChange = async (e: Event) => {
uploadProgress.value = 0
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const notificationInstance = ElNotification({
title: '上传文件进度',
message: () => (
<div>
<el-progress
indeterminate
percentage={uploadProgress.value}
duration={0}
status={uploadProgress.value === 100 ? 'success' : ''}
/>
</div>
),
duration: 0,
})
try {
await props.api(file, (progress) => {
uploadProgress.value = progress
})
setTimeout(() => {
push.success('上传成功')
}, 1000)
emit('success')
} catch (error: any) {
// 解析失败信息
console.error(error)
if (error.code === 501) {
// 部分上传的数据不对 有错误
emit('error', error.data as T[])
} else {
console.error(error)
emit('error', [])
}
} finally {
;(e.target as HTMLInputElement).value = ''
setTimeout(() => {
notificationInstance.close()
setTimeout(() => {
uploadProgress.value = 0
}, 300)
}, 1500)
}
}
</script>
<template>
<el-button :loading="uploadProgress > 0" type="primary" @click="fileInput?.click()">
<input type="file" accept=".xlsx" ref="fileInput" @change="handleChange" class="hidden" />
<el-icon v-show="uploadProgress === 0"><IEpUpload /></el-icon>
导入
</el-button>
</template>
<template>
<Teleport to="body">
<Transition name="confirm" @after-leave="emit('afterLeave')">
<div v-if="visible" class="fixed inset-0 z-3000 flex items-center justify-center p-4">
<!-- Backdrop -->
<div class="confirm-backdrop absolute inset-0 bg-black/20" />
<!-- Card -->
<div
class="confirm-card relative bg-white rounded-2xl max-w-full"
:style="{ width: normalizedWidth }"
>
<!-- 右上角关闭按钮 -->
<div class="absolute top-2 right-2 cursor-pointer" @click.stop="close">
<el-icon><Close /></el-icon>
</div>
<div class="px-6 py-5">
<!-- Icon -->
<div
class="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-xl"
:class="
type === 'danger'
? 'bg-red-50'
: type === 'warning'
? 'bg-yellow-50'
: 'bg-indigo-50/80'
"
>
<svg
class="w-5 h-5"
:class="
type === 'danger'
? 'text-red-500'
: type === 'warning'
? 'text-yellow-500'
: 'text-[#7083FF]'
"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<!-- Title -->
<h3 class="text-center text-base font-600 text-gray-800 leading-6">
{{ title }}
</h3>
<!-- Message -->
<p v-if="message" class="mt-1.5 text-center text-13px text-gray-400 leading-5">
<component :is="normalizedMessage" />
</p>
<!-- Actions -->
<div class="mt-5 grid grid-cols-2 gap-2.5">
<button
v-if="showCancelButton"
type="button"
class="confirm-btn cancel-btn"
@click.stop="onCancel"
>
{{ cancelText }}
</button>
<button
v-if="showConfirmButton"
type="button"
class="confirm-btn primary-btn"
:class="{ 'danger-btn': type === 'danger', 'warning-btn': type === 'warning' }"
:disabled="loading"
@click.stop="onConfirm"
>
<svg
v-if="loading"
class="animate-spin h-3.5 w-3.5 mr-1.5 text-white/70"
viewBox="0 0 24 24"
fill="none"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
class="opacity-25"
/>
<path
fill="currentColor"
class="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{{ confirmText }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { MessageBoxProps } from './types'
import { Close } from '@element-plus/icons-vue'
const {
title = '确认操作',
message = '',
confirmText = '确认',
cancelText = '取消',
width = 380,
loading = false,
type = 'primary',
showCancelButton = true,
showConfirmButton = true,
validate = null,
mousePosition,
} = defineProps<MessageBoxProps>()
const normalizedWidth = computed(() => (typeof width === 'number' ? `${width}px` : width))
const visible = defineModel<boolean>({ default: false })
const normalizedMousePosition = computed(() => {
return {
x: mousePosition.x + 'px',
y: mousePosition.y + 'px',
}
})
onMounted(() => {
console.log('onMounted')
})
onUpdated(() => {
console.log('onUpdated')
})
onUnmounted(() => {
console.log('onUnmounted')
})
const normalizedMessage = computed(() => {
if (!message) return ''
if (typeof message === 'function') {
return message
} else {
return () => message
}
})
const emit = defineEmits<{
confirm: [res: string | number | undefined]
cancel: []
close: []
afterLeave: []
}>()
const close = () => {
visible.value = false
emit('close')
}
const onCancel = () => {
emit('cancel')
close()
}
const onConfirm = async () => {
if (validate) {
const res = await validate?.()
emit('confirm', res as string | number)
close()
} else {
emit('confirm', undefined)
if (!loading) close()
}
}
const open = () => {
visible.value = true
}
defineExpose({ open, close })
</script>
<style scoped>
/* 背景透明度过渡:打开时 0→1,关闭时 1→0 */
.confirm-enter-active,
.confirm-leave-active {
transition: opacity 0.2s ease;
}
.confirm-enter-from {
opacity: 0;
}
.confirm-leave-to {
opacity: 0;
}
/* card 动画 */
.confirm-enter-active .confirm-card {
transition:
transform 0.2s cubic-bezier(0.22, 1.2, 0.36, 1),
opacity 0.2s ease;
}
.confirm-leave-active .confirm-card {
transition:
transform 0.2s ease,
opacity 0.2s ease;
}
/* 从点击点展开(先用固定坐标 200px,200px 测试动画是否生效) */
.confirm-enter-from .confirm-card {
/* transform: translate(calc(200px - 50vw), calc(200px - 50vh)) scale(0.1); */
transform: translate(
calc(v-bind('normalizedMousePosition.x') - 50vw),
calc(v-bind('normalizedMousePosition.y') - 50vh)
)
scale(0.1);
opacity: 0;
}
.confirm-enter-to .confirm-card {
transform: translate(0, 0) scale(1);
opacity: 1;
}
/* 关闭时缩回点击点 */
.confirm-leave-from .confirm-card {
transform: translate(0, 0) scale(1);
opacity: 1;
}
.confirm-leave-to .confirm-card {
transform: translate(
calc(v-bind('normalizedMousePosition.x') - 50vw),
calc(v-bind('normalizedMousePosition.y') - 50vh)
)
scale(0.1);
opacity: 0;
}
.confirm-card {
box-shadow:
0 8px 30px rgba(100, 116, 180, 0.16),
0 2px 6px rgba(0, 0, 0, 0.04);
}
.confirm-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
border: none;
outline: none;
}
.cancel-btn {
color: rgb(107 114 128);
background: #fff;
border: 1px solid rgb(229 231 235);
}
.cancel-btn:hover {
color: rgb(75 85 99);
background: rgb(249 250 251);
border-color: rgb(209 213 219);
}
.cancel-btn:active {
transform: scale(0.97);
}
.primary-btn {
color: #fff;
background: linear-gradient(135deg, #b3b8fd 0%, #7083ff 100%);
box-shadow: 0 2px 8px rgba(112, 131, 255, 0.3);
}
.primary-btn:hover {
box-shadow: 0 4px 14px rgba(112, 131, 255, 0.4);
filter: brightness(1.05);
}
.primary-btn:active {
transform: scale(0.97);
}
.primary-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.danger-btn {
background: linear-gradient(135deg, #fca5a5 0%, #ef4444 100%);
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
}
.danger-btn:hover {
box-shadow: 0 4px 14px rgba(239, 68, 68, 0.4);
}
.warning-btn {
background: linear-gradient(135deg, #ffa500 0%, #ff8c00 100%);
box-shadow: 0 2px 8px rgba(255, 165, 0, 0.3);
}
.warning-btn:hover {
box-shadow: 0 4px 14px rgba(255, 165, 0, 0.4);
}
</style>
import type { VNode, Component } from 'vue'
export interface MessageBoxProps {
title?: string
message?: string | (() => VNode) | Component
confirmText?: string
cancelText?: string
width?: string | number
loading?: boolean
type?: 'primary' | 'warning' | 'danger'
showCancelButton?: boolean
showConfirmButton?: boolean
validate?: () => Promise<string | number> | null
mousePosition: { x: number; y: number }
}
<template>
<el-dialog v-model="dialogVisible" title="选择标签" width="500px" :close-on-click-modal="false">
<el-dialog
v-model="dialogVisible"
title="选择标签"
width="500px"
:close-on-click-modal="false"
top="30vh"
>
<div class="space-y-6 px-2">
<div class="flex items-start gap-4">
<div class="text-sm text-gray-700 w-16 flex-shrink-0">主标签</div>
......
......@@ -2,6 +2,7 @@
<div class="bg-white p-6 mb-6 rounded-lg shadow-sm">
<div class="flex-1 bg-white rounded-lg border border-gray-200">
<!-- 主输入区域 -->
<div class="flex gap-3 mb-2 items-start">
<!-- 用户头像 -->
<el-avatar
......@@ -28,18 +29,15 @@
</div>
<!-- 主要内容输入 -->
<div class="relative mb-3">
<el-input
type="textarea"
<RichTextarea
:placeholder="textMap[type].content"
:rows="6"
:maxlength="maxLength"
show-word-limit
resize="none"
class="main-textarea"
:imgList="imgList"
:uploadPercent="uploadPercent"
v-model="form.content"
@deleteImg="handleDeleteImg"
:height="100"
/>
<!-- 字符计数 -->
<!-- <div class="absolute bottom-3 right-3 text-xs text-gray-400">1/30</div> -->
</div>
<!-- 标签内容 -->
<div class="mb-2">
......@@ -55,34 +53,6 @@
</span>
</div>
</div>
<!-- 图片相关 -->
<div
v-if="form.imgUrl.length"
class="flex flex-wrap gap-2 w-fit"
v-loading="uploadPercent > 0"
:element-loading-text="uploadPercent + '%'"
>
<!-- 删除图片 -->
<div
class="relative w-20 h-20 rounded-lg overflow-hidden group"
v-for="img in form.imgUrl"
:key="img"
>
<div
class="absolute top-1 right-1 z-10 w-5 h-5 flex items-center justify-center bg-black/60 rounded-full cursor-pointer opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-black/80 hover:scale-110"
@click="handleDeleteImg(img)"
>
<el-icon class="text-white text-xs">
<IEpClose />
</el-icon>
</div>
<el-image
:src="img"
class="w-full h-full rounded-lg border border-gray-200"
fit="cover"
/>
</div>
</div>
</div>
</div>
......@@ -105,46 +75,20 @@
</el-button>
</el-tooltip>
<!-- 隐藏上传文件的input -->
<input
type="file"
class="hidden"
ref="fileInputRef"
@change="handleFileChange"
accept="image/*"
multiple
/>
<el-tooltip content="添加图片" placement="top">
<el-button
text
class="w-10 h-10 text-gray-500 hover:bg-gray-100 hover:text-gray-700 rounded-lg"
@click="fileInputRef?.click()"
:disabled="uploadPercent > 0"
>
<el-icon size="18">
<span v-if="!form.imgUrl.length && uploadPercent > 0"> <IEpLoading /></span>
<span v-else> <IEpPicture /></span>
</el-icon>
</el-button>
</el-tooltip>
<!-- <el-tooltip content="添加视频" placement="top">
<UploadImgIcon @fileChange="handleFileChange">
<el-tooltip content="添加图片" placement="top">
<el-button
text
class="w-10 h-10 text-gray-500 hover:bg-gray-100 hover:text-gray-700 rounded-lg"
:disabled="uploadPercent > 0"
>
<el-icon size="18"><VideoPlay /></el-icon>
<el-icon size="18">
<span v-if="!imgList.length && uploadPercent > 0"> <IEpLoading /></span>
<span v-else> <IEpPicture /></span>
</el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="添加附件" placement="top">
<el-button
text
class="w-10 h-10 text-gray-500 hover:bg-gray-100 hover:text-gray-700 rounded-lg"
>
<el-icon size="18"><Paperclip /></el-icon>
</el-button>
</el-tooltip> -->
</UploadImgIcon>
</div>
<!-- 右侧操作按钮 -->
......@@ -159,6 +103,7 @@
<el-button
type="primary"
:disabled="disabledSubmit"
:loading="loading"
class="px-6 py-2 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-white text-sm font-medium shadow-sm hover:shadow-md transition-all duration-200"
@click="handlePublish(ReleaseStatusTypeEnum.PUBLISH)"
>
......@@ -188,22 +133,22 @@ import { ArticleTypeEnum, ReleaseStatusTypeEnum, SendTypeEnum } from '@/constant
import { useTagsStore } from '@/stores'
import { addOrUpdatePractice, addOrUpdateArticle } from '@/api'
import type { AddOrUpdatePracticeDto } from '@/api'
import type { BooleanFlag } from '@/constants'
import type { ElButton } from 'element-plus'
import { useAnimate } from '@vueuse/core'
import RichTextarea from '../RichTextarea/index.vue'
import UploadImgIcon from '../UploadImgIcon/index.vue'
import { push } from 'notivue'
// 暂时只有 问吧 和 实践 需要发布框 这俩都需要实名
type ArticleType = ArticleTypeEnum.QUESTION | ArticleTypeEnum.PRACTICE
const {
type,
isReal,
maxLength = 500,
} = defineProps<{
const { type, maxLength = 500 } = defineProps<{
type: ArticleType
isReal: BooleanFlag
maxLength?: number
}>()
const loading = ref(false)
const textMap: Record<
ArticleType,
{ title: string; content: string; api: (data: any) => Promise<any> }
......@@ -225,12 +170,19 @@ const { tagList } = storeToRefs(tagsStore)
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const userAvatar = computed(() => (isReal ? userInfo.value.avatar : userInfo.value.hiddenAvatar))
// 问吧 和 实践 需要实名 后续如果要改在这里改
const isReal = computed(
() => type === ArticleTypeEnum.PRACTICE || type === ArticleTypeEnum.QUESTION,
)
const userAvatar = computed(() =>
isReal.value ? userInfo.value.avatar : userInfo.value.hiddenAvatar,
)
const selectTagsDialogRef =
useTemplateRef<InstanceType<typeof SelectTagsDialog>>('selectTagsDialogRef')
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
// const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const openTour = ref(false)
......@@ -260,7 +212,7 @@ const { play } = useAnimate(
const [form, resetForm] = useResetData({
title: '',
content: '',
imgUrl: [] as string[],
imgUrl: '',
releaseStatus: ReleaseStatusTypeEnum.PUBLISH,
mainTagId: '',
tagList: [],
......@@ -268,7 +220,7 @@ const [form, resetForm] = useResetData({
sendTime: '',
})
const { imgsStr, handleFileChange, handleDeleteImg, uploadPercent } = useUploadImg(
const { imgList, handleFileChange, handleDeleteImg, uploadPercent } = useUploadImg(
toRef(form.value, 'imgUrl'),
)
......@@ -286,10 +238,7 @@ const handleAddTag = () => {
const validateForm = () => {
if (!form.value.mainTagId) {
ElMessage.warning({
message: '请选择主标签',
offset: 200,
})
push.warning('请选择主标签')
play()
visibleTagTooltip.value = true
......@@ -302,8 +251,8 @@ const transformForm = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdatePractic
return {
...form.value,
releaseStatus,
faceUrl: form.value.imgUrl[0] || '',
imgUrl: imgsStr.value,
faceUrl: imgList.value[0] || '',
imgUrl: form.value.imgUrl,
tagList: [form.value.mainTagId, ...form.value.tagList].map((item, index) => ({
sort: index,
tagId: Number(item),
......@@ -314,9 +263,17 @@ const transformForm = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdatePractic
const handlePublish = async (releaseStatus: ReleaseStatusTypeEnum) => {
if (!validateForm()) return
await textMap[type].api(transformForm(releaseStatus))
ElMessage.success(releaseStatus === ReleaseStatusTypeEnum.PUBLISH ? '发布成功' : '存草稿成功')
resetForm()
loading.value = true
try {
await textMap[type].api(transformForm(releaseStatus))
loading.value = false
push.success(releaseStatus === ReleaseStatusTypeEnum.PUBLISH ? '发布成功' : '存草稿成功')
resetForm()
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
</script>
......
<template>
<span @click="handleClick">
<slot></slot>
</span>
</template>
<script setup lang="ts">
import { useActivityStore } from '@/stores/activity'
import { RewardButtonEnum } from '@/constants'
const { pageKey } = defineProps<{
pageKey: RewardButtonEnum
}>()
const activityStore = useActivityStore()
// 调用接口让他跳出动画
const handleClick = () => {
console.log('点击了')
activityStore.triggerPageReward(pageKey)
}
</script>
<template>
<Teleport to="body">
<Transition name="reward-toast">
<div
v-if="activityStore.showRewardAnimation"
class="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none"
>
<div class="relative flex flex-col items-center gap-4">
<!-- 光晕背景 -->
<div class="absolute inset--20 rounded-full bg-amber-400/10 blur-3xl animate-pulse" />
<!-- 碎片 +1 -->
<div class="reward-item reward-item-1">
<div
class="flex items-center gap-2.5 px-6 py-3 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 shadow-[0_4px_24px_rgba(99,102,241,0.5)]"
>
<span class="text-2xl">🧩</span>
<span class="text-white text-lg font-bold tracking-wide"
>碎片 +{{ activityStore.rewardText.fragment }}</span
>
</div>
</div>
<!-- 亚币 +2 -->
<div class="reward-item reward-item-2">
<div
class="flex items-center gap-2.5 px-6 py-3 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 shadow-[0_4px_24px_rgba(245,158,11,0.5)]"
>
<span class="text-2xl">💰</span>
<span class="text-white text-lg font-bold tracking-wide"
>亚币 +{{ activityStore.rewardText.yabi }}</span
>
</div>
</div>
<!-- 星星粒子效果 -->
<div class="absolute inset--10">
<span v-for="i in 8" :key="i" class="particle" :style="getParticleStyle(i)" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { useActivityStore } from '@/stores'
const activityStore = useActivityStore()
const getParticleStyle = (index: number) => {
const angle = (index / 8) * 360
const delay = index * 0.1
return {
'--angle': `${angle}deg`,
'--delay': `${delay}s`,
}
}
</script>
<style scoped>
.reward-toast-enter-active {
transition: opacity 0.3s ease;
}
.reward-toast-leave-active {
transition: opacity 0.5s ease 1.8s;
}
.reward-toast-enter-from,
.reward-toast-leave-to {
opacity: 0;
}
.reward-item {
opacity: 0;
animation: rewardSlideUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.reward-item-1 {
animation-delay: 0.1s;
}
.reward-item-2 {
animation-delay: 0.35s;
}
@keyframes rewardSlideUp {
0% {
opacity: 0;
transform: translateY(30px) scale(0.7);
}
60% {
opacity: 1;
transform: translateY(-8px) scale(1.05);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.particle {
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
background: linear-gradient(135deg, #fbbf24, #a78bfa);
opacity: 0;
animation: particleBurst 1.2s ease-out var(--delay) forwards;
}
@keyframes particleBurst {
0% {
opacity: 1;
transform: translate(-50%, -50%) rotate(var(--angle)) translateX(0) scale(1);
}
70% {
opacity: 0.8;
transform: translate(-50%, -50%) rotate(var(--angle)) translateX(80px) scale(0.8);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--angle)) translateX(120px) scale(0);
}
}
</style>
<script setup lang="ts">
// 展示一个textarea 里面可展示图片等(暂时只加入了 图片 后续若有其他的 再添加)
// 暂时用到了快捷发布问吧 和 发布实践 以及 评论相关的内容
interface RichTextareaProps {
placeholder?: string
maxlength?: number
imgList: string[]
uploadPercent: number
height?: number
}
interface Emits {
deleteImg: [img: string]
}
const {
placeholder = '请输入内容',
maxlength,
imgList,
uploadPercent,
height = 55,
} = defineProps<RichTextareaProps>()
const emit = defineEmits<Emits>()
const inputStr = defineModel<string>({ required: true })
const textareaRef = useTemplateRef<HTMLTextAreaElement>('textareaRef')
defineExpose({
getTextarea: () => textareaRef.value,
})
</script>
<template>
<div
style="border: 1px solid rgb(229, 231, 235)"
class="relative w-full rounded-lg border border-gray-200 px-3 py-2 transition focus-within:border-[var(--el-color-primary)] focus-within:ring-1 focus-within:ring-[var(--el-color-primary)] bg-white"
>
<!-- 文本输入区 -->
<textarea
ref="textareaRef"
v-model="inputStr"
:placeholder="placeholder"
class="w-full resize-none border-none outline-none text-sm leading-5 text-gray-800"
:style="{ height: height + 'px' }"
:maxlength="maxlength"
/>
<!-- 定位到右边 -->
<span v-if="maxlength" class="flex justify-end text-xs text-gray-400">
{{ inputStr?.length }} / {{ maxlength }}
</span>
<div class="flex justify-between items-center mt-2">
<!-- 图片列表展示 -->
<div
v-if="imgList.length"
class="flex flex-wrap gap-2"
v-loading="uploadPercent > 0"
:element-loading-text="uploadPercent + '%'"
>
<div
class="relative w-20 h-20 rounded-lg overflow-hidden group"
v-for="img in imgList"
:key="img"
>
<!-- 删除按钮 -->
<div
class="absolute top-1 right-1 z-10 w-5 h-5 flex items-center justify-center bg-black/60 rounded-full cursor-pointer opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-black/80 hover:scale-110"
@click="emit('deleteImg', img)"
>
<el-icon class="text-white text-xs">
<IEpClose />
</el-icon>
</div>
<el-image
:src="img"
class="w-full h-full rounded-lg border border-gray-200"
fit="cover"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>
<template>
<el-dialog
v-model="activityStore.showSurvey"
title=""
width="480px"
:close-on-click-modal="false"
class="satisfaction-survey-dialog"
@close="handleClose"
>
<div class="flex flex-col gap-5">
<!-- 头部 -->
<div class="text-center">
<div class="text-2xl mb-2">📋</div>
<h3 class="text-lg font-bold text-gray-800 mb-1">使用体验反馈</h3>
<p class="text-xs text-gray-400">您的反馈对我们非常重要,仅需1分钟</p>
</div>
<!-- Q1: 满意度评分 -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-gray-700">整体满意度</label>
<div class="flex items-center justify-center gap-2">
<button
v-for="star in 5"
:key="star"
class="text-2xl transition-all duration-200 cursor-pointer hover:scale-125 border-none bg-transparent p-1"
:class="star <= form.rating ? 'grayscale-0' : 'grayscale opacity-30'"
@click="form.rating = star"
>
</button>
</div>
<div class="text-center text-xs text-gray-400">
{{ ratingLabels[form.rating] || '请点击星星评分' }}
</div>
</div>
<!-- Q2: 最喜欢的功能 -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-gray-700">最喜欢的功能(可多选)</label>
<div class="flex flex-wrap gap-2">
<button
v-for="feat in featureOptions"
:key="feat.value"
class="px-3 py-1.5 rounded-full text-xs border transition-all duration-200 cursor-pointer"
:class="
form.favoriteFeatures.includes(feat.value)
? 'bg-indigo-50 border-indigo-300 text-indigo-600'
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-300'
"
@click="toggleFeature(feat.value)"
>
{{ feat.label }}
</button>
</div>
</div>
<!-- Q3: 改进建议 -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-gray-700">改进建议(选填)</label>
<el-input
v-model="form.suggestion"
type="textarea"
:rows="3"
placeholder="说说您的想法,帮助我们做得更好..."
maxlength="300"
show-word-limit
/>
</div>
<!-- 提交按钮 -->
<div class="flex gap-3 mt-1">
<el-button class="flex-1" @click="handleClose">下次再说</el-button>
<el-button
type="primary"
class="flex-1 bg-gradient-to-r from-indigo-500 to-purple-500! border-none!"
:disabled="form.rating === 0"
@click="handleSubmit"
>
提交反馈
</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { useActivityStore } from '@/stores'
import { submitSurvey } from '@/api'
import { push } from 'notivue'
const activityStore = useActivityStore()
const form = reactive({
rating: 0,
favoriteFeatures: [] as string[],
suggestion: '',
})
const ratingLabels: Record<number, string> = {
1: '非常不满意',
2: '不太满意',
3: '一般',
4: '比较满意',
5: '非常满意',
}
const featureOptions = [
{ value: 'post', label: '帖子/实践' },
{ value: 'video', label: '视频' },
{ value: 'question', label: '问吧' },
{ value: 'sign', label: '签到/任务' },
{ value: 'store', label: '积分商城' },
{ value: 'auction', label: '限时竞拍' },
{ value: 'lottery', label: '每日抽奖' },
{ value: 'column', label: '专栏/专访' },
]
const toggleFeature = (value: string) => {
const idx = form.favoriteFeatures.indexOf(value)
if (idx >= 0) {
form.favoriteFeatures.splice(idx, 1)
} else {
form.favoriteFeatures.push(value)
}
}
const handleSubmit = async () => {
if (form.rating === 0) return
try {
await submitSurvey({
rating: form.rating,
favoriteFeatures: form.favoriteFeatures,
suggestion: form.suggestion,
})
push.success('感谢您的反馈!')
activityStore.markSurveySubmitted()
} catch {
push.success('感谢您的反馈!')
activityStore.markSurveySubmitted()
}
}
const handleClose = () => {
activityStore.showSurvey = false
}
</script>
<style>
.satisfaction-survey-dialog .el-dialog__header {
display: none;
}
.satisfaction-survey-dialog .el-dialog__body {
padding: 24px;
}
</style>
<template>
<div class="tag-selector">
<!-- 已选标签区域 -->
<div class="selected-tags">
<!-- 添加标签按钮 -->
<el-popover placement="top" :width="300" trigger="click">
<template #reference>
<el-button class="button-new-tag" size="small"> + 添加标签 </el-button>
</template>
<!-- 暂时不加搜索输入框 -->
<!-- <div class="search-section mb-4">
<el-input
v-model="searchKeyword"
placeholder="Enter键添加标签"
class="search-input"
clearable
@keyup.enter="addTagFromSearch"
>
<template #suffix>
<span class="text-gray-400 text-sm"
>{{ searchKeyword.length }} / {{ maxTagLength }}</span
>
</template>
</el-input>
</div> -->
<!-- 推荐标签区域 -->
<div class="recommended-section">
<div class="section-title mb-3 text-gray-600 text-sm">官方标签列表</div>
<div class="tags-grid">
<el-tag
v-for="tag in filteredRecommendedTags"
:key="tag.id"
:type="arryrOfModelValue.includes(tag.id) ? 'primary' : 'info'"
:effect="arryrOfModelValue.includes(tag.id) ? 'dark' : 'plain'"
class="tag-item cursor-pointer mr-2 mb-2"
@click="toggleTag(tag.id)"
>
{{ tag.title }}
</el-tag>
</div>
</div>
</el-popover>
<div class="flex flex-wrap gap-2 mt-2">
<el-tag
v-for="tag in selectedTags"
:key="tag.id"
closable
type="primary"
@close="removeTag(tag.id)"
>
{{ tag.title }}
</el-tag>
</div>
</div>
</div>
</template>
// 选中的可能是 可能是 多选 [id1,id2] 或者 'id1,id2' 或者 (单选 number这种不考虑处理了
可以在父组件自己处理)
// 选中的可能是 可能是 多选: [id1,id2] 或者 'id1,id2' 单选 'id1' 单选 number这种不考虑处理了
<script setup lang="ts" generic="T extends string | number[] | number">
import { useTagsStore } from '@/stores/tags'
import { storeToRefs } from 'pinia'
import type { TagItemDto } from '@/api/tag/types'
import type { SelectTagProps } from './types'
const { maxSelectedTags = 1, filterTagsFn, tagType = 'culture' } = defineProps<SelectTagProps>()
import { TagTypeEnum } from '@/constants'
import { push } from 'notivue'
const {
maxSelectedTags = 1,
filterTagsFn,
tagType = TagTypeEnum.CULTURE_TAG,
} = defineProps<SelectTagProps>()
const emit = defineEmits<{
selected: [tag?: TagItemDto]
}>()
const tagsStore = useTagsStore()
const { tagList: cultureTagList, relatedScenariosTagList } = storeToRefs(tagsStore)
const tagList = computed<TagItemDto[]>(() =>
tagType === 'culture' ? cultureTagList.value : relatedScenariosTagList.value,
)
const {
tagList: cultureTagList,
relatedScenariosTagList,
yearRecommendTagList,
} = storeToRefs(tagsStore)
const tagList = computed<TagItemDto[]>(() => {
if (tagType === TagTypeEnum.CULTURE_TAG) {
return cultureTagList.value
} else if (tagType === TagTypeEnum.SCENE_TAG) {
return relatedScenariosTagList.value
} else if (tagType === TagTypeEnum.YEAR_TAG) {
return yearRecommendTagList.value
}
return []
})
const filterTags = computed(() => {
if (filterTagsFn) {
......@@ -136,10 +93,10 @@ const filteredRecommendedTags = computed(() => {
const addTag = (tagId: number) => {
if (arryrOfModelValue.value.length >= maxSelectedTags)
return ElMessage.warning(`最多只能选择 ${maxSelectedTags} 个标签`)
return push.warning(`最多只能选择 ${maxSelectedTags} 个标签`)
// 不能直接push 触发不了computed 的 set
arryrOfModelValue.value = [...arryrOfModelValue.value, tagId]
ElMessage.success('标签添加成功')
push.success('标签添加成功')
emit('selected', filterTags.value.find((tag) => tag.id === tagId)!)
}
......@@ -160,3 +117,63 @@ const toggleTag = (tagId: number) => {
}
}
</script>
<template>
<div class="tag-selector">
<!-- 已选标签区域 -->
<div class="selected-tags">
<!-- 添加标签按钮 -->
<el-popover placement="top" :width="300" trigger="click">
<template #reference>
<el-button class="button-new-tag" size="small"> + 添加标签 </el-button>
</template>
<!-- 暂时不加搜索输入框 -->
<!-- <div class="search-section mb-4">
<el-input
v-model="searchKeyword"
placeholder="Enter键添加标签"
class="search-input"
clearable
@keyup.enter="addTagFromSearch"
>
<template #suffix>
<span class="text-gray-400 text-sm"
>{{ searchKeyword.length }} / {{ maxTagLength }}</span
>
</template>
</el-input>
</div> -->
<!-- 推荐标签区域 -->
<div class="recommended-section">
<div class="section-title mb-3 text-gray-600 text-sm">官方标签列表</div>
<div class="tags-grid">
<el-tag
v-for="tag in filteredRecommendedTags"
:key="tag.id"
:type="arryrOfModelValue.includes(tag.id) ? 'primary' : 'info'"
:effect="arryrOfModelValue.includes(tag.id) ? 'dark' : 'plain'"
class="tag-item cursor-pointer mr-2 mb-2"
@click="toggleTag(tag.id)"
>
{{ tag.title }}
</el-tag>
</div>
</div>
</el-popover>
<div class="flex flex-wrap gap-2 mt-2">
<el-tag
v-for="tag in selectedTags"
:key="tag.id"
closable
type="primary"
@close="removeTag(tag.id)"
>
{{ tag.title }}
</el-tag>
</div>
</div>
</div>
</template>
import type { TagItemDto } from '@/api/tag/types'
import { TagTypeEnum } from '@/constants'
export type SelectTagProps = {
maxSelectedTags?: number
filterTagsFn?: (tags: TagItemDto[]) => TagItemDto[]
tagType?: 'culture' | 'related_scenarios'
tagType?: TagTypeEnum
}
import type { SetupContext } from 'vue'
import RewardButton from '@/components/common/RewardButton/index.vue'
import { RewardButtonEnum } from '@/constants'
type TypeOfValue = string | number
interface TabsProps<T> {
tabs: {
label: string
value: T
pageKey?: RewardButtonEnum
}[]
modelValue: T
}
......@@ -25,21 +28,41 @@ export default function Tabs<T extends TypeOfValue>(
{ tabs, modelValue }: TabsProps<T>,
{ emit }: SetupContext<TabsEmits<T>>,
) {
return tabs.map((tab) => (
<div
key={tab.value}
class={[
BASE_TAB_CALASSES,
{
[ACTIVE_TAB_CLASSES]: modelValue === tab.value,
},
]}
onClick={() => {
emit('update:modelValue', tab.value)
emit('change', tab.value)
}}
>
{tab.label}
</div>
))
return tabs.map((tab) =>
tab.pageKey ? (
<RewardButton pageKey={tab.pageKey}>
<div
key={tab.value}
class={[
BASE_TAB_CALASSES,
{
[ACTIVE_TAB_CLASSES]: modelValue === tab.value,
},
]}
onClick={() => {
emit('update:modelValue', tab.value)
emit('change', tab.value)
}}
>
{tab.label}
</div>
</RewardButton>
) : (
<div
key={tab.value}
class={[
BASE_TAB_CALASSES,
{
[ACTIVE_TAB_CLASSES]: modelValue === tab.value,
},
]}
onClick={() => {
emit('update:modelValue', tab.value)
emit('change', tab.value)
}}
>
{tab.label}
</div>
),
)
}
<script setup lang="ts">
import emojis from '@/utils/emoji/face.json'
import type { IEmoji } from '@/utils/emoji/type'
const emit = defineEmits<{
selectEmoji: [emoji: IEmoji]
}>()
</script>
<template>
<div
class="w-8 h-8 text-gray-500 hover:bg-gray-100 hover:text-gray-700 rounded-lg flex items-center justify-center"
>
<el-popover placement="bottom" trigger="click" width="384">
<template #reference>
<el-icon size="20" @mousedown.prevent>
<svg-icon name="icon_face" class="cursor-pointer"
/></el-icon>
</template>
<!-- 表情面板 -->
<el-scrollbar class="h-50">
<div class="flex flex-wrap">
<span
v-for="item in emojis"
:key="item.name"
class="cursor-pointer hover:bg-gray-100 rounded p-1 flex items-center justify-center"
@click="emit('selectEmoji', item)"
>
<img :src="item.url" alt="" class="w-6 h-6" />
</span>
</div>
</el-scrollbar>
</el-popover>
</div>
</template>
<style scoped></style>
......@@ -28,6 +28,8 @@ import { Plus } from '@element-plus/icons-vue'
import { uploadFile as uploadFileApi } from '@/api'
import type { UploadProps, UploadUserFile } from 'element-plus'
import type { UploadFileProps } from './types'
import { push } from 'notivue'
import { useMessageBox } from '@/hooks'
const props = withDefaults(defineProps<UploadFileProps>(), {
limit: 2,
......@@ -37,6 +39,7 @@ const props = withDefaults(defineProps<UploadFileProps>(), {
const modelValue = defineModel<T>({
required: true,
})
const { confirm } = useMessageBox()
const fileList = ref<UploadUserFile[]>([])
const uploadRef = useTemplateRef('uploadRef')
......@@ -104,7 +107,7 @@ watch(
const handleExceed: UploadProps['onExceed'] = (uploadFiles) => {
console.log('uploadFiles', uploadFiles)
if (uploadFiles.length > props.limit) {
ElMessage.error(`最多上传 ${props.limit} 个文件`)
push.error(`最多上传 ${props.limit} 个文件`)
return
}
}
......@@ -112,7 +115,7 @@ const handleExceed: UploadProps['onExceed'] = (uploadFiles) => {
const handleChange: UploadProps['onChange'] = async (uploadFile, uploadFiles) => {
console.log('uploadFiles', uploadFiles)
if (uploadFiles.length > props.limit) {
ElMessage.error(`最多上传 ${props.limit} 个文件`)
push.error(`最多上传 ${props.limit} 个文件`)
const index = fileList.value.findIndex((file) => file.uid === uploadFile.uid)
if (index !== -1) {
fileList.value.splice(index, 1)
......@@ -151,13 +154,13 @@ const handleChange: UploadProps['onChange'] = async (uploadFile, uploadFiles) =>
name,
status: 'success',
}
ElMessage.success('上传成功')
push.success('上传成功')
} else {
console.warn('找不到对应的文件,uid:', uid)
}
} catch (error) {
console.error('上传失败:', error)
ElMessage.error('上传失败,请重试')
push.error('上传失败,请重试')
const fileIndex = fileList.value.findIndex((file) => file.uid === uid)
if (fileIndex !== -1) {
......@@ -170,10 +173,11 @@ const handleChange: UploadProps['onChange'] = async (uploadFile, uploadFiles) =>
}
const handleBeforeRemove: UploadProps['beforeRemove'] = () => {
return ElMessageBox.confirm('确定要删除这个文件吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
return confirm({
title: '确定要删除这个文件吗?',
confirmText: '确定',
cancelText: '取消',
type: 'danger',
})
.then(() => true)
.catch(() => false)
......
<script lang="tsx">
import type { SetupContext } from 'vue'
interface Emits {
fileChange: [e: Event]
}
export default defineComponent({
setup(_, { slots, emit }: SetupContext<Emits>) {
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const handleFileChange = (e: Event) => {
emit('fileChange', e)
}
return () => {
const UploadImgIcon = slots.default ? (
slots.default()
) : (
<div class="w-8 h-8 text-gray-500 hover:bg-gray-100 hover:text-gray-700 rounded-lg flex items-center justify-center">
<el-icon size="30" class="cursor-pointer">
<svg-icon name="icon_picture" />
</el-icon>
</div>
)
return (
<div>
<input
type="file"
class="hidden"
ref="fileInputRef"
onChange={handleFileChange}
accept="image/*"
multiple
/>
<div onClick={() => fileInputRef?.value?.click()}>{UploadImgIcon}</div>
</div>
)
}
},
})
</script>
......@@ -36,24 +36,28 @@
</div>
<!-- 上传完成 -->
<div v-if="videoInfo && !uploading" class="upload-success">
<div class="video-preview">
<video
:src="videoInfo.url"
poster="@/assets/img/culture/ask.png"
class="video-thumbnail"
muted
></video>
<div class="video-overlay">
<el-button type="primary" size="small" @click="replaceVideo"> 重新选择 </el-button>
<div v-if="videoInfo && !uploading" class="upload-success-optimized">
<div class="video-info-display">
<div class="video-icon-wrapper">
<el-icon class="video-preview-icon"><IEpVideoCamera /></el-icon>
</div>
<div class="video-details-text">
<p class="video-name">{{ videoInfo.name }}</p>
<p class="video-meta">
<span v-if="videoInfo.size"> {{ formatFileSize(videoInfo.size) }} </span> ·
<span v-if="videoInfo.duration"> {{ videoInfo.duration }} </span> ·
<span v-if="videoInfo.resolution"> {{ videoInfo.resolution }} </span>
</p>
</div>
</div>
<div class="video-details">
<p class="video-name">{{ videoInfo.name }}</p>
<p class="video-meta">
{{ formatFileSize(videoInfo.size) }} · {{ videoInfo.duration }} ·
{{ videoInfo.resolution }}
</p>
<div class="video-actions" :class="btnClass">
<el-button type="primary" @click="replaceVideo">
<el-icon class="mr-2"><IEpRefresh /></el-icon> 重新选择
</el-button>
<span></span>
<el-button type="success" @click="previewVideo">
<el-icon class="mr-2"><IEpView /></el-icon> 预览播放
</el-button>
</div>
</div>
</el-upload>
......@@ -71,6 +75,8 @@
import { uploadFile as uploadFileApi } from '@/api/common'
import type { UploadFile } from 'element-plus'
import type { UploadVideoProps } from './types'
import { getVideoMetadata } from '@/utils'
import { push } from 'notivue'
interface VideoInfo {
url: string
......@@ -82,9 +88,11 @@ interface VideoInfo {
fileId?: string
}
const { maxSize = 1000, acceptFormats = ['mp4', 'avi', 'mov', 'wmv', 'flv'] } =
defineProps<UploadVideoProps>()
const {
maxSize = 1000,
acceptFormats = ['mp4', 'avi', 'mov', 'wmv', 'flv'],
btnClass = '',
} = defineProps<UploadVideoProps>()
const modelValue = defineModel<string>('modelValue', { required: true })
const videoInfo = defineModel<VideoInfo | null>('videoInfo', { required: false, default: null })
......@@ -108,30 +116,6 @@ const formatFileSize = (bytes: number): string => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 获取视频时长和分辨率
const getVideoMetadata = (file: File): Promise<{ duration: string; resolution: string }> => {
return new Promise((resolve) => {
const video = document.createElement('video')
video.preload = 'metadata'
video.onloadedmetadata = () => {
const duration = formatDuration(video.duration)
const resolution = `${video.videoWidth}x${video.videoHeight}`
URL.revokeObjectURL(video.src)
resolve({ duration, resolution })
}
video.src = URL.createObjectURL(file)
})
}
// 格式化时长
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// 上传前验证
const beforeUpload = (file: File): boolean => {
uploadError.value = ''
......@@ -185,7 +169,7 @@ const startUpload = async () => {
console.log(data)
// 获取视频元数据
const metadata = await getVideoMetadata(currentFile.value)
const metadata = await getVideoMetadata(data.filePath)
// 根据你的 API 返回结构调整
const videoData: VideoInfo = {
......@@ -211,11 +195,11 @@ const startUpload = async () => {
url: videoData.url,
videoDuration: videoData.duration,
})
ElMessage.success('视频上传成功!')
push.success('视频上传成功!')
} catch (error) {
uploading.value = false
uploadError.value = error instanceof Error ? error.message : '上传失败,请重试'
ElMessage.error(uploadError.value)
push.error(uploadError.value)
}
}
......@@ -224,17 +208,51 @@ const cancelUpload = () => {
cancelUploadController()
uploading.value = false
uploadProgress.value = 0
ElMessage.info('已取消上传')
push.info('已取消上传')
}
// 编辑回显:当 modelValue 有值(父组件赋值)时,通过 getVideoMetadata 拉取元信息并展示
async function syncVideoInfoFromModel() {
const url = modelValue.value
if (!url || uploading.value) return
// 下面这种直接限制 新增视频 或者 编辑视频的二次上传
if (videoInfo.value?.url === url) return
let duration = '--'
let resolution = '--'
try {
const metadata = await getVideoMetadata(url)
duration = metadata.duration
resolution = metadata.resolution
} catch {
// 跨域或加载失败时使用占位
}
if (modelValue.value !== url) return
// 手动回显视频信息
videoInfo.value = {
url,
name: '已上传视频',
size: 0,
duration,
resolution,
}
}
watch(() => modelValue.value, syncVideoInfoFromModel, { immediate: true })
// 重新选择视频
const replaceVideo = () => {
videoInfo.value = null
modelValue.value = ''
uploadError.value = ''
uploadProgress.value = 0
currentFile.value = null
}
// 预览播放
const previewVideo = () => {
window.open(videoInfo.value?.url, '_blank')
}
// 重试上传
const retryUpload = () => {
uploadError.value = ''
......@@ -472,4 +490,49 @@ defineExpose({
height: 90px;
}
}
.upload-success-optimized {
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
height: 100%;
gap: 15px;
justify-content: space-between;
}
.video-info-display {
display: flex;
align-items: center;
gap: 15px;
}
.video-icon-wrapper {
font-size: 48px;
color: #6366f1;
}
.video-details-text {
text-align: left;
}
.video-name {
font-size: 16px;
font-weight: bold;
color: #303133;
margin-bottom: 5px;
word-break: break-all;
}
.video-meta {
font-size: 13px;
color: #909399;
}
.video-actions {
display: flex;
gap: 5px;
margin-top: 10px;
/* flex-direction: column; */
}
</style>
export interface UploadVideoProps {
maxSize?: number // MB
acceptFormats?: string[]
btnClass?: string
}
......@@ -97,3 +97,57 @@ export enum UsageStatusEnum {
// 已使用
USED = 1,
}
// 视频位置枚举
export enum VideoPositionEnum {
// 顶部
TOP = 1,
// 底部
BOTTOM = 2,
}
// 活动分类
export enum ActivityTypeEnum {
// 竞拍
AUCTION = 1,
// 每日抽奖
DAILY_LOTTERY = 2,
}
// 竞拍状态枚举
export enum AuctionStatusEnum {
// 已发布
PUBLISHED = 0,
// 竞拍中
AUCTIONING = 1,
// 竞拍成功
AUCTION_SUCCESS = 2,
// 已流拍
AUCTION_FAILED = 3,
}
// 奖励按钮枚举
export enum RewardButtonEnum {
USER_PAGE = 'userPage', // 个人中心按钮
PUBLISH_LONG_ARTICLE = 'publishLongArticle', // 发布帖子按钮
PUBLISH_CASE = 'publishCase', // 去投稿按钮
PUBLISH_QUESTION = 'publishQuestion', // 话题发布按钮(问吧)
SELF_ANSWER = 'selfAnswer', // 回答问题按钮
PUBLISH_VIDEO = 'publishVideo', // 视频发布按钮
SELF_COMMENT = 'selfComment', // 我的回答按钮
ASK_TAB = 'askTab', // 问吧按钮
YA_TAB = 'yaTab', // 亚文化按钮
YA_PRACTICE = 'yaPractice', // 亚文化实践
YA_INTERVIEW = 'yaInterview', // 亚文化专访
YA_VIDEO = 'yaVideo', // 亚文化视频
}
// 发放商品的分类
export enum GoodsDistributionTypeEnum {
// 每日抽奖
LOTTERY = 'lottery',
// 积分商城
ORDER = 'order',
// 竞拍
AUCTION = 'auction',
}
......@@ -4,6 +4,8 @@ import {
CommentTypeEnum,
TaskTypeEnum,
UsageStatusEnum,
AuctionStatusEnum,
GoodsDistributionTypeEnum,
} from './enums'
// 地区列表
......@@ -75,7 +77,12 @@ export const articleTypeListOptionsForReal: { label: string; value: ArticleTypeE
label: '专访',
value: ArticleTypeEnum.INTERVIEW,
},
{
label: '问吧',
value: ArticleTypeEnum.QUESTION,
},
]
export const articleTypeListOptionsForNotReal: { label: string; value: ArticleTypeEnum }[] = [
{
label: '帖子',
......@@ -86,11 +93,6 @@ export const articleTypeListOptionsForNotReal: { label: string; value: ArticleTy
value: ArticleTypeEnum.VIDEO,
},
{
label: '问吧',
value: ArticleTypeEnum.QUESTION,
},
{
label: '专栏',
value: ArticleTypeEnum.COLUMN,
},
......@@ -192,3 +194,42 @@ export const levelListOptions: {
expScope: [6000, 12000],
},
]
// 竞拍状态列表
export const auctionStatusListOptions: { label: string; value: AuctionStatusEnum }[] = [
{
label: '已发布',
value: AuctionStatusEnum.PUBLISHED,
},
{
label: '竞拍中',
value: AuctionStatusEnum.AUCTIONING,
},
{
label: '竞拍成功',
value: AuctionStatusEnum.AUCTION_SUCCESS,
},
{
label: '已流拍',
value: AuctionStatusEnum.AUCTION_FAILED,
},
]
// 发放商品的分类列表
export const goodsDistributionTypeListOptions: {
label: string
value: GoodsDistributionTypeEnum
}[] = [
{
label: '每日抽奖',
value: GoodsDistributionTypeEnum.LOTTERY,
},
{
label: '积分商城',
value: GoodsDistributionTypeEnum.ORDER,
},
{
label: '竞拍',
value: GoodsDistributionTypeEnum.AUCTION,
},
]
......@@ -3,3 +3,5 @@ export * from './usePageSearch'
export * from './useScrollTop'
export * from './useHintAnimation'
export * from './useUploadImg'
export * from './useNavigation'
export * from './useMessageBox.tsx'
import MessageBox from '@/components/common/MessageBox/index.vue'
import type { MessageBoxProps } from '@/components/common/MessageBox/types'
import type { FormInstance } from 'element-plus'
import { render, effect, stop } from 'vue'
interface ConfirmProps extends MessageBoxProps {
useLoading?: boolean // 是否用到loading
}
interface PromptProps extends MessageBoxProps {
inputPattern?: RegExp // 输入框的正则
inputErrorMessage?: string // 输入框的错误信息
}
let hasBindEvent = false
// 记录每次鼠标点击的位置 x y
const mousePosition = ref({ x: 0, y: 0 })
let shouldUpdateMousePosition = true
const handler = (e: MouseEvent) => {
if (!shouldUpdateMousePosition) return
mousePosition.value = { x: e.clientX, y: e.clientY }
}
if (!hasBindEvent) {
window.addEventListener('click', handler, {
capture: true,
})
hasBindEvent = true
}
export const useMessageBox = () => {
const confirm = (props: Omit<ConfirmProps, 'mousePosition'>) => {
return new Promise((resolve, reject) => {
shouldUpdateMousePosition = false
const visible = ref(false)
const loading = ref(false)
const onConfirm = () => {
if (props.useLoading) {
// 设置loading
loading.value = true
// 关闭全给用户
resolve({
close: () => {
visible.value = false
},
setLoading: (value: boolean) => {
loading.value = value
},
})
} else {
resolve(true)
}
}
const onCancel = () => {
reject(false)
}
const onClose = () => {
reject(false)
}
const effectRunner = effect(() => {
render(
<MessageBox
v-model={visible.value}
{...props}
mousePosition={mousePosition.value}
loading={loading.value}
onConfirm={onConfirm}
onCancel={onCancel}
onClose={onClose}
onAfterLeave={() => {
stop(effectRunner)
render(null, document.body)
shouldUpdateMousePosition = true
}}
/>,
document.body,
)
})
visible.value = true
})
}
const prompt = <T extends string | number = string>(
props: Omit<PromptProps, 'mousePosition'>,
): Promise<T> => {
return new Promise((resolve, reject) => {
shouldUpdateMousePosition = false
const visible = ref(false)
const loading = ref(false)
const rules = {
inputValue: [
{
required: true,
message: '内容不能为空',
trigger: 'blur',
},
{
pattern: props.inputPattern,
message: props.inputErrorMessage ?? '请输入正确的内容',
trigger: 'blur',
},
],
}
const validate = async () => {
await formRef.value?.validate()
return form.value.inputValue
}
const formRef = ref<FormInstance | null>(null)
const form = ref<{ inputValue: T }>({ inputValue: '' as T })
const messageFC = () => (
<div>
<el-form ref={formRef} rules={rules} model={form.value}>
<el-form-item prop="inputValue">
<el-input v-model={form.value.inputValue} />
</el-form-item>
</el-form>
</div>
)
const onConfirm = (val: T) => {
resolve(val)
}
const onCancel = () => {
reject(false)
}
const onClose = () => {
reject(false)
}
const effectRunner = effect(() => {
render(
<MessageBox
{...props}
v-model={visible.value}
validate={validate}
message={messageFC}
loading={loading.value}
mousePosition={mousePosition.value}
onConfirm={(res: string | number | undefined) => onConfirm(res as T)}
onCancel={onCancel}
onClose={onClose}
onAfterLeave={() => {
stop(effectRunner)
render(null, document.body)
shouldUpdateMousePosition = true
}}
/>,
document.body,
)
})
visible.value = true
})
}
return { confirm, prompt }
}
import { ArticleTypeEnum } from '@/constants'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
export function useNavigation() {
const router = useRouter()
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
// 根据文章类型跳到对应的文章详情页面
const jumpToArticleDetailPage = ({ type, id }: { type: ArticleTypeEnum; id: number }) => {
if (type === ArticleTypeEnum.VIDEO) {
router.push(`/videoDetail/${id}`)
} else if (type === ArticleTypeEnum.QUESTION) {
router.push(`/questionDetail/${id}`)
} else {
router.push(`/articleDetail/${id}`)
}
}
// 点击头像跳转用户首页 【实践 专访 问吧】 是 1 实名 其他是 0 匿名
const jumpToUserHomePage = ({ userId, type }: { userId: string; type: ArticleTypeEnum }) => {
const isSelf = userInfo.value.userId === userId
if (isSelf) {
router.push(`/userPage/selfPublish`)
} else {
let isReal = 0
if (
type === ArticleTypeEnum.PRACTICE ||
type === ArticleTypeEnum.INTERVIEW ||
type === ArticleTypeEnum.QUESTION
) {
isReal = 1
}
router.push(`/otherUserPage/${userId}/${isReal}`)
}
}
return {
router,
jumpToArticleDetailPage,
jumpToUserHomePage,
}
}
......@@ -33,6 +33,8 @@ export interface PageSearchConfig<T extends PageSearchParams = PageSearchParams>
pageSizeField?: keyof T
/** 格式化列表数据 */
formatList?: (list: any[]) => any[]
/** 成功回调 */
success?: (list: Ref<any[]>) => void
}
/**
......@@ -57,6 +59,7 @@ export function usePageSearch<
pageField = 'current' as keyof TParams,
pageSizeField = 'size' as keyof TParams,
formatList = (list: any[]) => list,
success = () => {},
} = config
const loading = shallowRef(false)
......@@ -79,6 +82,7 @@ export function usePageSearch<
list.value = formatList(data.list || [])
total.value = data.total || 0
success?.(list)
} catch (error) {
console.log('分页搜索失败:', error)
list.value = []
......
......@@ -12,7 +12,7 @@ function ScrollTopComp(_: any, { emit }: SetupContext<Events>) {
<div class="w-8 h-8 bg-gradient-to-r from-blue-500 to-indigo-500 rounded-full flex items-center justify-center group-hover:rotate-15 transition-transform duration-300 shadow-sm">
<SvgIcon name="icon_top" size="16" className="text-white" />
</div>
<span class="text-12px font-medium text-gray-700 group-hover:text-blue-600 transition-colors">
<span class=" hidden lg:block text-xs font-medium text-gray-700 group-hover:text-blue-600 transition-colors">
回到顶部
</span>
</button>
......
import { uploadFile } from '@/api'
import { push } from 'notivue'
// 默认参数
export const useUploadImg = (imgList: Ref<string[]> = ref([])) => {
const uploadPercent = ref(0)
// 字符串拼
const imgsStr = computed(() => imgList.value.join(','))
// 类型定义
type BaseReturn = {
handleFileChange: (e: Event) => Promise<void>
uploadPercent: Ref<number>
handleDeleteImg: (urlStr: string) => void
}
// 传单字符串时多返回 imgList
type UseUploadImgReturnString = BaseReturn & {
imgList: ComputedRef<string[]>
}
// 传字符串数组时只返回基础
type UseUploadImgReturnArray = BaseReturn
// 直接传ref('imgs1,imgs2') 或者 ref(['img1','img2]) 传字符串的时候 会多返回一个imgList数组 便于模板使用遍历等
export function useUploadImg(imgs: Ref<string>): UseUploadImgReturnString
export function useUploadImg(imgs: Ref<string[]>): UseUploadImgReturnArray
export function useUploadImg(imgs: Ref<string> | Ref<string[]>) {
const uploadPercent = ref(0)
// 上传图片的change事件
const handleFileChange = async (e: Event) => {
try {
......@@ -18,9 +32,14 @@ export const useUploadImg = (imgList: Ref<string[]> = ref([])) => {
},
})
const data = await promise
imgList.value.push(data.filePath)
if (Array.isArray(imgs.value)) {
imgs.value = [...imgs.value, data.filePath]
} else {
imgs.value = [...imgs.value.split(',').filter(Boolean), data.filePath].join(',')
}
} catch (error) {
console.error('上传失败:', error)
push.error('上传失败,请重试')
} finally {
uploadPercent.value = 0
// 重置input的value
......@@ -30,14 +49,37 @@ export const useUploadImg = (imgList: Ref<string[]> = ref([])) => {
// 删除图片
const handleDeleteImg = (urlStr: string) => {
imgList.value = imgList.value.filter((item) => item !== urlStr)
if (Array.isArray(imgs.value)) {
imgs.value = imgs.value.filter((item) => item !== urlStr)
} else {
imgs.value =
imgs.value
.split(',')
.filter((item) => item !== urlStr)
.join(',') || ''
}
}
return {
imgsStr,
imgList,
handleFileChange,
uploadPercent,
handleDeleteImg,
const imgList = computed(() => {
if (Array.isArray(imgs.value)) {
return imgs.value
} else {
return imgs.value.split(',').filter(Boolean)
}
})
if (Array.isArray(imgs.value)) {
return {
handleFileChange,
uploadPercent,
handleDeleteImg,
}
} else {
return {
handleFileChange,
uploadPercent,
handleDeleteImg,
imgList,
}
}
}
import { ArticleTypeEnum, BooleanFlag, ReleaseStatusTypeEnum, SendTypeEnum } from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import { useResetData } from '@/hooks'
import type { TagItemDto } from '@/api/tag/types'
import { useColumnStore } from '@/stores/column'
import { storeToRefs } from 'pinia'
import SelectTags from '@/components/common/SelectTags/index.vue'
import type { AddOrUpdateColumnForm, AddOrUpdateColumnDto } from '@/api/article/types'
export default defineComponent((_, { expose }) => {
const columnStore = useColumnStore()
const { columnList } = storeToRefs(columnStore)
console.log(columnList.value)
const [form, resetForm] = useResetData<AddOrUpdateColumnForm>({
title: '',
content: '',
faceUrl: '',
imgUrl: '',
releaseStatus: ReleaseStatusTypeEnum.PUBLISH,
mainTagId: '',
tagList: [],
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
isRelateColleague: BooleanFlag.NO,
relateColumnId: undefined,
type: ArticleTypeEnum.COLUMN,
isRecommend: BooleanFlag.NO,
})
const formRef = ref<InstanceType<typeof ElForm>>()
const rules = {
title: [{ required: true, message: '请输入专栏标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入专栏内容', trigger: 'blur' }],
releaseStatus: [{ required: true, message: '请选择发布时间', trigger: 'blur' }],
mainTagId: [{ required: true, message: '请选择主标签', trigger: 'blur' }],
sendType: [{ required: true, message: '请选择发布类型', trigger: 'blur' }],
sendTime: [{ required: true, message: '请选择发布时间', trigger: 'blur' }],
relateColumnId: [
{ required: true, message: '请选择专栏栏目', trigger: 'blur', type: 'number', min: 1 },
],
isRecommend: [{ required: true, message: '请选择是否推荐', trigger: 'blur' }],
imgUrl: [{ required: true, message: '请上传图片', trigger: 'blur' }],
}
const transformForm = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdateColumnDto => {
return {
...form.value,
releaseStatus,
faceUrl: form.value.imgUrl?.split(',').filter(Boolean)[0] || '',
tagList: [form.value.mainTagId, ...form.value.tagList].map((tag, index) => {
return {
sort: index,
tagId: Number(tag),
}
}),
}
}
// 检验并且获取表单数据
const getValidatedFormData = async (releaseStatus: ReleaseStatusTypeEnum) => {
try {
await formRef.value?.validate()
return transformForm(releaseStatus)
} catch (error) {
console.log(error)
ElMessage.warning('请检查输入内容')
return null
}
}
const filterTagsFn = (allTags: TagItemDto[]) => {
// 引用了form.value.mainTagId
return allTags.filter((tag) => tag.id !== Number(form.value.mainTagId))
}
const resetFields = () => {
formRef.value?.resetFields()
resetForm()
}
expose({
getValidatedFormData,
resetFields,
})
return () => (
<div>
<el-form
ref={formRef}
model={form.value}
label-width="auto"
label-position="right"
rules={rules}
>
<el-form-item label="标题" prop="title">
<el-input
v-model={form.value.title}
placeholder="请输入实践标题"
maxlength={200}
show-word-limit
/>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model={form.value.content}
type="textarea"
placeholder="请输入专栏内容"
rows={6}
maxlength={1000}
show-word-limit
class="content-input"
/>
</el-form-item>
<el-form-item label="图片" prop="imgUrl">
{/* @ts-ignore */}
<UploadFile v-model={form.value.imgUrl} />
</el-form-item>
<el-form-item label="专栏栏目选择" prop="relateColumnId">
<el-select v-model={form.value.relateColumnId} placeholder="请选择专栏栏目">
{columnList.value.map((item) => (
<el-option value={item.id} label={item.title}></el-option>
))}
</el-select>
</el-form-item>
<el-form-item label="主标签" prop="mainTagId">
{{
// @ts-ignore
default: () => <SelectTags v-model={form.value.mainTagId} />,
label: () => (
// <el-tooltip content="主标签最多选1个" placement="top">
<span class="cursor-pointer">
主标签
{/* <el-icon class="ml-1">
<InfoFilled />
</el-icon> */}
</span>
// </el-tooltip>
),
}}
</el-form-item>
<el-form-item label="副标签">
{{
default: () => (
// @ts-ignore
<SelectTags
v-model={form.value.tagList}
filterTagsFn={filterTagsFn}
maxSelectedTags={3}
/>
),
label: () => (
// <el-tooltip content="副标签最多选3个" placement="top">
<span class="cursor-pointer">
副标签
{/* <el-icon class="ml-1">
<InfoFilled />
</el-icon> */}
</span>
// </el-tooltip>
),
}}
</el-form-item>
<el-form-item label="是否同步同事吧" prop="isRelateColleague">
<el-radio-group v-model={form.value.isRelateColleague}>
<el-radio value={BooleanFlag.YES} class="radio-item scheduled">
</el-radio>
<el-radio value={BooleanFlag.NO} class="radio-item scheduled">
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="是否推荐" prop="isRecommend">
<el-radio-group v-model={form.value.isRecommend} class="radio-group">
<el-radio value={BooleanFlag.YES} class="radio-item immediate">
</el-radio>
<el-radio value={BooleanFlag.NO} class="radio-item scheduled">
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="发布类型" prop="sendType">
<el-radio-group v-model={form.value.sendType} class="radio-group">
<el-radio value={SendTypeEnum.IMMEDIATE} class="radio-item immediate">
立即发布
</el-radio>
<el-radio value={SendTypeEnum.SCHEDULED} class="radio-item scheduled">
定时发布
</el-radio>
</el-radio-group>
</el-form-item>
{form.value.sendType === SendTypeEnum.SCHEDULED && (
<el-form-item label="发布时间" prop="sendTime">
<el-date-picker
class="ml-2"
v-model={form.value.sendTime}
type="datetime"
placeholder="请选择发布时间"
// 不能选现在
disabled-date={(time: Date) => {
return time.getTime() < Date.now() - 1000 * 60 * 60 * 24
}}
value-format="X"
style={{ width: '250px' }}
/>
</el-form-item>
)}
</el-form>
</div>
)
})
import { ArticleTypeEnum, BooleanFlag, ReleaseStatusTypeEnum, SendTypeEnum } from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import { useResetData } from '@/hooks'
import { useInterviewStore } from '@/stores/interview'
import { storeToRefs } from 'pinia'
import type { AddOrUpdateInterviewDto, AddOrUpdateInterviewForm } from '@/api/article/types'
import type { TagItemDto } from '@/api/tag/types'
export default defineComponent((_, { expose }) => {
const interviewStore = useInterviewStore()
const { interviewList } = storeToRefs(interviewStore)
const [form, resetForm] = useResetData<AddOrUpdateInterviewForm>({
title: '',
content: '',
faceUrl: '',
imgUrl: '',
releaseStatus: ReleaseStatusTypeEnum.PUBLISH,
mainTagId: '',
tagList: [],
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
type: ArticleTypeEnum.INTERVIEW,
relateColumnId: undefined,
isRecommend: BooleanFlag.NO,
})
const formRef = ref<InstanceType<typeof ElForm>>()
const rules = {
title: [{ required: true, message: '请输入专访标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入专访内容', trigger: 'blur' }],
releaseStatus: [{ required: true, message: '请选择发布时间', trigger: 'blur' }],
mainTagId: [{ required: true, message: '请选择主标签', trigger: 'blur' }],
sendType: [{ required: true, message: '请选择发布类型', trigger: 'blur' }],
sendTime: [{ required: true, message: '请选择发布时间', trigger: 'blur' }],
relateColumnId: [
{ required: true, message: '请选择专访栏目', trigger: 'blur', type: 'number', min: 1 },
],
isRecommend: [{ required: true, message: '请选择是否推荐', trigger: 'blur' }],
imgUrl: [{ required: true, message: '请上传图片', trigger: 'blur' }],
}
const transformForm = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdateInterviewDto => {
return {
...form.value,
releaseStatus,
faceUrl: form.value.imgUrl?.split(',').filter(Boolean)[0] || '',
tagList: [form.value.mainTagId, ...form.value.tagList].map((tag, index) => {
return {
sort: index,
tagId: Number(tag),
}
}),
}
}
// 检验并且获取表单数据
const getValidatedFormData = async (releaseStatus: ReleaseStatusTypeEnum) => {
try {
await formRef.value?.validate()
return transformForm(releaseStatus)
} catch (error) {
console.log(error)
ElMessage.warning('请检查输入内容')
return null
}
}
const filterTagsFn = (allTags: TagItemDto[]) => {
// 引用了form.value.mainTagId
return allTags.filter((tag) => tag.id !== Number(form.value.mainTagId))
}
const resetFields = () => {
formRef.value?.resetFields()
resetForm()
}
expose({
getValidatedFormData,
resetFields,
})
return () => (
<div>
<el-form
ref={formRef}
model={form.value}
label-width="auto"
label-position="right"
rules={rules}
>
<el-form-item label="标题" prop="title">
<el-input
v-model={form.value.title}
placeholder="请输入实践标题"
maxlength={200}
show-word-limit
/>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model={form.value.content}
type="textarea"
placeholder="请输入专访内容"
rows={6}
maxlength={1000}
show-word-limit
class="content-input"
/>
</el-form-item>
<el-form-item label="图片" prop="imgUrl">
{/* @ts-ignore */}
<UploadFile v-model={form.value.imgUrl} />
</el-form-item>
<el-form-item label="专访栏目选择" prop="relateColumnId">
<el-select v-model={form.value.relateColumnId} placeholder="请选择专访栏目">
{interviewList.value.map((item) => (
<el-option value={item.id} label={item.title} />
))}
</el-select>
</el-form-item>
<el-form-item label="主标签" prop="mainTagId">
{{
// @ts-ignore
default: () => <SelectTags v-model={form.value.mainTagId} />,
label: () => (
// <el-tooltip content="主标签最多选1个" placement="top">
<span class="cursor-pointer">
主标签
{/* <el-icon class="ml-1">
<InfoFilled />
</el-icon> */}
</span>
// </el-tooltip>
),
}}
</el-form-item>
<el-form-item label="副标签">
{{
default: () => (
// @ts-ignore
<SelectTags
v-model={form.value.tagList}
filterTagsFn={filterTagsFn}
maxSelectedTags={3}
/>
),
label: () => (
// <el-tooltip content="副标签最多选3个" placement="top">
<span class="cursor-pointer">
副标签
{/* <el-icon class="ml-1">
<InfoFilled />
</el-icon> */}
</span>
// </el-tooltip>
),
}}
</el-form-item>
<el-form-item label="是否推荐" prop="isRecommend">
<el-radio-group v-model={form.value.isRecommend} class="radio-group">
<el-radio value={BooleanFlag.YES} class="radio-item immediate">
</el-radio>
<el-radio value={BooleanFlag.NO} class="radio-item scheduled">
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="发布类型" prop="sendType">
<el-radio-group v-model={form.value.sendType} class="radio-group">
<el-radio value={SendTypeEnum.IMMEDIATE} class="radio-item immediate">
立即发布
</el-radio>
<el-radio value={SendTypeEnum.SCHEDULED} class="radio-item scheduled">
定时发布
</el-radio>
</el-radio-group>
</el-form-item>
{form.value.sendType === SendTypeEnum.SCHEDULED && (
<el-form-item label="发布时间" prop="sendTime">
<el-date-picker
class="ml-2"
v-model={form.value.sendTime}
type="datetime"
placeholder="请选择发布时间"
// 不能选现在
disabled-date={(time: Date) => {
return time.getTime() < Date.now() - 1000 * 60 * 60 * 24
}}
value-format="X"
style={{ width: '250px' }}
/>
</el-form-item>
)}
</el-form>
</div>
)
})
<template>
<Draggable
:initial-value="{ x: x, y: y }"
storage-key="vueuse-draggable"
storage-type="session"
class="fixed"
>
<div class="cursor-pointer online-time flex flex-col items-center z-1050">
<!-- 图片容器 -->
<transition name="online-time">
<Draggable
v-show="showOnlineTime"
:initial-value="{ x: x, y: y }"
storage-key="vueuse-draggable"
storage-type="session"
class="fixed z-2"
>
<div
class="mb-4 w-24 h-24 bg-white rounded-full shadow-xl flex items-center justify-center hover:scale-110 transition-transform cursor-pointer"
class="group flex items-center gap-2.5 py-2.5 pr-4 pl-3 bg-#fff rounded-xl shadow-[inset_0_2px_6px_rgba(0,0,0,0.08)] cursor-pointer transition-all duration-250 relative shadow-[inset_0_2px_8px_rgba(0,0,0,0.12)]"
>
<img src="@/assets/img/culture/ask.png" draggable="false" alt="吉祥物" class="w-20 h-20" />
<!-- 如果没有图片,用emoji代替 -->
<!-- <span class="text-6xl">🎯</span> -->
</div>
<!-- 时长显示卡片 -->
<div
class="w-24 md:w-28 lg:w-32 h-16 md:h-18 lg:h-20 rounded-lg bg-gradient-to-br from-white via-blue-50 to-purple-50 shadow-lg hover:shadow-xl border border-white/50 flex flex-col justify-center items-center transition-all duration-300 ease-out hover:scale-105 hover:-translate-y-1 backdrop-blur-sm relative overflow-hidden group transform-gpu backface-hidden will-change-transform"
>
<!-- 其他内容保持不变 -->
<!-- 在线指示灯 -->
<div
class="absolute inset-0 bg-gradient-to-r from-blue-400/5 via-purple-400/5 to-pink-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500"
class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0 animate-pulse shadow-[0_0_6px_rgba(34,197,94,0.4)]"
></div>
<div
class="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 opacity-80"
></div>
<div
class="absolute top-1 left-0 right-0 h-1 bg-gradient-to-r from-blue-300 via-purple-300 to-pink-300 opacity-60"
></div>
<div class="text-center px-3 relative z-10">
<div class="text-xs md:text-sm text-gray-700 font-medium mb-1 tracking-wide">
今日在线时长
</div>
<div
class="text-center text-2xl font-bold bg-gradient-to-r from-blue-500 to-purple-500 bg-clip-text text-transparent"
>
<!-- 时长内容 -->
<div class="flex flex-col leading-tight">
<span class="text-base text-gray-400 tracking-wider font-medium">今日在线时长</span>
<span class="text-base font-bold text-blue-600 tabular-nums tracking-wider">
{{ formatSeconds }}
</div>
</div>
<div class="absolute bottom-2 left-3 right-3 h-1 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-blue-400 to-purple-400 rounded-full animate-pulse"
:style="{
width: widthRate + '%',
}"
></div>
</span>
</div>
<div class="absolute top-3 right-3 w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<div
class="absolute bottom-3 left-3 w-1.5 h-1.5 bg-blue-400 rounded-full animate-pulse"
style="animation-delay: 0.8s"
></div>
<div
class="absolute left-0 top-4 bottom-4 w-0.5 bg-gradient-to-b from-transparent via-purple-300 to-transparent opacity-50"
></div>
<!-- 关闭按钮 -->
<div
class="absolute right-0 top-4 bottom-4 w-0.5 bg-gradient-to-b from-transparent via-blue-300 to-transparent opacity-50"
></div>
class="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-white text-gray-400 shadow-sm flex items-center justify-center opacity-0 scale-60 transition-all duration-200 cursor-pointer group-hover:opacity-100 group-hover:scale-100 hover:!bg-red-500 hover:!text-white"
title="关闭"
@click.stop="showOnlineTime = false"
>
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5">
<path
d="M1.5.4L6 4.9 10.5.4l1.1 1.1L7.1 6l4.5 4.5-1.1 1.1L6 7.1 1.5 11.6.4 10.5 4.9 6 .4 1.5z"
fill="currentColor"
/>
</svg>
</div>
</div>
</div>
</Draggable>
</Draggable>
</transition>
</template>
<script setup lang="ts">
import { UseDraggable as Draggable } from '@vueuse/components'
import { useWindowSize } from '@vueuse/core'
import { getTodayOnlineSeconds, heartbeat } from '@/api'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { useOnlineTimeStore } from '@/stores'
import { storeToRefs } from 'pinia'
dayjs.extend(utc)
const onlineTimeStore = useOnlineTimeStore()
const { showOnlineTime } = storeToRefs(onlineTimeStore)
const { height } = useWindowSize()
const CONTAINER_HEIGHT = 170,
GAP = 30
// CONTAINER_WIDTH = 130
const maxSeconds = 60 * 30 // 半小时
const x = GAP
const y = height.value - CONTAINER_HEIGHT - GAP
const currentSeconds = ref(0)
// 进度条的比例
const widthRate = computed(() => {
if (currentSeconds.value >= maxSeconds) {
return 100
} else {
return (currentSeconds.value / maxSeconds) * 100
}
})
// 在线时长格式化 将秒级 格式化为 00:00
// 如果大于一个小时的话,则显示小时:分钟:秒
const formatSeconds = computed(() => {
if (currentSeconds.value >= 60 * 60) {
return dayjs.utc(currentSeconds.value * 1000).format('hh:mm:ss')
......@@ -121,3 +88,15 @@ onUnmounted(() => {
clearInterval(timer2)
})
</script>
<style lang="scss" scoped>
.online-time-enter-from,
.online-time-leave-to {
opacity: 0;
transform: translateY(10px);
}
.online-time-enter-active,
.online-time-leave-active {
transition: all 0.3s ease;
}
</style>
import { ArticleTypeEnum, ReleaseStatusTypeEnum, SendTypeEnum } from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import { useResetData } from '@/hooks'
import type { AddOrUpdatePostDto } from '@/api'
export default defineComponent(
(_, { expose }) => {
const [form, resetForm] = useResetData<AddOrUpdatePostDto>({
title: '',
content: '',
faceUrl: '',
imgUrl: '',
releaseStatus: ReleaseStatusTypeEnum.PUBLISH,
type: ArticleTypeEnum.POST,
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
})
const formRef = ref<InstanceType<typeof ElForm>>()
const rules = {
title: [{ required: true, message: '请输入帖子标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入帖子内容', trigger: 'blur' }],
sendType: [{ required: true, message: '请选择发布类型', trigger: 'blur' }],
sendTime: [{ required: true, message: '请选择发布时间', trigger: 'blur' }],
}
const transformForm = (releaseStatus: ReleaseStatusTypeEnum) => {
return {
...form.value,
releaseStatus,
faceUrl: form.value.imgUrl?.split(',').filter(Boolean)[0] || '',
}
}
const getValidatedFormData = async (releaseStatus: ReleaseStatusTypeEnum) => {
try {
await formRef.value?.validate()
return transformForm(releaseStatus)
} catch (error) {
console.log(error)
ElMessage.warning('请检查输入内容')
return null
}
}
const resetFields = () => {
formRef.value?.resetFields()
resetForm()
}
expose({
getValidatedFormData,
resetFields,
})
return () => (
<div>
<el-form
ref={formRef}
model={form.value}
label-width="auto"
label-position="right"
rules={rules}
>
<el-form-item label="标题" prop="title">
<el-input
v-model={form.value.title}
placeholder="请输入帖子标题"
maxlength={30}
show-word-limit
/>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model={form.value.content}
type="textarea"
placeholder="良言良语,快乐无限;恶语恶言,伤心伤肝。请简述发帖内容,或点击长文章编辑格式"
rows={6}
maxlength={255}
show-word-limit
class="content-input"
/>
</el-form-item>
<el-form-item label="图片" prop="faceUrl">
{/* @ts-ignore */}
<UploadFile v-model={form.value.imgUrl} />
</el-form-item>
<el-form-item label="发布类型" prop="sendType">
<el-radio-group v-model={form.value.sendType} class="radio-group">
<el-radio value={SendTypeEnum.IMMEDIATE} class="radio-item immediate">
立即发布
</el-radio>
<el-radio value={SendTypeEnum.SCHEDULED} class="radio-item scheduled">
定时发布
</el-radio>
</el-radio-group>
</el-form-item>
{form.value.sendType === SendTypeEnum.SCHEDULED && (
<el-form-item label="发布时间" prop="sendTime">
<el-date-picker
class="w-full"
v-model={form.value.sendTime}
type="datetime"
placeholder="请选择发布时间"
// 不能选现在
disabled-date={(time: Date) => {
return time.getTime() < Date.now() - 1000 * 60 * 60 * 24
}}
value-format="X"
style={{ width: '250px' }}
/>
</el-form-item>
)}
</el-form>
</div>
)
},
{
name: 'PostForm',
},
)
import { ReleaseStatusTypeEnum, SendTypeEnum } from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import SelectTags from '@/components/common/SelectTags/index.vue'
import { useResetData } from '@/hooks'
import type { TagItemDto } from '@/api/tag/types'
import type { AddOrUpdatePracticeDto } from '@/api/practice/types'
export default defineComponent((_, { expose }) => {
const [form, resetForm] = useResetData<AddOrUpdatePracticeDto>({
title: '',
content: '',
faceUrl: '',
imgUrl: '',
releaseStatus: ReleaseStatusTypeEnum.PUBLISH,
mainTagId: '',
tagList: [],
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
})
const formRef = ref<InstanceType<typeof ElForm>>()
const rules = {
title: [{ required: true, message: '请输入实践标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入实践内容', trigger: 'blur' }],
releaseStatus: [{ required: true, message: '请选择发布时间', trigger: 'blur' }],
mainTagId: [{ required: true, message: '请选择主标签', trigger: 'blur' }],
sendType: [{ required: true, message: '请选择发布类型', trigger: 'blur' }],
sendTime: [{ required: true, message: '请选择发布时间', trigger: 'blur' }],
}
const transformForm = (releaseStatus: ReleaseStatusTypeEnum) => {
return {
...form.value,
releaseStatus,
faceUrl: form.value.imgUrl.split(',')[0],
tagList: [form.value.mainTagId, ...form.value.tagList].map((tag, index) => {
return {
sort: index,
tagId: Number(tag),
}
}),
}
}
// 检验并且获取表单数据
const getValidatedFormData = async (releaseStatus: ReleaseStatusTypeEnum) => {
try {
await formRef.value?.validate()
return transformForm(releaseStatus)
} catch (error) {
console.log(error)
ElMessage.warning('请检查输入内容')
return null
}
}
const filterTagsFn = (allTags: TagItemDto[]) => {
// 引用了form.value.mainTagId
return allTags.filter((tag) => tag.id !== Number(form.value.mainTagId))
}
const resetFields = () => {
formRef.value?.resetFields()
resetForm()
}
expose({
getValidatedFormData,
resetFields,
})
return () => (
<div>
<el-form
ref={formRef}
model={form.value}
label-width="auto"
label-position="right"
rules={rules}
>
<el-form-item label="标题" prop="title">
<el-input
v-model={form.value.title}
placeholder="请输入实践标题"
maxlength={200}
show-word-limit
/>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model={form.value.content}
type="textarea"
placeholder="分享你的企业文化实践实例"
rows={6}
maxlength={2000}
show-word-limit
class="content-input"
/>
</el-form-item>
<el-form-item label="图片">
{/* @ts-ignore */}
<UploadFile v-model={form.value.imgUrl} />
</el-form-item>
<el-form-item label="主标签" prop="mainTagId">
{{
// @ts-ignore
default: () => <SelectTags v-model={form.value.mainTagId} />,
label: () => (
// <el-tooltip content="主标签最多选1个" placement="top">
<span class="cursor-pointer">
主标签
{/* <el-icon class="ml-1">
<InfoFilled />
</el-icon> */}
</span>
// </el-tooltip>
),
}}
</el-form-item>
<el-form-item label="副标签">
{{
default: () => (
// @ts-ignore
<SelectTags
v-model={form.value.tagList}
filterTagsFn={filterTagsFn}
maxSelectedTags={3}
/>
),
label: () => (
// <el-tooltip content="副标签最多选3个" placement="top">
<span class="cursor-pointer">
副标签
{/* <el-icon class="ml-1">
<InfoFilled />
</el-icon> */}
</span>
// </el-tooltip>
),
}}
</el-form-item>
<el-form-item label="发布类型" prop="sendType">
<el-radio-group v-model={form.value.sendType} class="radio-group">
<el-radio value={SendTypeEnum.IMMEDIATE} class="radio-item immediate">
立即发布
</el-radio>
<el-radio value={SendTypeEnum.SCHEDULED} class="radio-item scheduled">
定时发布
</el-radio>
</el-radio-group>
</el-form-item>
{form.value.sendType === SendTypeEnum.SCHEDULED && (
<el-form-item label="发布时间" prop="sendTime">
<el-date-picker
class="ml-2"
v-model={form.value.sendTime}
type="datetime"
placeholder="请选择发布时间"
// 不能选现在
disabled-date={(time: Date) => {
return time.getTime() < Date.now() - 1000 * 60 * 60 * 24
}}
value-format="X"
style={{ width: '250px' }}
/>
</el-form-item>
)}
</el-form>
</div>
)
})
<template>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
:close-on-click-modal="false"
width="500px"
class="post-dialog"
:lock-scroll="true"
align-center
@closed="handleClosed"
>
<div v-loading="loading" class="bg-white/95 rounded-16px p-24px backdrop-blur-10px">
<!-- <keep-alive> -->
<component :is="currentFormComp" ref="formComponentRef" />
<!-- </keep-alive> -->
<!-- 底部按钮 -->
<div class="flex justify-end gap-1">
<el-button class="rounded-lg" @click="handleClosed">取消</el-button>
<el-button class="rounded-lg" @click="handleSubmit(ReleaseStatusTypeEnum.DRAFT)">
存草稿
</el-button>
<el-button
type="primary"
@click="handleSubmit(ReleaseStatusTypeEnum.PUBLISH)"
class="px-6 py-2 bg-blue-500 hover:bg-blue-600 rounded-lg text-white text-sm font-medium shadow-sm hover:shadow-md transition-all duration-200"
>
发布
</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
// 如果 你已经按需引入了 那么写 这个就会有css bug 所以 这里不写 就是你如果写这个的话 那么 他不会引入css
// import { ElDialog } from 'element-plus'
// import { Plus } from '@element-plus/icons-vue'
import { addOrUpdateArticle, addOrUpdatePractice } from '@/api'
import { ArticleTypeEnum, ReleaseStatusTypeEnum } from '@/constants'
import LoadingComponent from '@/components/common/LoadingComponent/index.vue'
interface ApiMap {
[ArticleTypeEnum.VIDEO]: typeof addOrUpdateArticle
[ArticleTypeEnum.QUESTION]: typeof addOrUpdateArticle
[ArticleTypeEnum.POST]: typeof addOrUpdateArticle
[ArticleTypeEnum.PRACTICE]: typeof addOrUpdatePractice
[ArticleTypeEnum.COLUMN]: typeof addOrUpdateArticle
[ArticleTypeEnum.INTERVIEW]: typeof addOrUpdateArticle
}
interface TypeConfig<T extends ArticleTypeEnum> {
title: string
component: Component
api?: ApiMap[T]
}
const typeMap: {
[key in ArticleTypeEnum]: TypeConfig<key>
} = {
[ArticleTypeEnum.VIDEO]: {
title: '视频',
component: defineAsyncComponent({
loader: () => import('./postForm.tsx'),
delay: 200,
loadingComponent: LoadingComponent,
}),
},
[ArticleTypeEnum.QUESTION]: {
title: '问题',
component: defineAsyncComponent({
loader: () => import('./postForm.tsx'),
delay: 200,
loadingComponent: LoadingComponent,
}),
},
[ArticleTypeEnum.POST]: {
title: '帖子',
component: defineAsyncComponent({
loader: async () => {
const start = Date.now()
const comp = await import('./postForm.tsx')
const cost = Date.now() - start
console.log('cost', cost)
const min = 1000 // 最低 200ms
if (cost < min) {
await new Promise((r) => setTimeout(r, min - cost))
}
return comp
},
delay: 200,
loadingComponent: LoadingComponent,
}),
api: addOrUpdateArticle,
},
[ArticleTypeEnum.PRACTICE]: {
title: '实践',
component: defineAsyncComponent({
loader: () => import('./practiceForm.tsx'),
delay: 200,
loadingComponent: LoadingComponent,
}),
api: addOrUpdatePractice,
},
[ArticleTypeEnum.COLUMN]: {
title: '专栏',
component: defineAsyncComponent({
loader: () => import('./colnumForm.tsx'),
delay: 200,
loadingComponent: LoadingComponent,
}),
api: addOrUpdateArticle,
},
[ArticleTypeEnum.INTERVIEW]: {
title: '专访',
component: defineAsyncComponent({
loader: () => import('./interviewForm.tsx'),
delay: 200,
loadingComponent: LoadingComponent,
}),
api: addOrUpdateArticle,
},
}
const dialogTitle = computed(() => '发布' + typeMap[articleType.value].title)
export interface BaseFormExpose {
resetFields: () => void
getValidatedFormData: (releaseStatus: ReleaseStatusTypeEnum) => Promise<unknown>
}
const formComponentRef = useTemplateRef<BaseFormExpose>('formComponentRef')
const currentFormComp = computed(() => {
return typeMap[articleType.value].component
})
const dialogVisible = ref(false)
const articleType = ref<ArticleTypeEnum>(ArticleTypeEnum.PRACTICE)
const loading = ref(false)
// 打开弹窗
const open = (type: ArticleTypeEnum) => {
articleType.value = type
dialogVisible.value = true
}
// 关闭弹窗
const close = () => {
dialogVisible.value = false
formComponentRef?.value?.resetFields()
}
const handleClosed = () => {
dialogVisible.value = false
formComponentRef.value?.resetFields()
}
const handleSubmit = async (releaseStatus: ReleaseStatusTypeEnum) => {
loading.value = true
try {
const formData = (await formComponentRef.value?.getValidatedFormData(releaseStatus)) as any
if (!formData) return
console.log(formData)
await typeMap[articleType.value].api?.({
...formData,
})
ElMessage.success('发布成功')
// 这里可以添加发布逻辑
close()
} catch (error) {
console.log(error)
} finally {
loading.value = false
}
}
// 暴露方法给父组件
defineExpose({
open,
close,
})
</script>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment