Commit b82ae1ad by 王立鹏

Merge branch 'feature/25171-【YAYA文化岛】改动汇总' into 'master'

Feature/25171 【yaya文化岛】改动汇总

See merge request !39
parents 392ffd3a 053e6c56
...@@ -3,6 +3,7 @@ import ssh from "ssh2"; ...@@ -3,6 +3,7 @@ import ssh from "ssh2";
import archiver from "archiver"; import archiver from "archiver";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import inquirer from "inquirer";
import { getCurrentBranchName, getDeploySlug } from "./util.js"; import { getCurrentBranchName, getDeploySlug } from "./util.js";
// 当前分支名 // 当前分支名
...@@ -430,23 +431,82 @@ ${metaContent} ...@@ -430,23 +431,82 @@ ${metaContent}
EOF`); EOF`);
} }
// 已部署分支数量达到上限时,直接在控制台勾选要删除的分支,删完继续部署。
// 返回删除后剩余的分支列表;用户取消 / 不是交互终端 / 删的不够时返回 null,调用方终止部署
async function cleanUpBranches(deployEntries) {
const needRemoveCount = deployEntries.length - MAX_DEPLOY_BRANCH_COUNT + 1;
console.log(`当前已部署分支数量已达到上限 ${MAX_DEPLOY_BRANCH_COUNT},需要先删除至少 ${needRemoveCount} 个:`);
deployEntries.forEach((item, index) => {
console.log(`${index + 1}. ${item.branchName} (${item.slug}) ${item.deployedAt}`);
});
if (!process.stdin.isTTY) {
console.log("当前终端不是交互模式,无法选择,请手动到服务器上删除后重试");
return null;
}
const { slugs } = await inquirer.prompt([
{
type: "checkbox",
name: "slugs",
message: `请选择要删除的分支(空格选中,回车确认,至少选 ${needRemoveCount} 个,不选直接回车则取消部署)`,
choices: deployEntries.map((item) => ({
name: `${item.branchName} (${item.slug}) ${item.deployedAt}`,
value: item.slug,
})),
validate: (value) =>
value.length === 0 || value.length >= needRemoveCount
? true
: `至少要选 ${needRemoveCount} 个才有空位`,
},
]);
if (slugs.length === 0) {
console.log("已取消部署");
return null;
}
const { confirmed } = await inquirer.prompt([
{
type: "confirm",
name: "confirmed",
message: `将从服务器永久删除 ${slugs.length} 个分支的部署目录,删除后无法恢复,确认吗?`,
default: false,
},
]);
if (!confirmed) {
console.log("已取消部署");
return null;
}
for (const slug of slugs) {
// 目录名来自服务器上 find 出来的结果,这里再校验一次再拼进 rm 命令,避免 .. 或带特殊字符的名字
if (!/^[\w-]+$/.test(slug)) {
throw new Error(`目录名不合法,已停止删除:${slug}`);
}
await execCommand(`rm -rf '${remoteRootPath}/${slug}'`);
console.log(`已删除:${slug}`);
}
return deployEntries.filter((item) => !slugs.includes(item.slug));
}
async function start() { async function start() {
try { try {
await connectServer(); await connectServer();
const folderList = await getFolderList(); const folderList = await getFolderList();
const deployEntries = await getDeployEntries(folderList); let deployEntries = await getDeployEntries(folderList);
const alreadyDeployed = deployEntries.some((item) => item.slug === deploySlug); const alreadyDeployed = deployEntries.some((item) => item.slug === deploySlug);
if (!alreadyDeployed && deployEntries.length >= MAX_DEPLOY_BRANCH_COUNT) { if (!alreadyDeployed && deployEntries.length >= MAX_DEPLOY_BRANCH_COUNT) {
console.log( deployEntries = await cleanUpBranches(deployEntries);
`当前已部署分支数量已达到上限 ${MAX_DEPLOY_BRANCH_COUNT},请先手动删除以下分支中的一个或多个:`, if (!deployEntries) {
);
deployEntries.forEach((item, index) => {
console.log(`${index + 1}. ${item.branchName} (${item.slug})`);
});
return; return;
} }
}
await writeIndexHtml(deployEntries); await writeIndexHtml(deployEntries);
await build(); await build();
......
...@@ -36,6 +36,7 @@ ...@@ -36,6 +36,7 @@
"@wecom/jssdk": "^2.3.3", "@wecom/jssdk": "^2.3.3",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"axios": "^1.13.0", "axios": "^1.13.0",
"cropperjs": "^2.2.0",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"element-plus": "^2.11.5", "element-plus": "^2.11.5",
......
...@@ -38,6 +38,9 @@ importers: ...@@ -38,6 +38,9 @@ importers:
axios: axios:
specifier: ^1.13.0 specifier: ^1.13.0
version: 1.13.0 version: 1.13.0
cropperjs:
specifier: ^2.2.0
version: 2.2.0
crypto-js: crypto-js:
specifier: ^4.2.0 specifier: ^4.2.0
version: 4.2.0 version: 4.2.0
...@@ -428,6 +431,39 @@ packages: ...@@ -428,6 +431,39 @@ packages:
'@bufbuild/protobuf@2.10.0': '@bufbuild/protobuf@2.10.0':
resolution: {integrity: sha512-fdRs9PSrBF7QUntpZpq6BTw58fhgGJojgg39m9oFOJGZT+nip9b0so5cYY1oWl5pvemDLr0cPPsH46vwThEbpQ==} resolution: {integrity: sha512-fdRs9PSrBF7QUntpZpq6BTw58fhgGJojgg39m9oFOJGZT+nip9b0so5cYY1oWl5pvemDLr0cPPsH46vwThEbpQ==}
'@cropper/element-canvas@2.2.0':
resolution: {integrity: sha512-H0Ay2ZSZrxWLP/8fSudH9hghsgIJh9fCgEmpfzD7MnQbE8HdQKljnoRxbrjuFT7Y0rmf405Sp9PmNA6T4twegw==}
'@cropper/element-crosshair@2.2.0':
resolution: {integrity: sha512-RXNhlsCd7lPTTjR2rfUEYghaL5bkqiU70qojJCUz1EIKfLriToDztjlsw3TZKJUBHYA+JOnmTBDFTnizoMUN1w==}
'@cropper/element-grid@2.2.0':
resolution: {integrity: sha512-1hxHLi7Jrek/xxeG12a9+TO18Ot4pCG4V9Zh+A0GMLsfFjrd2k/kl0wOq0x1isNNrP3kkIw47FdID2iWR8A48A==}
'@cropper/element-handle@2.2.0':
resolution: {integrity: sha512-+WPzHWF96RtLTAtotIcQQ6Sx3zVLQdK+qbaM2yKa841Iz60gQ3qKk7Jl5ndy2OgkEfxZaJI/3lWyf1RMGs/TEw==}
'@cropper/element-image@2.2.0':
resolution: {integrity: sha512-J0zBFMEfW6yuAmXoP6S+ZdNJy5J2duVsUS0N2o3D/HxHiVo4Vm3jrSexasu/Vfvwe4Lv0nmZ/10KW3RxhrvDCQ==}
'@cropper/element-selection@2.2.0':
resolution: {integrity: sha512-ATtWeEb1J/scQ98ins2jy4YbVOqz13F54jDk4Mm7OCpG0fzDseOujSLoIP8y26gtY7XGQXvn8WiFBbIZbeSgxQ==}
'@cropper/element-shade@2.2.0':
resolution: {integrity: sha512-fiurnjecablA8+17GoOjiNrzKWe85Q6h9JLbPJkxKCQxcxqpjEsOdGu3YlRxaBYcYvr6wH9tghufOlUfYJS9nw==}
'@cropper/element-viewer@2.2.0':
resolution: {integrity: sha512-w6uO7m7PwBWKynMwj8DYkJHFXrHC6Xxvcp5Y+tSxhfcNtOvw92FDncqRLYv+xHQoCDXEvfCqJyrTx9nIiZgiLQ==}
'@cropper/element@2.2.0':
resolution: {integrity: sha512-Kckk7TiWt3Cf9MvSfmMx7ZOIuN1rzIUIwIPQSqSj0Sl6gj6opo+CPax+fREvyvVKEzAug7L0sQFZ0TL4H5LsNw==}
'@cropper/elements@2.2.0':
resolution: {integrity: sha512-OQiVvEjIQUbMAUGpFrI/L95elHNIXjdlSxxzeywbU6C93PdPypXeGQfOSLQ43zUYmFutWNb7jTrHvgb3JaE+VQ==}
'@cropper/utils@2.2.0':
resolution: {integrity: sha512-wTrpZl+F4Kr2hzovqjztSexWUU+EVK1CxPymbYgFYoAS/NZXwCDsrvikJQn+HYZVxjqyo/ClnFxA0fG+tFy6Hw==}
'@csstools/color-helpers@6.0.2': '@csstools/color-helpers@6.0.2':
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
...@@ -2219,6 +2255,9 @@ packages: ...@@ -2219,6 +2255,9 @@ packages:
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
cropperjs@2.2.0:
resolution: {integrity: sha512-7egO+tXxt3jGHQcO6x4k1CVoMIlEu+4Klixc6ejspKOAns7onlC/lEi5tRi3qbuHVcATK7rqaZyp8LzynPz6Cg==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
...@@ -5272,6 +5311,72 @@ snapshots: ...@@ -5272,6 +5311,72 @@ snapshots:
'@bufbuild/protobuf@2.10.0': {} '@bufbuild/protobuf@2.10.0': {}
'@cropper/element-canvas@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-crosshair@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-grid@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-handle@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-image@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/element-canvas': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-selection@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/element-canvas': 2.2.0
'@cropper/element-image': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-shade@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/element-canvas': 2.2.0
'@cropper/element-selection': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-viewer@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/element-canvas': 2.2.0
'@cropper/element-image': 2.2.0
'@cropper/element-selection': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element@2.2.0':
dependencies:
'@cropper/utils': 2.2.0
'@cropper/elements@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/element-canvas': 2.2.0
'@cropper/element-crosshair': 2.2.0
'@cropper/element-grid': 2.2.0
'@cropper/element-handle': 2.2.0
'@cropper/element-image': 2.2.0
'@cropper/element-selection': 2.2.0
'@cropper/element-shade': 2.2.0
'@cropper/element-viewer': 2.2.0
'@cropper/utils@2.2.0': {}
'@csstools/color-helpers@6.0.2': {} '@csstools/color-helpers@6.0.2': {}
'@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
...@@ -7023,6 +7128,11 @@ snapshots: ...@@ -7023,6 +7128,11 @@ snapshots:
crc-32: 1.2.2 crc-32: 1.2.2
readable-stream: 4.7.0 readable-stream: 4.7.0
cropperjs@2.2.0:
dependencies:
'@cropper/elements': 2.2.0
'@cropper/utils': 2.2.0
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
......
...@@ -10,12 +10,14 @@ ...@@ -10,12 +10,14 @@
<component :is="Component" /> <component :is="Component" />
</router-view> </router-view>
</el-config-provider> </el-config-provider>
<DevUserSwitch />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { initWxConfig } from '@/utils/wxUtil/initWXConfig' import { initWxConfig } from '@/utils/wxUtil/initWXConfig'
import { Notivue, NotivueSwipe, Notification, pastelTheme } from 'notivue' import { Notivue, NotivueSwipe, Notification, pastelTheme } from 'notivue'
import Progress from '@/components/common/Progress/index.vue' import Progress from '@/components/common/Progress/index.vue'
import DevUserSwitch from '@/components/common/DevUserSwitch/index.vue'
const locale = ref(zhCn) const locale = ref(zhCn)
......
...@@ -22,6 +22,9 @@ import type { ...@@ -22,6 +22,9 @@ import type {
RelatedColumnItemDto, RelatedColumnItemDto,
RelatedColumnParams, RelatedColumnParams,
UpdateArticleShowLatestDto, UpdateArticleShowLatestDto,
LocateCommentParams,
CommentLocationDto,
PushRecallResultDto,
} from './types' } from './types'
import type { BackendServicePageResult, PageSearchParams } from '@/utils/request/types' import type { BackendServicePageResult, PageSearchParams } from '@/utils/request/types'
import { SpecificVideoRewardEnum, BooleanFlag } from '@/constants' import { SpecificVideoRewardEnum, BooleanFlag } from '@/constants'
...@@ -222,6 +225,17 @@ export const updateArticleRecommendAndSort = (data: UpdateArticleRecommendAndSor ...@@ -222,6 +225,17 @@ export const updateArticleRecommendAndSort = (data: UpdateArticleRecommendAndSor
} }
/** /**
* 撤回/取消推送。定时待推送时是取消这次定时推送,已推送时是撤回已发送的批次;
* 已经撤回的批次会自动跳过,重复调用失败可以重试。能不能点由列表 pushInfo.recallable 决定。
*/
export const recallPush = (articleId: number) => {
return service.request<PushRecallResultDto>({
url: `/cultureEventPushPersons/recallPush?articleId=${articleId}`,
method: 'POST',
})
}
/**
* 删除文章接口 * 删除文章接口
*/ */
export const deleteArticle = (id: number) => { export const deleteArticle = (id: number) => {
...@@ -362,6 +376,17 @@ export const getCommentDetail = (id: number) => { ...@@ -362,6 +376,17 @@ export const getCommentDetail = (id: number) => {
} }
/** /**
* 评论定位:消息通知带 commentId 跳进详情页时,换算出评论在第几页、在哪条楼层/评论下、回复在第几页
*/
export const locateComment = (data: LocateCommentParams) => {
return service.request<CommentLocationDto>({
url: `/api/cultureComment/locateComment`,
method: 'POST',
data,
})
}
/**
* 获取特定视频观看奖励 * 获取特定视频观看奖励
*/ */
export const getSpecificVideoWatchReward = (pageKey: SpecificVideoRewardEnum) => { export const getSpecificVideoWatchReward = (pageKey: SpecificVideoRewardEnum) => {
...@@ -421,3 +446,14 @@ export const updateArticleShowLatest = (data: UpdateArticleShowLatestDto) => { ...@@ -421,3 +446,14 @@ export const updateArticleShowLatest = (data: UpdateArticleShowLatestDto) => {
data, data,
}) })
} }
/**
* 后台内容管理删除文章(目前帖子管理在用)
*/
export const deleteArticleByAdmin = (id: number) => {
return service.request<boolean>({
url: '/api/cultureArticle/adminDeleteArticle',
method: 'POST',
data: { id },
})
}
...@@ -16,6 +16,16 @@ export interface ArticleSearchParams extends PageSearchParams { ...@@ -16,6 +16,16 @@ export interface ArticleSearchParams extends PageSearchParams {
title?: string title?: string
questionFocus?: number questionFocus?: number
status?: BooleanFlag status?: BooleanFlag
// 视频分类id,前台分类栏点击筛选用,只对视频类型生效,不传为全部
categoryId?: number
// 后台内容管理的搜索条件
/** 标签 id,多选 */
tagIdList?: number[]
/** 创建人,模糊查询 */
createUserName?: string
/** 创建时间范围,秒级时间戳,结束时间要转成当天 23:59:59;清空时设 undefined,不要设 0(0 会变成 create_time <= 0,一条都查不出来) */
createTimeStart?: number
createTimeEnd?: number
} }
/** /**
...@@ -26,11 +36,32 @@ export type AddOrUpdateArticleDto = ...@@ -26,11 +36,32 @@ export type AddOrUpdateArticleDto =
| AddOrUpdateColumnDto | AddOrUpdateColumnDto
| AddOrUpdateInterviewDto | AddOrUpdateInterviewDto
| AddOrUpdateVideoDto | AddOrUpdateVideoDto
| AddOrUpdateQuestionDto
/**
* 推送对象列表里的一项(部门或人员)
*/
export interface PushListItem {
valueId: string
valueType: number
valueName: string | number
}
/**
* 官方账号才有的推送设置字段,帖子/问吧/专栏/专访都共用这一套
*/
interface OfficialPushFields {
isPushAll?: BooleanFlag
pushType?: SendTypeEnum
pushTime?: string
pushList?: PushListItem[]
summary?: string
}
/** /**
* 添加帖子的DTO * 添加帖子的DTO
*/ */
export interface AddOrUpdatePostDto { export interface AddOrUpdatePostDto extends OfficialPushFields {
id?: number id?: number
title?: string title?: string
content?: string content?: string
...@@ -42,6 +73,28 @@ export interface AddOrUpdatePostDto { ...@@ -42,6 +73,28 @@ export interface AddOrUpdatePostDto {
type?: ArticleTypeEnum.POST type?: ArticleTypeEnum.POST
// 1是隐藏 // 1是隐藏
regionHide?: BooleanFlag regionHide?: BooleanFlag
// 主/副标签,都选填,不选就不传或者传空数组
mainTagId?: number | string
tagList?: { tagId: number; sort: number }[]
}
/**
* 添加问吧的DTO
*/
export interface AddOrUpdateQuestionDto extends OfficialPushFields {
id?: number
title?: string
content?: string
releaseStatus?: ReleaseStatusTypeEnum
sendType?: SendTypeEnum
// 问吧封面用内容图片的第一张,faceUrl 是前端自己派生后一起传的
imgUrl?: string
faceUrl?: string
sendTime?: string
type?: ArticleTypeEnum.QUESTION
regionHide?: BooleanFlag
mainTagId?: string
tagList?: { tagId: number; sort: number }[]
} }
interface AddOrUpdateColumnBase { interface AddOrUpdateColumnBase {
...@@ -96,9 +149,10 @@ export interface AddOrUpdateInterviewForm extends AddOrUpdateInterviewBase { ...@@ -96,9 +149,10 @@ export interface AddOrUpdateInterviewForm extends AddOrUpdateInterviewBase {
} }
interface PushSettingBase { interface PushSettingBase {
isPushAll?: BooleanFlag
pushType: SendTypeEnum pushType: SendTypeEnum
pushTime: string pushTime: string
pushList: { valueId: string; valueName: string | number }[] pushList: PushListItem[]
summary: string summary: string
} }
// 推送设置相关的字段 // 推送设置相关的字段
...@@ -126,6 +180,8 @@ export interface AddOrUpdateVideoDto { ...@@ -126,6 +180,8 @@ export interface AddOrUpdateVideoDto {
tagList: { tagId: number; sort: number }[] | number[] tagList: { tagId: number; sort: number }[] | number[]
relateColumnId?: number relateColumnId?: number
isShowLatest?: BooleanFlag isShowLatest?: BooleanFlag
// 视频分类,最多5个,选填;不传表示不改动已有分类,传空数组表示清空
categoryIdList?: number[]
} }
/** /**
...@@ -214,6 +270,56 @@ export interface ArticleItemDto { ...@@ -214,6 +270,56 @@ export interface ArticleItemDto {
isOfficialAccount: BooleanFlag isOfficialAccount: BooleanFlag
deptId: string deptId: string
isShowLatest?: BooleanFlag isShowLatest?: BooleanFlag
publishTime: number
// 官方账号推送的文章才有这个字段,列表接口直接带出来,不用再单独查一次推送状态
pushInfo?: ArticlePushInfoDto
// 实践的查看权限回显(详情接口才有),结构跟 addOrUpdatePractice 保存时传的一样
typeUpdates?: ArticleTypeUpdateEchoDto[]
// 视频分类,只有视频类型才有;categoryIdList/categoryNameList 按下标一一对应,
// 详情接口回显的分类里可能包含已被设为不公开的分类,前端需要把它们并进下拉选项里
categoryIdList?: number[]
categoryNameList?: string[]
}
/**
* 实践查看权限的回显(getArticleDetail 返回),结构跟保存时的 PracticeTypeUpdateDto 一样,
* 只是多了 valueType=0 的「公开」这一组(没选范围时后端存的默认值),回显时要跳过它
*/
export interface ArticleTypeUpdateEchoDto {
/** 0 公开 / 1 部门 / 2 人员 */
valueType: number
finalPermissions: { valueId: string; valueName: string }[]
}
/**
* 文章的推送状态(列表接口 pushInfo 字段),后端已经算好了 recallable(含 24 小时窗口、
* 定时待推送等情况),前端直接拿来控制"撤回推送"按钮显隐,不用自己拿 lastSendTime 去算。
*/
export interface ArticlePushInfoDto {
articleId: number
/** 0 未推送 / 1 已推送 / 2 定时待推送 / 3 已全部撤回 */
pushStatus: number
recallable: boolean
totalBatch: number
recalledBatch: number
receiverNum: number
lastSendTime: number | null
scheduleTime: number | null
}
/**
* 撤回/取消推送的结果。后端撤回失败时不会抛异常(部分批次成功时状态要保留),
* HTTP 仍然是 200,所以前端必须看 failCount 才知道到底撤没撤掉。
*/
export interface PushRecallResultDto {
/** 本次需要撤回的批次数 */
total: number
successCount: number
failCount: number
/** 是否属于「取消尚未执行的定时推送」 */
cancelled: boolean
/** 结果说明,可直接展示给用户 */
message: string
} }
/** /**
...@@ -333,6 +439,44 @@ export interface CommentChildrenSearchParams extends PageSearchParams { ...@@ -333,6 +439,44 @@ export interface CommentChildrenSearchParams extends PageSearchParams {
} }
/** /**
* 评论定位入参。sortType / size 这些要跟前端列表当前用的保持一致,后端才能算出对得上的页码
*/
export interface LocateCommentParams {
commentId: number
articleId?: number
/** 楼层列表(问吧是回答列表)的排序 */
sortType?: number
/** 问吧专用:回答下面「评论」列表的排序 */
secondSortType?: number
size?: number
secondSize?: number
/** 展开后的回复列表每页条数 */
childSize?: number
}
/**
* 评论定位结果。展示层级:帖子是 楼层 → 回复;问吧是 回答 → 评论 → 回复
*/
export interface CommentLocationDto {
found: boolean
message?: string
targetCommentId: number
articleId: number
rootCommentId: number
rootIndex: number
page: number
/** 问吧专用:目标所在的「评论」id,目标就是回答本身时为空 */
secondCommentId?: number
secondIndex?: number
secondPage?: number
isChild: boolean
childIndex?: number
/** 回复在默认展示的前几条里,不用点「展开」 */
childInPreview?: boolean
childPage?: number
}
/**
* 新增评论 * 新增评论
*/ */
export interface AddCommentDto { export interface AddCommentDto {
...@@ -415,7 +559,7 @@ export interface SearchMoreColumnItemDto { ...@@ -415,7 +559,7 @@ export interface SearchMoreColumnItemDto {
type: ArticleTypeEnum.COLUMN type: ArticleTypeEnum.COLUMN
videoDuration: string videoDuration: string
viewCount: number viewCount: number
publishTime:number publishTime: number
} }
/** /**
......
...@@ -154,6 +154,18 @@ export const deleteWheelPrize = (idList: number[]) => { ...@@ -154,6 +154,18 @@ export const deleteWheelPrize = (idList: number[]) => {
}) })
} }
/**
* 批量启用/禁用转盘奖品。是「设置」不是「切换」:isEnabled 传 1 就把这批全部启用,传 0 就全部禁用。
* 批量启用会一次性校验总概率,超过 100% 整批失败(后端错误信息会由请求拦截器自动弹出来)。
*/
export const batchUpdateWheelPrizeStatus = (data: { idList: number[]; isEnabled: BooleanFlag }) => {
return service.request({
url: '/api/cultureWheelPrizes/batchUpdateStatus',
method: 'POST',
data,
})
}
export const getWheelConfig = () => { export const getWheelConfig = () => {
return service.request<BackendWheelConfigDto>({ return service.request<BackendWheelConfigDto>({
url: '/api/cultureWheelPrizes/getWheelConfig', url: '/api/cultureWheelPrizes/getWheelConfig',
......
...@@ -40,6 +40,28 @@ export const deleteShopItem = (id: number) => { ...@@ -40,6 +40,28 @@ export const deleteShopItem = (id: number) => {
method: 'POST', method: 'POST',
}) })
} }
/**
* 批量修改商品是否展示在前台。注意商城这边字段叫 enable,跟其它模块的 isDisplay 不是一回事。
*/
export const changeProductEnable = (data: { idList: number[]; enable: number }) => {
return service.request({
url: '/api/culture/shop/item/changeProductEnable',
method: 'POST',
data,
})
}
/**
* 批量删除商品
*/
export const batchDeleteProduct = (idList: number[]) => {
return service.request({
url: '/api/culture/shop/item/batchDeleteProduct',
method: 'POST',
data: { idList },
})
}
/** /**
* 后台商品领用列表 * 后台商品领用列表
*/ */
......
import service from '@/utils/request/index'
import type {
DepartmentDto,
DepartmentListParams,
DepartmentUserDto,
DepartmentUserListParams,
} from './types'
/** 获取扁平部门列表,由组件根据 pid 构建部门树。 */
export const getAllDept = (data: DepartmentListParams = {}) => {
return service.request<DepartmentDto[]>({
url: '/api/dept/getAllDept',
method: 'POST',
data,
})
}
/** 获取指定部门成员;传 keyword 时用于全局搜索人员。 */
export const getDeptUsers = (data: DepartmentUserListParams) => {
return service.request<DepartmentUserDto[]>({
url: '/api/dept/getDeptUsers',
method: 'POST',
data,
})
}
/** 部门树节点 */
export interface DepartmentDto {
deptId: string | number
pid: string | number
pids?: string
simpleName: string
fullName: string
hasUserFlag: number
children?: DepartmentDto[]
}
/** 部门成员 */
export interface DepartmentUserDto {
userId: string | number
name: string
account?: string
jobNumId?: string
avatar?: string
deptId: string | number
}
/** 获取部门树的查询参数 */
export interface DepartmentListParams {
deptIdList?: Array<string | number>
simpleName?: string
}
/** 获取部门成员或搜索人员的查询参数 */
export interface DepartmentUserListParams {
deptId?: string | number
keyword?: string
/** 按 userId(或企微账号)批量查人,传了就忽略 deptId/keyword */
userIdList?: string[]
}
...@@ -17,6 +17,8 @@ export * from './dailyLottery' ...@@ -17,6 +17,8 @@ export * from './dailyLottery'
export * from './launchCampaign' export * from './launchCampaign'
export * from './selfMessage' export * from './selfMessage'
export * from './luckyWheel' export * from './luckyWheel'
export * from './deptUser'
export * from './videoCategory'
// 导出类型 // 导出类型
export * from './task/types' export * from './task/types'
export * from './shop/types' export * from './shop/types'
...@@ -36,3 +38,5 @@ export * from './dailyLottery/types' ...@@ -36,3 +38,5 @@ export * from './dailyLottery/types'
export * from './launchCampaign/types' export * from './launchCampaign/types'
export * from './selfMessage/types' export * from './selfMessage/types'
export * from './luckyWheel/types' export * from './luckyWheel/types'
export * from './deptUser/types'
export * from './videoCategory/types'
...@@ -2,6 +2,16 @@ import { ArticleTypeEnum, ReleaseStatusTypeEnum, BooleanFlag, SendTypeEnum } fro ...@@ -2,6 +2,16 @@ import { ArticleTypeEnum, ReleaseStatusTypeEnum, BooleanFlag, SendTypeEnum } fro
import type { PageSearchParams } from '@/utils/request/types' import type { PageSearchParams } from '@/utils/request/types'
/** /**
* 保存查看权限时传的一组:valueType 0 公开 / 1 部门 / 2 人员(valueType=2 时 valueId 存的是 user_id,不是企微账号)。
* 字段名必须是 finalPermissions,跟详情接口返回的结构完全一致——后端不认识的字段会被直接忽略,
* 传错了后端会当成「没选范围」,按默认规则存(官方账号就是公开)。finalPermissions 传空数组表示删掉这一类
*/
export interface PracticeTypeUpdateDto {
valueType: 0 | 1 | 2
finalPermissions: { valueId: string; valueName: string }[]
}
/**
* 添加或更新实践DTO * 添加或更新实践DTO
*/ */
export interface AddOrUpdatePracticeDto { export interface AddOrUpdatePracticeDto {
...@@ -17,6 +27,8 @@ export interface AddOrUpdatePracticeDto { ...@@ -17,6 +27,8 @@ export interface AddOrUpdatePracticeDto {
sendTime: string sendTime: string
deptId?: string deptId?: string
type?: ArticleTypeEnum type?: ArticleTypeEnum
// 查看权限:一个都没选就是公开,不传这个字段(不要传空数组硬凑)
typeUpdates?: PracticeTypeUpdateDto[]
} }
/** /**
......
import service from '@/utils/request/index'
import type { BooleanFlag } from '@/constants'
import type { VideoCategoryItemDto, AddOrUpdateVideoCategoryDto } from './types'
/**
* 视频分类列表-后台管理:含未公开的,已按排序返回,name 不传为全部
*/
export const listVideoCategoryForManage = (name?: string) =>
service.request<VideoCategoryItemDto[]>({
url: '/api/cultureVideoCategory/listForManage',
method: 'POST',
params: { name },
})
/**
* 视频分类列表-前台:只返回公开的,已按排序返回,前台分类栏与提交/编辑视频的下拉共用
*/
export const listVideoCategoryPublic = () =>
service.request<VideoCategoryItemDto[]>({
url: '/api/cultureVideoCategory/listPublic',
method: 'POST',
})
/**
* 新增或修改分类:不传 id 为新增,传 id 为改名
*/
export const addOrUpdateVideoCategory = (data: AddOrUpdateVideoCategoryDto) =>
service.request<boolean>({
url: '/api/cultureVideoCategory/addOrUpdate',
method: 'POST',
data,
})
/**
* 删除分类:会同时移除该分类下所有视频的关联,视频本身不受影响
*/
export const deleteVideoCategory = (id: number) =>
service.request<boolean>({
url: '/api/cultureVideoCategory/delete',
method: 'POST',
params: { id },
})
/**
* 公开/不公开分类:isPublic 1公开 0不公开
*/
export const changeVideoCategoryPublic = (id: number, isPublic: BooleanFlag) =>
service.request<boolean>({
url: '/api/cultureVideoCategory/changePublic',
method: 'POST',
params: { id, isPublic },
})
/**
* 分类排序:传拖动后排好序的完整 id 列表,后端按下标重排
*/
export const updateVideoCategorySort = (idList: number[]) =>
service.request<boolean>({
url: '/api/cultureVideoCategory/updateSort',
method: 'POST',
data: idList,
})
/**
* 设置某个视频的分类(后台视频列表里改分类用):全量覆盖,传空数组等于清空
*/
export const bindVideoCategoryToArticle = (articleId: number, categoryIdList: number[]) =>
service.request<boolean>({
url: '/api/cultureVideoCategory/bindArticle',
method: 'POST',
data: { articleId, categoryIdList },
})
import type { BooleanFlag } from '@/constants'
/**
* 视频分类
*/
export interface VideoCategoryItemDto {
id: number
name: string
sort: number
isPublic: BooleanFlag
}
/**
* 新增或修改视频分类:不传 id 为新增(后端自动排最后、默认公开),传 id 为改名
*/
export interface AddOrUpdateVideoCategoryDto {
id?: number
name: string
}
...@@ -95,6 +95,7 @@ ...@@ -95,6 +95,7 @@
alt="" alt=""
class="w-12 h-12 rounded-full object-cover cursor-pointer" class="w-12 h-12 rounded-full object-cover cursor-pointer"
@click=" @click="
!isPreview &&
jumpToUserHomePage({ jumpToUserHomePage({
userId: articleDetail.createUserId, userId: articleDetail.createUserId,
type: articleDetail.type, type: articleDetail.type,
...@@ -163,7 +164,7 @@ ...@@ -163,7 +164,7 @@
{{ articleType }} {{ articleType }}
</span> </span>
<ActionMore :articleDetail="articleDetail" /> <ActionMore v-if="!isPreview" :articleDetail="articleDetail" />
</div> </div>
</div> </div>
</div> </div>
...@@ -299,16 +300,16 @@ import ActionMore from '@/components/common/ActionMore/index.vue' ...@@ -299,16 +300,16 @@ import ActionMore from '@/components/common/ActionMore/index.vue'
import SendMessageDialog from '@/components/common/SendMessageDialog/index.vue' import SendMessageDialog from '@/components/common/SendMessageDialog/index.vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import type { Directive } from 'vue' import { useImagePreview, useNavigation } from '@/hooks'
import { useNavigation } from '@/hooks'
const userStore = useUserStore() const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore) const { userInfo } = storeToRefs(userStore)
const router = useRouter() const router = useRouter()
const { jumpToUserHomePage } = useNavigation() const { jumpToUserHomePage } = useNavigation()
const { articleDetail, isAudit } = defineProps<{ const { articleDetail, isAudit, isPreview = false } = defineProps<{
articleDetail: ArticleItemDto articleDetail: ArticleItemDto
isAudit: boolean // 是否是审核页面 isAudit: boolean // 是否是审核页面
isPreview?: boolean // 是否是发布页的预览:数据是前端拼的,头像不跳转、不展示举报
}>() }>()
const sendMessageDialogRef = ref<InstanceType<typeof SendMessageDialog> | null>(null) const sendMessageDialogRef = ref<InstanceType<typeof SendMessageDialog> | null>(null)
...@@ -358,12 +359,13 @@ const isAuthor = computed(() => { ...@@ -358,12 +359,13 @@ const isAuthor = computed(() => {
return articleDetail.createUserId === userInfo.value.userId return articleDetail.createUserId === userInfo.value.userId
}) })
// 如果类型是帖子 专栏 和 专访 就是html // 如果类型是帖子 专栏 和 专访 和 问吧 就是html
const isHtml = computed(() => { const isHtml = computed(() => {
return ( return (
articleDetail.type === ArticleTypeEnum.POST || articleDetail.type === ArticleTypeEnum.POST ||
articleDetail.type === ArticleTypeEnum.COLUMN || articleDetail.type === ArticleTypeEnum.COLUMN ||
articleDetail.type === ArticleTypeEnum.INTERVIEW articleDetail.type === ArticleTypeEnum.INTERVIEW ||
articleDetail.type === ArticleTypeEnum.QUESTION
) )
}) })
...@@ -386,116 +388,5 @@ const showBottomVideo = computed( ...@@ -386,116 +388,5 @@ const showBottomVideo = computed(
articleDetail.articleVideoUrl, articleDetail.articleVideoUrl,
) )
const showPreview = ref(false) const { showPreview, currentPreviewIndex, srcList, vImagePreview } = useImagePreview()
const currentPreviewIndex = ref(0)
const srcList = ref<string[]>([])
// 图片预览的指令
const vImagePreview: Directive = {
mounted(el: HTMLElement) {
const imgs = el.querySelectorAll('img')
imgs.forEach((item, index) => {
item.style.cursor = 'pointer'
if (item.src) {
srcList.value.push(item.src)
item.addEventListener('click', () => {
currentPreviewIndex.value = index
showPreview.value = true
})
}
})
},
}
</script> </script>
<style scoped>
.html-content {
font-size: 18px;
line-height: 1.8;
color: #374151;
word-break: break-word;
}
.html-content :deep(p) {
margin: 1em 0;
}
.html-content :deep(h1),
.html-content :deep(h2),
.html-content :deep(h3),
.html-content :deep(h4),
.html-content :deep(h5),
.html-content :deep(h6) {
margin: 1.2em 0 0.6em;
line-height: 1.4;
font-weight: 600;
color: #111827;
}
.html-content :deep(h1) {
font-size: 1.7em;
}
.html-content :deep(h2) {
font-size: 1.5em;
}
.html-content :deep(h3) {
font-size: 1.3em;
}
.html-content :deep(ul),
.html-content :deep(ol) {
margin: 1em 0;
padding-left: 1.5em;
}
.html-content :deep(ul) {
list-style: disc;
}
.html-content :deep(ol) {
list-style: decimal;
}
.html-content :deep(li) {
margin: 0.35em 0;
}
.html-content :deep(blockquote) {
margin: 1em 0;
padding: 0.6em 1em;
border-left: 4px solid #d1d5db;
color: #6b7280;
background: #f9fafb;
}
.html-content :deep(pre),
.html-content :deep(code) {
font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
}
.html-content :deep(pre) {
margin: 1em 0;
padding: 0.8em 1em;
border-radius: 6px;
overflow-x: auto;
background: #f3f4f6;
}
.html-content :deep(code) {
padding: 0.1em 0.35em;
border-radius: 4px;
background: #f3f4f6;
}
.html-content :deep(pre code) {
padding: 0;
background: transparent;
}
.html-content :deep(a) {
color: #2563eb;
text-decoration: underline;
}
</style>
<template>
<el-dialog v-model="visible" title="内容" width="800px" append-to-body>
<el-scrollbar max-height="60vh">
<div v-if="isHtml" class="html-content" v-html="content"></div>
<div v-else class="plain-content">{{ content }}</div>
</el-scrollbar>
</el-dialog>
</template>
<script setup lang="ts">
/**
* 后台内容管理各个列表页"点击查看内容"共用的弹窗。
*
* 专栏/专访/问吧/帖子是富文本(WangEditor),内容本身就带 <p>、<span> 这些标签,要用 v-html
* 才能正常显示;实践的内容是纯文本 textarea 存的,直接当 html 用 v-html 渲染的话,用户内容里
* 随手打的 "<"、"&" 这些字符会被当成标签解析,显示错乱。所以这里不按"是哪个类型"区分,而是
* 直接检测内容里有没有真正的 html 标签,有就当富文本用 v-html,没有就当纯文本原样显示。
*/
const visible = ref(false)
const content = ref('')
// 粗略判断:内容里有没有形如 <xxx ...> 的标签。纯文本里正常打字基本不会凑出这个模式。
const HTML_TAG_REGEX = /<[a-z][^>]*>/i
const isHtml = computed(() => HTML_TAG_REGEX.test(content.value))
const open = (value: string) => {
content.value = value || ''
visible.value = true
}
defineExpose({ open })
</script>
<style scoped>
.plain-content {
white-space: pre-wrap;
word-break: break-all;
line-height: 1.6;
}
</style>
// 楼层下默认直接展示的回复条数 / 「展开回复」后每页条数,跟后端 getComment、评论定位接口的口径一致
export const PREVIEW_CHILD_SIZE = 5
export const CHILD_PAGE_SIZE = 10
...@@ -186,15 +186,15 @@ ...@@ -186,15 +186,15 @@
</div> </div>
</div> </div>
<!-- 回复列表 --> <!-- 回复列表 -->
<!-- 问吧 和 其他 做区分 -->
<template v-if="!isQuestion">
<div v-if="item.children?.length" class="mt-3 ml-4 space-y-3"> <div v-if="item.children?.length" class="mt-3 ml-4 space-y-3">
<!-- 回复评论的内容 里面可能是 展示全部的 也有可能是展示5条之内容 --> <!-- 回复评论的内容 里面可能是 展示全部的 也有可能是展示5条之内容 -->
<div <div
v-for="child in getCurrentChildrenList(item)" v-for="child in getCurrentChildrenList(item)"
v-loading="item.loadingChildren" v-loading="item.loadingChildren"
:key="child.id" :key="child.id"
:ref="(el) => setReplyEl(child.id, el)"
class="flex gap-2 p-3 rounded-lg" class="flex gap-2 p-3 rounded-lg"
:class="{ 'reply-locate-highlight': highlightCommentId === child.id }"
> >
<img <img
@click="jumpToUserHomePage({ userId: child.userId, type })" @click="jumpToUserHomePage({ userId: child.userId, type })"
...@@ -278,27 +278,15 @@ ...@@ -278,27 +278,15 @@
</div> </div>
</div> </div>
</div> </div>
</template> <!-- 回复超过默认展示条数才会显示这个 -->
<!-- 问吧 如果有 子评论 直接变成 查看更多 --> <div class="ml-4" v-show="item.childrenNum > PREVIEW_CHILD_SIZE">
<template v-else>
<div v-if="item.childNum">
<button
class="cursor-pointer text-sm text-gray-500 mt-2"
@click="handleOpenCommentDialog(item)"
>
查看全部{{ item.childNum }} 条回复>
</button>
</div>
</template>
<!-- 只有大于5 才会显示这个 -->
<div class="ml-4" v-show="item.childrenNum > 5 && !isQuestion">
<!-- 展示 展开回复 --> <!-- 展示 展开回复 -->
<button <button
v-show="!item.showChildrenPage" v-show="!item.showChildrenPage"
class="cursor-pointer text-sm text-gray-500" class="cursor-pointer text-sm text-gray-500"
@click="handleExpandReply(item)" @click="handleExpandReply(item)"
> >
还有{{ item.childrenNum - 5 }}条回复,<span 还有{{ item.childrenNum - PREVIEW_CHILD_SIZE }}条回复,<span
class="hover:text-blue-500 transition-colors" class="hover:text-blue-500 transition-colors"
>点击查看</span >点击查看</span
> >
...@@ -306,13 +294,14 @@ ...@@ -306,13 +294,14 @@
<!-- 展示 收起回复 以及分页 --> <!-- 展示 收起回复 以及分页 -->
<div class="flex items-center gap-2" v-show="item.showChildrenPage"> <div class="flex items-center gap-2" v-show="item.showChildrenPage">
<span class="text-sm text-gray-500" <span class="text-sm text-gray-500"
>共{{ Math.ceil(item.childrenNum / 10) }}页</span >共{{ Math.ceil(item.childrenNum / CHILD_PAGE_SIZE) }}页</span
> >
<el-pagination <el-pagination
:pager-count="5" :pager-count="5"
layout="pager" layout="pager"
v-show="item.showChildrenPage" v-show="item.showChildrenPage"
:current-page="item.childrenPageCurrent" :current-page="item.childrenPageCurrent"
:page-size="CHILD_PAGE_SIZE"
:total="item.childrenNum" :total="item.childrenNum"
@current-change="handleChildrenCurrentChange($event, item, index)" @current-change="handleChildrenCurrentChange($event, item, index)"
/> />
...@@ -386,7 +375,6 @@ ...@@ -386,7 +375,6 @@
</div> </div>
</div> </div>
</div> </div>
<CommentListDialog ref="commentListDialogRef" :articleId="id" :pid="currentDialogCommentPid" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
...@@ -396,21 +384,25 @@ import { ...@@ -396,21 +384,25 @@ import {
addComment, addComment,
getCommentChildren, getCommentChildren,
getSecondCommentList, getSecondCommentList,
getSecondCommentChildren,
topOrCancelTopComment, topOrCancelTopComment,
deleteComment, deleteComment,
locateComment as locateCommentApi,
} from '@/api' } from '@/api'
import { useMessageBox, usePageSearch, useScrollTop } from '@/hooks' import { useMessageBox, usePageSearch, useScrollTop, scrollToLocate } from '@/hooks'
import { ArticleTypeEnum, BooleanFlag } from '@/constants' import { ArticleTypeEnum, BooleanFlag } from '@/constants'
import type { CommentItemDto } from '@/api' import type { CommentItemDto } from '@/api'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import CommentListDialog from '../CommentListDialog/index.vue'
import { useNavigation } from '@/hooks' import { useNavigation } from '@/hooks'
import { parseEmoji } from '@/utils/emoji' import { parseEmoji } from '@/utils/emoji'
import CommentBox from '../CommentBox/index.vue' import CommentBox from '../CommentBox/index.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { push } from 'notivue' import { push } from 'notivue'
import { IS_REAL_KEY_COMMENT, CommentSortTypeEnum } from '@/constants' import { IS_REAL_KEY_COMMENT, CommentSortTypeEnum } from '@/constants'
import type { ComponentPublicInstance } from 'vue'
import type { CommentLocateTarget } from './types'
import { PREVIEW_CHILD_SIZE, CHILD_PAGE_SIZE } from './constants'
const { jumpToUserHomePage } = useNavigation() const { jumpToUserHomePage } = useNavigation()
const { confirm } = useMessageBox() const { confirm } = useMessageBox()
...@@ -456,7 +448,6 @@ const isAuthor = computed(() => { ...@@ -456,7 +448,6 @@ const isAuthor = computed(() => {
return authorId === userInfo.value.userId return authorId === userInfo.value.userId
}) })
const commentRef = useTemplateRef<HTMLElement | null>('commentRef') const commentRef = useTemplateRef<HTMLElement | null>('commentRef')
const commentListDialogRef = useTemplateRef<typeof CommentListDialog>('commentListDialogRef')
const replyToOtherBoxRefList = ref<InstanceType<typeof CommentBox>[]>([]) const replyToOtherBoxRefList = ref<InstanceType<typeof CommentBox>[]>([])
const commentItemRefList = ref<HTMLElement[]>([]) const commentItemRefList = ref<HTMLElement[]>([])
// 回滚到评论框 // 回滚到评论框
...@@ -491,6 +482,9 @@ const { ...@@ -491,6 +482,9 @@ const {
...item, ...item,
imgUrlList: getCommentImageList(item.imgUrl), imgUrlList: getCommentImageList(item.imgUrl),
children: item.children?.map(formatCommentImageList) ?? [], children: item.children?.map(formatCommentImageList) ?? [],
// 问吧(getQuestionComment)用的是 childNum,帖子(getComment)用的是 childrenNum,
// 这里统一映射成 childrenNum,模板就不用再按 isQuestion 分支判断了
childrenNum: item.childrenNum ?? item.childNum,
} }
// if (obj.showChildrenPage == undefined) { // if (obj.showChildrenPage == undefined) {
obj.showChildrenPage = false obj.showChildrenPage = false
...@@ -511,6 +505,24 @@ const { ...@@ -511,6 +505,24 @@ const {
immediate, immediate,
}) })
// 问吧场景下,commentId(对应回答自己的评论 id)一开始可能还没有——比如某个问题/回答刚发布、
// 还没有第一条评论时,askTab/questionDetail 传进来的 commentId 是 undefined,这个 Comment 实例
// 会用 articleId 分支初始化 searchParams;之后一旦这个问题/回答有了第一条评论,父组件会把真实的
// commentId 传进来,但组件不会重新 mount,searchParams 不会自动跟着换成 pid 分支,
// 导致后续 search() 还是带着 articleId 去查 getQuestionComment,后端报"参数缺失"。
// 在真正发起搜索前校正一次 searchParams,而不是被动 watch commentId。
let syncedCommentId: number | undefined
const syncSearchTarget = () => {
if (!isQuestion || !commentId || syncedCommentId === commentId) return
syncedCommentId = commentId
searchParams.value = {
current: searchParams.value.current,
size: searchParams.value.size,
sortType: searchParams.value.sortType,
pid: commentId,
} as unknown as typeof searchParams.value
}
const commentSortOptions = [ const commentSortOptions = [
{ label: '最高点赞', value: CommentSortTypeEnum.MOST_LIKE }, { label: '最高点赞', value: CommentSortTypeEnum.MOST_LIKE },
{ label: '最多评论', value: CommentSortTypeEnum.MOST_COMMENT }, { label: '最多评论', value: CommentSortTypeEnum.MOST_COMMENT },
...@@ -712,11 +724,12 @@ const handleChildrenCurrentChange = (e: number, item: CommentItemDto, index: num ...@@ -712,11 +724,12 @@ const handleChildrenCurrentChange = (e: number, item: CommentItemDto, index: num
// 根据页数获取子评论列表 // 根据页数获取子评论列表
const getCommentChildrenList = async (item: CommentItemDto) => { const getCommentChildrenList = async (item: CommentItemDto) => {
const { data } = await getCommentChildren({ const fetchChildren = isQuestion ? getSecondCommentChildren : getCommentChildren
const { data } = await fetchChildren({
pid: item.id, pid: item.id,
articleId: id, articleId: id,
current: item.childrenPageCurrent, current: item.childrenPageCurrent,
size: 10, size: CHILD_PAGE_SIZE,
}) })
item.childrenPageList = data.list.map(formatCommentImageList) item.childrenPageList = data.list.map(formatCommentImageList)
} }
...@@ -730,15 +743,101 @@ const getCurrentChildrenList = (item: CommentItemDto) => { ...@@ -730,15 +743,101 @@ const getCurrentChildrenList = (item: CommentItemDto) => {
} }
} }
const currentDialogCommentPid = ref(0) // ==================== 评论定位(消息通知带 commentId 跳进详情页) ====================
const handleOpenCommentDialog = (item: CommentItemDto) => {
currentDialogCommentPid.value = item.id // 回复的 DOM,定位时滚动用。用函数 ref 收集,不去 querySelector
commentListDialogRef.value?.open() const replyElMap = new Map<number, HTMLElement>()
const setReplyEl = (replyId: number, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
replyElMap.set(replyId, el)
} else {
replyElMap.delete(replyId)
}
}
// 只高亮真正的目标那一条(楼层或者某条回复),不父子一起闪
const flashHighlight = async (commentId: number) => {
highlightCommentId.value = null
await nextTick()
highlightCommentId.value = commentId
setTimeout(() => {
if (highlightCommentId.value === commentId) {
highlightCommentId.value = null
}
}, 2400)
}
/**
* 把本评论区切到目标所在页,目标是被折叠的回复时翻开对应的回复页,然后滚一次、高亮目标。
* 问吧详情页定位到回答下的评论/回复时,也是直接调这个。
*/
const focusTarget = async (target: CommentLocateTarget) => {
syncSearchTarget()
searchParams.value.sortType = target.sortType
searchParams.value.current = target.page
await search()
const index = list.value.findIndex((item: CommentItemDto) => item.id === target.floorId)
const item = list.value[index]
if (!item) {
push.warning('评论位置有变化,没有定位到这条评论')
return
}
const isReply = target.targetId !== target.floorId
if (isReply && !target.childInPreview && target.childPage) {
item.childrenPageCurrent = target.childPage
await handleExpandReply(item)
}
await nextTick()
const floorEl = commentItemRefList.value[index]
if (!floorEl) return
const targetEl = isReply ? replyElMap.get(target.targetId) : floorEl
await scrollToLocate(floorEl, targetEl ?? floorEl)
flashHighlight(targetEl ? target.targetId : target.floorId)
}
/**
* 帖子/视频详情页用:按 commentId 定位。定位失败也要把评论区正常加载出来,不然进来看着像 0 条评论
*/
const locateComment = async (commentId: number) => {
try {
const { data } = await locateCommentApi({
commentId,
articleId: id,
sortType: searchParams.value.sortType,
size: searchParams.value.size,
childSize: CHILD_PAGE_SIZE,
})
if (data?.found) {
await focusTarget({
floorId: data.rootCommentId,
page: data.page,
sortType: searchParams.value.sortType,
targetId: data.targetCommentId,
childInPreview: data.childInPreview,
childPage: data.childPage,
})
return
}
push.warning(data?.message || '没有找到这条评论')
} catch (error) {
// 接口报错时全局拦截器已经弹过提示了
console.error(error)
}
searchParams.value.current = 1
await search()
} }
defineExpose({ defineExpose({
scrollToCommentBox: () => handleBackTop(), scrollToCommentBox: () => handleBackTop(),
search: () => search(), search: () => {
syncSearchTarget()
return search()
},
locateComment,
focusTarget,
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
...@@ -822,6 +921,10 @@ defineExpose({ ...@@ -822,6 +921,10 @@ defineExpose({
animation: commentBarFlash 2.4s ease-out; animation: commentBarFlash 2.4s ease-out;
} }
.reply-locate-highlight {
animation: commentFlash 2.4s ease-out;
}
@keyframes commentFlash { @keyframes commentFlash {
0% { 0% {
background-color: rgba(99, 102, 241, 0.16); background-color: rgba(99, 102, 241, 0.16);
......
import type { CommentSortTypeEnum } from '@/constants'
/**
* 评论区自己这一层要定位到哪。
* 帖子/视频的评论区,列表是楼层;问吧回答下内嵌的评论区,列表是回答下面的评论。
*/
export interface CommentLocateTarget {
/** 本评论区列表里要找的那一条 */
floorId: number
/** floorId 在本评论区列表里的页码 */
page: number
/** 算页码时用的排序,定位时会把列表切到这个排序,保证页码对得上 */
sortType: CommentSortTypeEnum
/** 真正要滚动到、高亮的评论:可能是 floorId 本身,也可能是它下面的某条回复 */
targetId: number
/** 目标是回复时:是否在默认展示的前几条里(不用点展开) */
childInPreview?: boolean
/** 目标是回复且不在默认展示里时:在展开后的第几页 */
childPage?: number
}
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="650px"
class="rounded-2xl!"
:show-close="false"
top="5vh"
append-to-body
destroy-on-close
>
<!-- 自定义头部 -->
<template #header="{ close, titleId, titleClass }">
<div class="flex items-center gap-3 py-4 px-2 border-b border-gray-100">
<el-icon
class="cursor-pointer hover:text-blue-500 transition-colors"
@click="close"
:size="24"
>
<IEpArrowLeft />
</el-icon>
<span :id="titleId" :class="titleClass" class="text-lg font-bold text-gray-800">
评论回复
</span>
</div>
</template>
<div class="flex flex-col h-[75vh]">
<!-- 中间滚动区域 -->
<div ref="scrollContainer" class="flex-1 overflow-y-auto custom-scrollbar p-6 pt-0">
<!-- 1. 顶部:父级评论展示 -->
<div v-if="parentComment" class="flex gap-4 bg-gray-50 p-5 rounded-xl">
<img
:src="parentComment.avatar"
class="w-12 h-12 rounded-full object-cover border-2 border-gray-200 flex-shrink-0"
/>
<div class="flex-1">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-1.5">
<span class="font-bold text-gray-900 text-base">{{ parentComment.replyUser }}</span>
<button
v-if="isSelfComment(parentComment)"
class="cursor-pointer text-red-500 text-13px leading-none"
@click="handleDeleteComment(parentComment, true)"
>
删除
</button>
</div>
<div class="flex items-center gap-3">
<!-- 点赞按钮 -->
<div
class="flex items-center gap-1.5 cursor-pointer text-gray-500 hover:text-blue-500 transition-colors px-3 py-1.5 rounded-full hover:bg-blue-50"
@click="handleLike(parentComment)"
>
<el-icon :size="18">
<svg-icon :name="parentComment.hasPraise ? 'praise_fill' : 'praise'"></svg-icon>
</el-icon>
<span class="text-sm font-medium">{{ parentComment.postPriseCount || 0 }}</span>
</div>
</div>
</div>
<div
class="text-gray-800 text-base leading-relaxed mb-2"
v-html="parseEmoji(parentComment.contentHtml || parentComment.content)"
v-parse-comment="closeDialog"
></div>
<!-- 下方图片 -->
<div class="flex flex-wrap gap-2" v-if="parentComment.imgUrl">
<div
v-for="(img, imgIndex) in parentComment.imgUrl.split(',').filter(Boolean)"
:key="imgIndex"
class="w-20 h-20 rounded-lg overflow-hidden mb-2 cursor-pointer"
>
<el-image
:src="img"
:preview-teleported="true"
class="w-full h-full object-cover"
:preview-src-list="parentComment.imgUrl.split(',').filter(Boolean)"
:initial-index="imgIndex"
fit="cover"
/>
</div>
</div>
<div class="text-sm text-gray-500 flex items-center gap-2">
<span>{{ dayjs(parentComment.createTime * 1000).format('MM-DD HH:mm') }}</span>
<span v-if="parentComment.region" class="text-xs text-gray-500"
>·{{ parentComment.region }}</span
>
</div>
</div>
</div>
<!-- 分割线 & 统计 -->
<div class="h-2.5"></div>
<div class="text-base font-bold text-gray-800 mb-6 px-1">{{ total }} 条回复</div>
<!-- 2. 下方:回复列表 -->
<div v-loading="loading" class="space-y-6">
<div v-for="(item, index) in list" :key="item.id" class="flex gap-4 relative group">
<img
:src="item.avatar"
class="w-10 h-10 rounded-full object-cover cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0"
/>
<div class="flex-1 border-b border-gray-100">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-1">
<span class="font-semibold text-gray-900 text-base">{{ item.replyUser }}</span>
<!-- v-if="item.replyName && item.replyName !== parentComment?.replyUser" -->
<span class="text-gray-500 text-sm flex items-center gap-1">
<el-icon class="mx-1"><IEpCaretRight /></el-icon>
{{ item.replyName }}
</span>
<button
v-if="isSelfComment(item)"
class="cursor-pointer text-red-500 text-13px leading-none"
@click="handleDeleteComment(item)"
>
删除
</button>
</div>
<!-- 列表项点赞 -->
<div class="flex items-center gap-4">
<div
class="flex items-center gap-1.5 cursor-pointer text-gray-500 hover:text-blue-500 transition-colors px-3 py-1.5 rounded-full hover:bg-blue-50"
@click="handleReplyInline(item, index)"
>
<span class="text-sm font-medium">回复</span>
</div>
<div
class="flex items-center gap-1.5 cursor-pointer text-gray-500 hover:text-blue-500 transition-colors px-3 py-1.5 rounded-full hover:bg-blue-50"
@click="handleLike(item)"
>
<el-icon :size="18">
<svg-icon :name="item.hasPraise ? 'praise_fill' : 'praise'"></svg-icon>
</el-icon>
<span class="text-sm font-medium">{{ item.postPriseCount || 0 }}</span>
</div>
</div>
</div>
<p
class="text-gray-700 text-base mb-2 break-all leading-relaxed whitespace-pre-wrap"
v-html="parseEmoji(item.contentHtml || item.content)"
v-parse-comment="closeDialog"
></p>
<!-- 下方图片 -->
<div class="flex flex-wrap gap-2" v-if="item.imgUrl">
<div
v-for="(img, imgIndex) in item.imgUrl.split(',').filter(Boolean)"
:key="imgIndex"
class="w-20 h-20 rounded-lg overflow-hidden mb-2 cursor-pointer"
>
<el-image
:src="img"
:preview-teleported="true"
class="w-full h-full object-cover"
:preview-src-list="item.imgUrl.split(',').filter(Boolean)"
:initial-index="imgIndex"
fit="cover"
/>
</div>
</div>
<div class="text-[14px] text-gray-500 flex items-center gap-2">
<span>{{ dayjs(item.createTime * 1000).format('MM-DD HH:mm') }}</span>
<span v-if="item.region" class="text-xs text-gray-500">·{{ item.region }}</span>
</div>
<!-- 内嵌回复框 -->
<div
v-show="currentInlineReplyId === item.id"
class="mt-4 bg-gray-50 p-4 rounded-xl border border-gray-200 animate-fade-in animate-fade-out"
>
<!-- 评论box -->
<CommentBox
v-model:inputText="commentStr"
v-model:inputImg="imgUrl"
:textAreaHeight="60"
:placeholder="`回复 ${item.replyUser}`"
:ref="
(el) => (replyToOtherBoxRefList[index] = el as InstanceType<typeof CommentBox>)
"
>
<template #submit>
<el-button
:disabled="isDisabled"
:loading="loadingBtn"
type="primary"
@click="submitReply(item.id, index)"
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>
</div>
</div>
<!-- 加载更多 / 分页 -->
<div class="flex justify-end" v-if="total > 0">
<el-pagination
v-model:current-page="searchParams.current"
:page-size="searchParams.size"
:total="total"
layout="prev, pager, next,total"
@current-change="goToPage"
@size-change="changePageSize"
/>
</div>
<el-empty v-if="!loading && list.length === 0" description="暂无回复" :image-size="80" />
</div>
</div>
<!-- 3. 底部固定回复框 -->
<div
class="border-t border-gray-100 p-5 bg-white flex items-start gap-4 z-10 shadow-[0_-4px_16px_rgba(0,0,0,0.04)] pb-0"
>
<img :src="currentUserAvatar" class="w-10 h-10 rounded-full object-cover flex-shrink-0" />
<CommentBox
class="flex-1"
v-model:inputText="bottomCommentContent"
v-model:inputImg="bottomImgUrl"
:textAreaHeight="40"
:placeholder="`回复 ${parentComment?.replyUser}`"
ref="commentBoxRef"
/>
<el-button
type="primary"
size="large"
circle
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"
:disabled="!bottomCommentContent.trim() || bottomLoadingBtn"
@click="submitReply(parentComment?.id)"
>
<el-icon :size="20"><IEpPosition /></el-icon>
</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia'
import {
getSecondCommentChildren,
addComment,
addOrCancelCommentLike,
getCommentDetail,
deleteComment,
} from '@/api'
import type { CommentItemDto } from '@/api'
import { BooleanFlag } from '@/constants'
import { usePageSearch } from '@/hooks'
import { parseEmoji } from '@/utils/emoji'
import CommentBox from '../CommentBox/index.vue'
import dayjs from 'dayjs'
import { push } from 'notivue'
import { IS_REAL_KEY_COMMENT } from '@/constants/symbolKey'
import { useMessageBox } from '@/hooks'
const { articleId, pid } = defineProps<{
articleId: number
pid: number
}>()
const emit = defineEmits<{
(e: 'refresh'): void // 通知父组件刷新
}>()
provide(IS_REAL_KEY_COMMENT, BooleanFlag.YES)
// Store
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const currentUserAvatar = computed(() => userInfo.value.avatar)
const { confirm } = useMessageBox()
// State
const visible = ref(false)
const parentComment = ref<CommentItemDto | null>(null)
const dialogTitle = ref('详情')
// Inline Reply State
const currentInlineReplyId = ref<number | null>(null)
const bottomCommentContent = ref('')
const bottomImgUrl = ref('')
const bottomLoadingBtn = ref(false)
const replyToOtherBoxRefList = ref<InstanceType<typeof CommentBox>[]>([])
const commentBoxRef = useTemplateRef<InstanceType<typeof CommentBox>>('commentBoxRef')
const scrollContainerRef = useTemplateRef('scrollContainer')
const commentStr = ref('')
const imgUrl = ref('')
const loadingBtn = ref(false)
const isDisabled = computed(() => !commentStr.value.trim() || loadingBtn.value)
const isSelfComment = (item: CommentItemDto) =>
String(item.userId || '') === String(userInfo.value.userId || '')
// --- Actions ---
const { list, total, search, searchParams, goToPage, changePageSize, refresh, loading } =
usePageSearch(getSecondCommentChildren, {
defaultParams: {
articleId: articleId,
pid: 0,
},
immediate: false,
})
const open = async () => {
// const { data } = await getSecondCommentChildren({
// pid: item.id,
// current: 1,
// size: 10,
// articleId: articleId,
// })
// list.value = data.list
// parentComment.value = item
// // Reset state
// currentInlineReplyId.value = null
// commentStr.value = ''
// bottomCommentContent.value = ''
await nextTick()
searchParams.value.pid = pid
search()
const { data } = await getCommentDetail(pid)
parentComment.value = data
visible.value = true
}
// 点击列表中的“回复”
const handleReplyInline = (item: CommentItemDto, index: number) => {
if (currentInlineReplyId.value === item.id) {
currentInlineReplyId.value = null // Toggle off
} else {
currentInlineReplyId.value = item.id
commentStr.value = '' // Clear previous
imgUrl.value = ''
// 聚焦到输入框
replyToOtherBoxRefList.value[index]?.focus()
}
}
// 提交评论 (共用逻辑)
// targetId: 如果是回复父评论,传 parentComment.id;如果是回复子评论,传 item.id
const submitReply = async (targetId: number | undefined, index?: number) => {
if (!targetId) return
// 判断使用的是哪个输入框的内容
const isBottom = targetId === parentComment.value?.id
const content = isBottom ? bottomCommentContent.value : commentStr.value
const imgStr = isBottom ? bottomImgUrl.value : imgUrl.value
try {
let mentionUserIdList: string | undefined = undefined
let contentHtml: string | undefined = undefined
if (isBottom) {
bottomLoadingBtn.value = true
mentionUserIdList = commentBoxRef.value?.getMentionFns?.()?.getMentionUserIds?.()?.join?.(',')
contentHtml = commentBoxRef.value?.getAnswerHtml?.()
} else {
loadingBtn.value = true
mentionUserIdList = replyToOtherBoxRefList.value[index!]
?.getMentionFns?.()
?.getMentionUserIds?.()
?.join?.(',')
contentHtml = replyToOtherBoxRefList.value[index!]?.getAnswerHtml?.()
}
await addComment({
articleId: articleId,
content: content,
pid: targetId, // 这里的pid逻辑根据您的后端接口来,通常回复子评论也是传该子评论ID作为pid
imgUrl: imgStr,
mentionUserIdList,
contentHtml,
})
push.success('回复成功')
// 清空输入框
if (isBottom) {
bottomCommentContent.value = ''
bottomImgUrl.value = ''
} else {
commentStr.value = ''
currentInlineReplyId.value = null
}
// 刷新列表
await refresh()
// 通知父组件可能需要更新评论数
emit('refresh')
scrollContainerRef.value?.scrollTo({
top: scrollContainerRef.value?.scrollHeight,
behavior: 'smooth',
})
// 下滑到底部
} catch (error) {
console.error(error)
} finally {
if (isBottom) {
bottomLoadingBtn.value = false
} else {
loadingBtn.value = false
}
}
}
// 点赞
const handleLike = async (item: CommentItemDto) => {
try {
await addOrCancelCommentLike(item.id)
// 乐观更新 UI
if (item.hasPraise === BooleanFlag.YES) {
item.hasPraise = BooleanFlag.NO
item.postPriseCount--
push.success('取消点赞成功')
} else {
item.hasPraise = BooleanFlag.YES
item.postPriseCount++
push.success('点赞成功')
}
} catch (error) {
console.error(error)
}
}
const handleDeleteComment = async (item: CommentItemDto, isParent = false) => {
await confirm({
title: '提示',
message: '确定删除该评论吗?',
type: 'danger',
})
await deleteComment(item.id)
push.success('删除成功')
if (isParent) {
visible.value = false
emit('refresh')
return
}
await refresh()
emit('refresh')
}
const closeDialog = () => {
visible.value = false
}
defineExpose({
open,
})
</script>
<style scoped lang="scss">
/* 使用 UnoCSS 这里写一些 Element Plus 的样式覆盖 */
:deep(.el-dialog__header) {
margin-right: 0;
padding: 0 16px;
}
:deep(.el-dialog__body) {
padding: 0;
}
/* 自定义滚动条 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #e5e7eb;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
<template>
<el-dialog
v-model="visible"
title="选择部门/人员"
width="880px"
append-to-body
destroy-on-close
align-center
@closed="handleClosed"
>
<div
class="h-[600px] min-h-0 flex overflow-hidden rounded-lg border border-[var(--el-border-color)] shadow-sm"
>
<!-- 左侧直接展示部门和成员,成员不再依赖单独的部门详情列。 -->
<section
class="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden border-r border-[var(--el-border-color)] p-4"
>
<div class="mb-3 flex items-center justify-between">
<div class="text-[15px] font-600 text-[var(--el-text-color-primary)]">
组织架构
<span class="ml-1 text-xs text-[var(--el-text-color-secondary)] font-400">
{{ canSelectUser ? '· 选择部门或成员' : '· 仅可选择部门' }}
</span>
</div>
<el-button
text
size="small"
:icon="Refresh"
:loading="departmentLoading"
aria-label="刷新部门"
@click="loadDepartments"
/>
</div>
<!-- 搜索结果以下拉浮层展示,不占用/替换下方组织架构树,可以边搜边继续浏览部门。 -->
<div v-if="canSelectUser" ref="searchWrapperRef" class="relative mb-3">
<el-input
v-model="searchKeyword"
clearable
placeholder="搜索成员,输入即搜索"
:prefix-icon="Search"
@input="handleSearchInput"
@focus="handleSearchFocus"
@clear="clearSearch"
/>
<div
v-if="searchPanelVisible"
class="absolute left-0 right-0 top-[calc(100%+6px)] z-10 max-h-[360px] overflow-y-auto rounded-md border border-[var(--el-border-color)] bg-white p-1 shadow-[0_8px_24px_rgba(0,0,0,0.12)]"
>
<el-skeleton v-if="searchLoading" class="p-2" :rows="6" animated />
<template v-else-if="searchResults.length">
<div
v-for="user in visibleSearchResults"
:key="user.userId"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-2 transition-colors hover:bg-[var(--el-color-primary-light-9)]"
@click="toggleSearchUser(user)"
>
<el-avatar
shape="square"
:size="28"
:src="user.avatar"
class="flex-shrink-0 rounded"
>
{{ user.name.slice(0, 1) }}
</el-avatar>
<span class="min-w-0 flex-1 truncate text-sm">{{ user.name }}</span>
<span
class="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border transition-colors"
:class="
isUserSelected(user)
? 'border-[var(--el-color-primary)] bg-[var(--el-color-primary)]'
: 'border-[var(--el-border-color)]'
"
>
<el-icon v-if="isUserSelected(user)" class="text-[10px] text-white">
<Check />
</el-icon>
</span>
</div>
<div
v-if="!showAllSearchResults && searchResults.length > SEARCH_PAGE_SIZE"
class="cursor-pointer px-2 py-2 text-center text-xs text-[var(--el-color-primary)] hover:underline"
@click="showAllSearchResults = true"
>
仅展示前 {{ SEARCH_PAGE_SIZE }} 个,点击查看全部 {{ searchResults.length }} 个成员
</div>
</template>
<el-empty v-else :image-size="56" description="未找到匹配的成员" />
</div>
</div>
<el-scrollbar class="min-h-0 flex-1 pr-1">
<el-skeleton v-if="departmentLoading" :rows="10" animated />
<el-tree
v-else-if="departmentTree.length"
:key="treeVersion"
ref="departmentTreeRef"
lazy
class="dept-tree w-full text-sm [--el-tree-node-content-height:52px]"
node-key="key"
show-checkbox
check-strictly
:props="treeProps"
:load="loadTreeNode"
:default-expanded-keys="expandedNodeKeys"
:expand-on-click-node="false"
@node-expand="handleNodeExpand"
@node-collapse="handleNodeCollapse"
@check="handleNodeCheck"
>
<template #default="{ data }">
<div class="min-w-0 flex flex-1 items-center gap-2">
<template v-if="data.nodeType === 'department'">
<el-icon class="flex-shrink-0 text-lg text-[var(--el-color-primary)]">
<FolderOpened v-if="expandedNodeKeys.includes(data.key)" />
<Folder v-else />
</el-icon>
<span
class="truncate text-[var(--el-text-color-primary)]"
:title="data.department.fullName"
>
{{ data.department.simpleName }}
</span>
</template>
<template v-else>
<el-avatar
shape="square"
:size="26"
:src="data.user.avatar"
class="flex-shrink-0 rounded"
>
{{ data.user.name.slice(0, 1) }}
</el-avatar>
<span class="min-w-0 flex-1 truncate text-[var(--el-text-color-primary)]">
{{ data.user.name }}
</span>
</template>
</div>
</template>
</el-tree>
<el-empty v-else :image-size="72" description="暂无部门数据" />
</el-scrollbar>
</section>
<!-- 右侧只负责展示已选项,保持和参考组件一致的两栏结构。 -->
<section
class="min-h-0 w-[300px] flex flex-shrink-0 flex-col bg-[var(--el-fill-color-light)] p-4"
>
<div class="border-b border-[var(--el-border-color)] pb-3">
<div class="text-[15px] font-600 text-[var(--el-text-color-primary)]">
已选择{{ multiple ? '' : '(单选)' }}
</div>
<div
class="mt-2 flex items-center justify-between text-xs text-[var(--el-text-color-secondary)]"
>
<div class="flex items-center gap-2">
<span>{{ selectedDepartmentCount }} 个部门</span>
<span>·</span>
<span>{{ selectedUserCount }} 个成员</span>
</div>
<div class="flex items-center gap-2">
<el-button
v-if="selectedDepartmentCount"
text
type="danger"
class="!h-auto !p-0 !text-xs"
@click="clearSelectionsByType(1)"
>
清除部门
</el-button>
<el-button
v-if="selectedUserCount"
text
type="danger"
class="!h-auto !p-0 !text-xs"
@click="clearSelectionsByType(2)"
>
清除成员
</el-button>
</div>
</div>
</div>
<el-scrollbar class="min-h-0 flex-1 pt-1">
<div v-if="selectedItems.length" class="space-y-1">
<div
v-for="item in selectedItems"
:key="selectionKey(item)"
class="group flex min-w-0 items-center gap-2 rounded-md bg-white px-2 py-2 shadow-[0_1px_2px_rgba(0,0,0,0.04)] transition-colors hover:bg-[var(--el-color-primary-light-9)]"
>
<el-avatar
v-if="item.valueType === 2"
shape="square"
:size="26"
:src="item.avatar"
class="flex-shrink-0 rounded"
>
{{ item.valueName.slice(0, 1) }}
</el-avatar>
<el-icon v-else class="flex-shrink-0 text-lg text-[var(--el-color-primary)]">
<FolderOpened />
</el-icon>
<span class="min-w-0 flex-1 truncate text-sm" :title="item.valueName">
{{ item.valueName }}
</span>
<el-button
text
class="!h-auto !flex-shrink-0 !p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
:icon="Close"
aria-label="移除选择"
@click="removeSelection(item)"
/>
</div>
</div>
<el-empty v-else :image-size="72" description="暂未选择" />
</el-scrollbar>
</section>
</div>
<template #footer>
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--el-text-color-secondary)]">
{{ selectedItems.length ? `共选择 ${selectedItems.length} 项` : '' }}
</span>
<div class="flex gap-2">
<el-button @click="close">取消</el-button>
<el-button type="primary" :disabled="!selectedItems.length" @click="confirmSelection">
确定
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
/**
* 选部门/选人弹窗的实际 UI(组织架构树 + 搜索 + 已选列表)。
* 业务页面不要直接用这个组件,统一通过 DeptUserPicker(表单场景的封装)来用。
* 这里只是 DeptUserPicker 内部拿来复用的弹窗实现,open()/close() 是给它调的。
*/
import { Check, Close, Folder, FolderOpened, Refresh, Search } from '@element-plus/icons-vue'
import { onClickOutside } from '@vueuse/core'
import { push } from 'notivue'
import { getAllDept, getDeptUsers } from '@/api'
import type { DepartmentDto, DepartmentUserDto } from '@/api/deptUser/types'
import type {
DepartmentUserModalExpose,
DepartmentUserModalOpenOptions,
DepartmentUserSelection,
SelectionMode,
UserValueField,
} from './types'
type DepartmentTreeNode = {
key: string
nodeType: 'department'
department: DepartmentDto
children: TreeNode[]
disabled: boolean
// 懒加载模式下 el-tree 靠这个字段判断要不要显示展开箭头,不能只看 children.length
// (成员是展开时才异步拉取的,初始 children 可能是空的)。
isLeaf: boolean
}
type UserTreeNode = {
key: string
nodeType: 'user'
user: DepartmentUserDto
disabled: boolean
isLeaf: true
}
type TreeNode = DepartmentTreeNode | UserTreeNode
const emit = defineEmits<{
confirm: [items: DepartmentUserSelection[]]
cancel: []
}>()
// 搜索结果一次性拿全量,但一屏渲染太多头像会卡,先只展示前 100 个,需要时再展开全部。
const SEARCH_PAGE_SIZE = 100
const visible = ref(false)
const departmentLoading = ref(false)
const searchLoading = ref(false)
const departmentTree = ref<DepartmentTreeNode[]>([])
const searchResults = ref<DepartmentUserDto[]>([])
const showAllSearchResults = ref(false)
const searchPanelVisible = ref(false)
const selectedItems = ref<DepartmentUserSelection[]>([])
const expandedNodeKeys = ref<string[]>([])
const searchKeyword = ref('')
const selectionMode = ref<SelectionMode>('both')
const multiple = ref(true)
const maxCount = ref<number | undefined>(undefined)
const userValueField = ref<UserValueField>('userId')
const departmentTreeRef = useTemplateRef('departmentTreeRef')
const searchWrapperRef = useTemplateRef<HTMLDivElement>('searchWrapperRef')
const treeProps = { children: 'children', label: 'label', disabled: 'disabled', isLeaf: 'isLeaf' }
// el-tree 懒加载模式下每次重新挂载才会清空已加载的节点状态;
// 点“刷新部门”时靠改这个 key 强制重建整棵树,避免懒加载的旧状态残留。
const treeVersion = ref(0)
let searchTimer: ReturnType<typeof setTimeout> | undefined
let syncingCheckedState = false
let suppressCancel = false
onClickOutside(searchWrapperRef, () => {
searchPanelVisible.value = false
})
const canSelectDepartment = computed(() => ['department', 'both'].includes(selectionMode.value))
const canSelectUser = computed(() => ['user', 'both'].includes(selectionMode.value))
const visibleSearchResults = computed(() =>
showAllSearchResults.value ? searchResults.value : searchResults.value.slice(0, SEARCH_PAGE_SIZE),
)
const selectedDepartmentCount = computed(
() => selectedItems.value.filter((item) => item.valueType === 1).length,
)
const selectedUserCount = computed(
() => selectedItems.value.filter((item) => item.valueType === 2).length,
)
const selectionKey = (item: Pick<DepartmentUserSelection, 'valueType' | 'valueId'>) =>
`${item.valueType}-${String(item.valueId)}`
const getDepartmentNodeKey = (deptId: string | number) => `department-${String(deptId)}`
const getUserValue = (user: DepartmentUserDto) => {
if (userValueField.value === 'account') return user.account || ''
if (userValueField.value === 'jobNumId') return user.jobNumId || ''
return user.userId
}
// 同一个人可能同时出现在父部门和子部门的成员列表里(后端数据结构决定的,不是 bug),
// 树的 node-key 必须全树唯一,所以要带上所属部门节点的 key 前缀,不能只用 userId;
// 这个 map 记录每个 userId 在树里实际出现过的所有 key,选中同步(setCheckedState)时要用。
const userNodeKeysById = new Map<string, Set<string>>()
const registerUserNodeKey = (userId: string, key: string) => {
if (!userNodeKeysById.has(userId)) userNodeKeysById.set(userId, new Set())
userNodeKeysById.get(userId)!.add(key)
}
const getUserNodeKey = (parentKey: string, user: DepartmentUserDto) =>
`${parentKey}::user-${String(getUserValue(user))}`
const normalizeSelection = (
item: DepartmentUserSelection,
field = userValueField.value,
): DepartmentUserSelection => {
const valueId =
item.valueType === 2
? field === 'userId'
? item.userId || item.valueId
: field === 'account'
? item.account || item.valueId
: item.jobNumId || item.valueId
: item.valueId
return {
...item,
valueId: String(valueId),
valueField: item.valueType === 2 ? field : undefined,
deptId: item.deptId ? String(item.deptId) : undefined,
userId: item.userId ? String(item.userId) : undefined,
account: item.account ? String(item.account) : undefined,
jobNumId: item.jobNumId ? String(item.jobNumId) : undefined,
}
}
// 选择项统一按类型和值字段去重,保证回显和重复展开部门时结果一致;
// 单选模式下选中新项会直接替换掉之前的选择,不区分部门/人员;
// 设了 max 的话,多选超出时直接提示、不接受这次勾选。
const addSelection = (item: DepartmentUserSelection) => {
if (!item.valueId) return
if (selectedItems.value.some((selected) => selectionKey(selected) === selectionKey(item))) return
if (!multiple.value) {
selectedItems.value = []
} else if (maxCount.value && selectedItems.value.length >= maxCount.value) {
push.warning(`最多只能选择 ${maxCount.value} 个`)
return
}
selectedItems.value.push(normalizeSelection(item))
}
const setCheckedState = async () => {
await nextTick()
syncingCheckedState = true
// 人员可能在树里出现在多个部门分支下,同一个 userId 要把它所有已知出现过的节点 key 都勾上,
// 不然只会勾中其中一个分支,另一个分支看起来像没选中(其实选中状态本身没错,是勾选框没同步)。
const keys = selectedItems.value.flatMap((item) => {
if (item.valueType === 1) return [getDepartmentNodeKey(item.valueId)]
return Array.from(userNodeKeysById.get(item.valueId) ?? [])
})
departmentTreeRef.value?.setCheckedKeys(keys)
syncingCheckedState = false
}
const removeSelection = (item: DepartmentUserSelection) => {
selectedItems.value = selectedItems.value.filter(
(selected) => selectionKey(selected) !== selectionKey(item),
)
void setCheckedState()
}
const clearSelectionsByType = (valueType: 1 | 2) => {
selectedItems.value = selectedItems.value.filter((item) => item.valueType !== valueType)
void setCheckedState()
}
const buildDepartmentTree = (list: DepartmentDto[]): DepartmentTreeNode[] => {
const nodeMap = new Map<string, DepartmentTreeNode>()
const roots: DepartmentTreeNode[] = []
list.forEach((department) => {
nodeMap.set(String(department.deptId), {
key: getDepartmentNodeKey(department.deptId),
nodeType: 'department',
department,
children: [],
disabled: !canSelectDepartment.value,
isLeaf: false,
})
})
nodeMap.forEach((node) => {
const parent = nodeMap.get(String(node.department.pid))
if (parent && node.key !== parent.key) parent.children.push(node)
else roots.push(node)
})
// 既没有下级部门、也没有成员(或当前模式不选人)的部门才是真正的叶子节点。
nodeMap.forEach((node) => {
node.isLeaf = !node.children.length && (!canSelectUser.value || !node.department.hasUserFlag)
})
return roots
}
const createUserNodes = (users: DepartmentUserDto[], parentKey: string): UserTreeNode[] =>
users
.filter((user) => !!getUserValue(user))
.map((user) => {
const key = getUserNodeKey(parentKey, user)
registerUserNodeKey(String(getUserValue(user)), key)
return {
key,
nodeType: 'user' as const,
user,
disabled: !canSelectUser.value,
isLeaf: true as const,
}
})
// el-tree 懒加载的 load 回调:部门下的子部门在打开弹窗时已经一次性拿到了,
// 这里只需要按需请求成员,再和已知的子部门合并交给 el-tree 自己管理,
// 不要再手动改 data.children —— 之前那么做会导致某些节点的展开箭头偶尔点了没反应。
// el-tree 懒加载的 Node 类型是内部私有类型,这里用 any 接一下,避免为了对齐它的类型声明引入脆弱的深层导入路径。
const loadTreeNode = async (node: any, resolve: (data: TreeNode[]) => void) => {
if (node.level === 0) {
resolve(departmentTree.value)
return
}
const data = node.data as TreeNode
const departmentChildren =
data.nodeType === 'department'
? data.children.filter((child) => child.nodeType === 'department')
: []
if (data.nodeType !== 'department' || !canSelectUser.value || !data.department.hasUserFlag) {
resolve(departmentChildren)
return
}
try {
const { data: users } = await getDeptUsers({ deptId: data.department.deptId })
// 展开部门时优先展示直属成员,再展示下级部门,符合选人时的浏览顺序。
resolve([...createUserNodes(users || [], data.key), ...departmentChildren])
} catch (error) {
console.error('获取部门成员失败:', error)
push.error('获取部门成员失败,请稍后重试')
resolve(departmentChildren)
} finally {
await setCheckedState()
}
}
const loadDepartments = async () => {
departmentLoading.value = true
try {
const { data } = await getAllDept()
departmentTree.value = buildDepartmentTree(data || [])
expandedNodeKeys.value = departmentTree.value.slice(0, 1).map((node) => node.key)
// 强制重建 el-tree,避免上一次懒加载展开的节点状态串到新数据上。
treeVersion.value += 1
} catch (error) {
console.error('获取部门树失败:', error)
push.error('获取部门树失败,请稍后重试')
} finally {
departmentLoading.value = false
await setCheckedState()
}
}
const handleNodeExpand = (data: TreeNode) => {
if (!expandedNodeKeys.value.includes(data.key)) {
expandedNodeKeys.value.push(data.key)
}
}
const handleNodeCollapse = (data: TreeNode) => {
expandedNodeKeys.value = expandedNodeKeys.value.filter((key) => key !== data.key)
}
const createUserSelection = (user: DepartmentUserDto): DepartmentUserSelection | null => {
const valueId = getUserValue(user)
if (!valueId) return null
return {
valueType: 2,
valueId: String(valueId),
valueName: user.name,
valueField: userValueField.value,
userId: String(user.userId),
account: user.account ? String(user.account) : undefined,
jobNumId: user.jobNumId ? String(user.jobNumId) : undefined,
avatar: user.avatar,
deptId: String(user.deptId),
}
}
const createSelectionFromNode = (data: TreeNode): DepartmentUserSelection | null => {
if (data.nodeType === 'department') {
return {
valueType: 1,
valueId: String(data.department.deptId),
valueName: data.department.fullName || data.department.simpleName,
}
}
return createUserSelection(data.user)
}
const handleNodeCheck = (data: TreeNode, checkState: { checkedKeys: Array<string | number> }) => {
if (syncingCheckedState) return
const selection = createSelectionFromNode(data)
if (!selection) return
const checked = checkState.checkedKeys.some((key) => String(key) === data.key)
if (checked) addSelection(selection)
else {
selectedItems.value = selectedItems.value.filter(
(selected) => selectionKey(selected) !== selectionKey(selection),
)
}
// 单选模式下勾选新项会替换掉旧的选择,这里同步一下勾选框,把旧项的勾去掉。
void setCheckedState()
}
const isUserSelected = (user: DepartmentUserDto) => {
const valueId = getUserValue(user)
if (!valueId) return false
return selectedItems.value.some(
(item) => item.valueType === 2 && item.valueId === String(valueId),
)
}
// 搜索下拉里的成员是点一下切换选中状态,同时要同步左侧组织架构树的勾选框。
const toggleSearchUser = (user: DepartmentUserDto) => {
const selection = createUserSelection(user)
if (!selection) return
if (selectedItems.value.some((item) => selectionKey(item) === selectionKey(selection))) {
selectedItems.value = selectedItems.value.filter(
(item) => selectionKey(item) !== selectionKey(selection),
)
} else {
addSelection(selection)
}
void setCheckedState()
}
const searchUsers = async () => {
const keyword = searchKeyword.value.trim()
if (!keyword) return
searchLoading.value = true
try {
const { data } = await getDeptUsers({ keyword })
searchResults.value = data || []
} catch (error) {
console.error('搜索成员失败:', error)
push.error('搜索成员失败,请稍后重试')
} finally {
searchLoading.value = false
}
}
// 输入搜索增加短暂防抖,避免用户连续输入时频繁请求成员接口。
const handleSearchInput = () => {
if (searchTimer) clearTimeout(searchTimer)
if (!searchKeyword.value.trim()) {
searchPanelVisible.value = false
searchResults.value = []
return
}
searchPanelVisible.value = true
showAllSearchResults.value = false
searchLoading.value = true
searchTimer = setTimeout(() => {
void searchUsers()
}, 300)
}
// 重新聚焦搜索框时,如果已经有结果就直接把下拉浮层带回来,不用重新请求。
const handleSearchFocus = () => {
if (searchKeyword.value.trim() && searchResults.value.length) {
searchPanelVisible.value = true
}
}
const clearSearch = () => {
if (searchTimer) clearTimeout(searchTimer)
searchKeyword.value = ''
searchPanelVisible.value = false
searchResults.value = []
showAllSearchResults.value = false
}
const resetState = () => {
if (searchTimer) clearTimeout(searchTimer)
departmentTree.value = []
searchResults.value = []
showAllSearchResults.value = false
searchPanelVisible.value = false
selectedItems.value = []
expandedNodeKeys.value = []
searchKeyword.value = ''
userNodeKeysById.clear()
}
const open = (options: DepartmentUserModalOpenOptions = {}) => {
resetState()
selectionMode.value = options.selectionMode ?? 'both'
multiple.value = options.multiple ?? true
maxCount.value = options.multiple === false ? undefined : options.max
userValueField.value = options.userValueField ?? 'userId'
selectedItems.value = (options.selected ?? []).map((item) =>
normalizeSelection(item, options.userValueField ?? 'userId'),
)
suppressCancel = false
visible.value = true
void loadDepartments()
}
const close = () => {
visible.value = false
}
const confirmSelection = () => {
suppressCancel = true
emit(
'confirm',
selectedItems.value.map((item) => normalizeSelection(item)),
)
close()
}
const handleClosed = () => {
if (!suppressCancel) emit('cancel')
suppressCancel = false
}
defineExpose<DepartmentUserModalExpose>({ open, close })
</script>
<style scoped>
/* 这几条放到普通 CSS 里写,链式的 UnoCSS 工具类在这么多变体组合下不太可靠(试过 border-b、box-shadow 都没生效)。 */
.dept-tree :deep(.el-tree-node__content) {
box-sizing: border-box;
height: 50px;
padding-right: 8px;
border-radius: 6px;
cursor: pointer;
box-shadow: 0 1px 0 0 var(--el-border-color-lighter);
transition: background-color 0.2s;
}
.dept-tree :deep(.el-tree-node__content:hover) {
background-color: var(--el-color-primary-light-9);
}
.dept-tree :deep(.el-tree-node__expand-icon) {
color: var(--el-text-color-primary);
}
.dept-tree :deep(.el-checkbox__inner) {
border-color: var(--el-text-color-secondary);
}
.dept-tree :deep(.el-checkbox.is-disabled) {
display: none;
}
</style>
export type SelectionMode = 'department' | 'user' | 'both'
export type UserValueField = 'userId' | 'account' | 'jobNumId'
/** 选人弹窗对外返回的统一选择项。 */
export interface DepartmentUserSelection {
valueType: 1 | 2
valueId: string
valueName: string
valueField?: UserValueField
avatar?: string
deptId?: string
userId?: string
account?: string
jobNumId?: string
}
/** 命令式打开弹窗时的配置,selected 用于表单或表格编辑回显。 */
export interface DepartmentUserModalOpenOptions {
selectionMode?: SelectionMode
userValueField?: UserValueField
selected?: DepartmentUserSelection[]
/** 是否允许多选,默认 true;单选时选中新项会自动替换掉之前的选择(不区分部门/人员)。 */
multiple?: boolean
/** 最多可选择的项数(部门+人员合计),不传不限制;超出时弹窗内直接提示,不接受本次勾选。 */
max?: number
}
export interface DepartmentUserModalExpose {
open: (options?: DepartmentUserModalOpenOptions) => void
close: () => void
}
<template>
<!-- 触发区完全交给调用方自己写(长度、placeholder 等都自己控制),这里只负责点击打开弹窗。
组件有两个根节点,class 等属性不会自动落到外层,这里手动挂到触发区上:
想让触发区撑满一行就在组件上写 class="w-full" -->
<div
v-bind="$attrs"
class="inline-block"
:class="{ 'cursor-not-allowed': disabled }"
@click="open"
>
<slot :echo-text="echoText" />
</div>
<DepartmentUserModal ref="modalRef" @confirm="handleConfirm" />
</template>
<script setup lang="ts">
/**
* 表单场景下的选部门/选人控件,v-model 直接绑 id(数组或逗号字符串,由 value-type 决定),
* 跟后端字段直接对齐,业务页面不用再转换成对象数组。
*
* 用法一(只选部门,值是逗号字符串,直接对应后端 deptId 字段):
* <DeptUserPicker
* v-model:dept-value="form.deptId"
* selection-mode="department"
* value-type="string"
* :max="3"
* @confirm="(items) => (form.deptName = items.map((i) => i.valueName).join(','))"
* v-slot="{ echoText }"
* >
* <el-input readonly :model-value="echoText" placeholder="请选择部门" />
* </DeptUserPicker>
*
* 用法二(部门/人员都能选,值是数组):
* <DeptUserPicker
* v-model:dept-value="deptIds"
* v-model:user-value="userIds"
* selection-mode="both"
* @confirm="(items) => (pushList = items)"
* v-slot="{ echoText }"
* >
* <el-input readonly :model-value="echoText" placeholder="请选择部门/人员" />
* </DeptUserPicker>
*
* 回显文案(echoText)不用页面自己拼:组件内部会缓存选过/查过的每一项完整信息(名字、头像),
* id 缺名字时自己反查——部门用 getAllDept({ deptIdList }),人员用 getDeptUsers({ userIdList })。
* 如果业务上还需要把选中的名字存到某个字段(比如 deptName),在 @confirm 里自己处理就行。
*/
import DepartmentUserModal from '@/components/common/DepartmentUserModal/index.vue'
import { getAllDept, getDeptUsers } from '@/api'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
import type { DeptUserPickerProps } from './types'
const {
selectionMode = 'both',
multiple = true,
userValueField = 'userId',
disabled = false,
max,
valueType = 'array',
} = defineProps<DeptUserPickerProps>()
defineOptions({ inheritAttrs: false })
const emit = defineEmits<{
confirm: [items: DepartmentUserSelection[]]
}>()
const deptValue = defineModel<string[] | string>('deptValue', { default: () => [] })
const userValue = defineModel<string[] | string>('userValue', { default: () => [] })
const modalRef = useTemplateRef<InstanceType<typeof DepartmentUserModal>>('modalRef')
// 记住选过/查过的每一项完整信息(名字、头像),key 是 `${valueType}-${valueId}`。
// 只增不减;用 reactive 包一层是因为反查接口异步写回缓存后也要触发 echoText 重新计算。
const detailCache = reactive(new Map<string, DepartmentUserSelection>())
const cacheKey = (valueType: 1 | 2, valueId: string) => `${valueType}-${valueId}`
const toIdArray = (value: string[] | string | undefined): string[] => {
if (!value) return []
return Array.isArray(value) ? value : value.split(',').filter(Boolean)
}
const serializeIds = (ids: string[]): string[] | string =>
valueType === 'string' ? ids.join(',') : ids
const deptIds = computed(() => toIdArray(deptValue.value))
const userIds = computed(() => toIdArray(userValue.value))
// 部门缺名字时自己查一次(getAllDept 支持按 deptIdList 查)
watch(
deptIds,
async (ids) => {
const missingIds = ids.filter((id) => !detailCache.has(cacheKey(1, id)))
if (!missingIds.length) return
try {
const { data } = await getAllDept({ deptIdList: missingIds })
data?.forEach((dept) => {
const key = cacheKey(1, String(dept.deptId))
if (!detailCache.has(key)) {
detailCache.set(key, {
valueType: 1,
valueId: String(dept.deptId),
valueName: dept.fullName || dept.simpleName,
})
}
})
} catch (error) {
console.error('查询部门名称失败:', error)
}
},
{ immediate: true },
)
// 人员缺名字时同理,getDeptUsers 传 userIdList 批量查(后端认 user_id 和企微账号,不认工号)
watch(
userIds,
async (ids) => {
if (userValueField === 'jobNumId') return
const missingIds = ids.filter((id) => !detailCache.has(cacheKey(2, id)))
if (!missingIds.length) return
try {
const { data } = await getDeptUsers({ userIdList: missingIds })
data?.forEach((user) => {
const valueId = String(userValueField === 'account' ? user.account : user.userId)
const key = cacheKey(2, valueId)
if (!detailCache.has(key)) {
detailCache.set(key, {
valueType: 2,
valueId,
valueName: user.name,
valueField: userValueField,
userId: String(user.userId),
account: user.account,
jobNumId: user.jobNumId,
avatar: user.avatar,
deptId: String(user.deptId),
})
}
})
} catch (error) {
console.error('查询人员名称失败:', error)
}
},
{ immediate: true },
)
const resolveSelection = (ids: string[], type: 1 | 2): DepartmentUserSelection[] =>
ids.map(
(valueId) =>
detailCache.get(cacheKey(type, valueId)) ?? { valueType: type, valueId, valueName: valueId },
)
// 拼好给触发区直接展示的文案,部门在前、人员在后;没缓存到名字的项目会退化成显示 id。
const echoText = computed(() =>
[...resolveSelection(deptIds.value, 1), ...resolveSelection(userIds.value, 2)]
.map((item) => item.valueName)
.join(','),
)
const open = () => {
if (disabled) return
modalRef.value?.open({
selectionMode,
multiple,
userValueField,
max,
selected: [...resolveSelection(deptIds.value, 1), ...resolveSelection(userIds.value, 2)],
})
}
const handleConfirm = (items: DepartmentUserSelection[]) => {
items.forEach((item) => detailCache.set(cacheKey(item.valueType, item.valueId), item))
const depts = items.filter((item) => item.valueType === 1)
const users = items.filter((item) => item.valueType === 2)
deptValue.value = serializeIds(depts.map((item) => item.valueId))
userValue.value = serializeIds(users.map((item) => item.valueId))
emit('confirm', items)
}
defineExpose({ open })
</script>
import type { SelectionMode, UserValueField } from '@/components/common/DepartmentUserModal/types'
/** v-model 的值格式:array 是 id 数组,string 是逗号拼接的 id 字符串(跟后端字段直接对齐)。 */
export type DeptUserValueType = 'array' | 'string'
export type DeptUserPickerProps = {
/** 部门/人员/两者,默认 both */
selectionMode?: SelectionMode
/** 是否多选,默认 true;单选时选中新项会自动替换掉之前的选择 */
multiple?: boolean
/** 人员值字段,默认 userId */
userValueField?: UserValueField
disabled?: boolean
/** 最多可选择的项数(部门+人员合计),不传不限制 */
max?: number
/** v-model:dept-value / v-model:user-value 的值格式,默认 array */
valueType?: DeptUserValueType
}
<template>
<template v-if="enabled">
<!-- 平时只露一个头像圆点,鼠标移上去向左展开显示名字,不挡页面内容 -->
<div
class="group fixed right-4 bottom-40 z-3000 flex flex-row-reverse items-center h-10 pl-1 pr-1 rounded-full bg-gray-900/75 text-white shadow-lg backdrop-blur cursor-pointer select-none hover:bg-gray-900/90 transition-colors"
title="切换用户(仅测试环境可见)"
@click="openPicker"
>
<el-avatar :size="32" :src="userInfo.avatar" class="shrink-0">
<el-icon><IEpUser /></el-icon>
</el-avatar>
<span
class="max-w-0 group-hover:max-w-32 overflow-hidden whitespace-nowrap text-sm opacity-0 group-hover:opacity-100 group-hover:pl-3 group-hover:pr-2 transition-all duration-300"
>
{{ userInfo.name || '切换用户' }}
<span class="text-xs text-white/60 ml-1">切换用户</span>
</span>
</div>
<DepartmentUserModal ref="modalRef" @confirm="handleConfirm" />
</template>
</template>
<script setup lang="ts">
/**
* 本地/测试环境切换登录用户的悬浮按钮,挂在 App.vue 上,前台后台共用。
*
* 切换方式就是在当前地址后面拼 ?code=账号@yswg.com.cn,路由守卫会用这个 code 重新登录,
* 登录完成后自动去掉 code,停留在当前页面。生产环境不显示。
* 注意不能用 import.meta.env.PROD 判断:测试环境也是 vite build 出来的,PROD 同样是 true。
*/
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import DepartmentUserModal from '@/components/common/DepartmentUserModal/index.vue'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
const EMAIL_SUFFIX = '@yswg.com.cn'
const enabled = import.meta.env.MODE !== 'production'
const { userInfo } = storeToRefs(useUserStore())
const modalRef = useTemplateRef<InstanceType<typeof DepartmentUserModal>>('modalRef')
const openPicker = () => {
modalRef.value?.open({ selectionMode: 'user', userValueField: 'account', multiple: false })
}
const handleConfirm = (items: DepartmentUserSelection[]) => {
const account = items[0]?.valueId
if (!account) return
const url = new URL(window.location.href)
url.searchParams.set('code', `${account}${EMAIL_SUFFIX}`)
window.location.href = url.toString()
}
</script>
<template>
<el-dialog
v-model="visible"
title="裁剪封面图"
width="720px"
append-to-body
destroy-on-close
@open="initCropper"
@close="handleClose"
>
<div
ref="cropContainer"
class="h-[420px] overflow-hidden bg-[#f5f7fa] [&>cropper-canvas]:block [&>cropper-canvas]:h-full [&>cropper-canvas]:w-full"
>
<img ref="imageRef" :src="src" alt="待裁剪图片" class="block max-w-full" />
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleConfirm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import Cropper, { type CropperSelection } from 'cropperjs'
import { push } from 'notivue'
const emit = defineEmits<{
confirm: [file: File]
cancel: []
}>()
const visible = ref(false)
const src = ref('')
// 原文件只用于导出时保留名称和类型,不参与界面渲染。
let sourceFile: File | null = null
let cropAspectRatio: number | undefined
const imageRef = useTemplateRef<HTMLImageElement>('imageRef')
const cropContainer = useTemplateRef<HTMLDivElement>('cropContainer')
const cropper = shallowRef<Cropper | null>(null)
const cropperSelection = shallowRef<CropperSelection | null>(null)
const loading = ref(false)
let suppressClose = false
// 每次打开都重新设置裁剪比例(宽 / 高),不传时恢复自由裁剪。
const open = (file: File, aspectRatio?: number) => {
sourceFile = file
cropAspectRatio = aspectRatio
if (src.value) URL.revokeObjectURL(src.value)
src.value = URL.createObjectURL(file)
suppressClose = false
visible.value = true
}
// 关闭或卸载时释放本次裁剪的临时资源。
const clearCropState = () => {
destroyCropper()
if (src.value) URL.revokeObjectURL(src.value)
src.value = ''
sourceFile = null
}
const destroyCropper = () => {
console.log('before')
cropperSelection.value?.removeEventListener('change', handleSelectionChange)
cropperSelection.value = null
cropper.value?.destroy()
cropper.value = null
}
const getImageBounds = () => {
const canvasRect = cropper.value?.getCropperCanvas()?.getBoundingClientRect()
const imageRect = cropper.value?.getCropperImage()?.getBoundingClientRect()
if (!canvasRect || !imageRect) return
return {
left: imageRect.left - canvasRect.left,
top: imageRect.top - canvasRect.top,
right: imageRect.right - canvasRect.left,
bottom: imageRect.bottom - canvasRect.top,
}
}
// 裁剪框超出图片可见区域时取消本次变化,避免导出透明或空白内容。
const handleSelectionChange = (event: Event) => {
const bounds = getImageBounds()
const { x, y, width, height } = (event as CustomEvent).detail ?? {}
if (!bounds || [x, y, width, height].some((value) => typeof value !== 'number')) return
if (x < bounds.left || y < bounds.top || x + width > bounds.right || y + height > bounds.bottom) {
event.preventDefault()
}
}
const initCropper = async () => {
console.log('开始了')
await nextTick()
if (!visible.value || !imageRef.value || !cropContainer.value) return
destroyCropper()
cropper.value = new Cropper(imageRef.value, {
container: cropContainer.value,
})
const image = cropper.value.getCropperImage()
const selection = cropper.value.getCropperSelection()
if (!image || !selection) return
await image.$ready()
// 图片加载期间可能关闭弹窗,不再初始化已销毁的裁剪器。
if (!visible.value || image !== cropper.value?.getCropperImage()) return
// 当前场景只调整裁剪框,固定图片可保证裁剪边界始终稳定。
image.translatable = false
image.scalable = false
image.rotatable = false
image.skewable = false
cropperSelection.value = selection
selection.addEventListener('change', handleSelectionChange)
const bounds = getImageBounds()
if (!bounds) return
const availableWidth = (bounds.right - bounds.left) * 0.8
const availableHeight = (bounds.bottom - bounds.top) * 0.8
let width = availableWidth
let height = availableHeight
if (cropAspectRatio) {
selection.aspectRatio = cropAspectRatio
if (width / height > cropAspectRatio) {
width = height * cropAspectRatio
} else {
height = width / cropAspectRatio
}
}
// 初始裁剪框按配置比例居中;不传比例时保持自由裁剪。
const x = Math.ceil(bounds.left + (bounds.right - bounds.left - width) / 2)
const y = Math.ceil(bounds.top + (bounds.bottom - bounds.top - height) / 2)
width = Math.floor(width)
height = Math.floor(height)
selection.$change(x, y, width, height)
}
const canvasToBlob = (canvas: HTMLCanvasElement, type: string) =>
new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('裁剪图片生成失败'))
}
}, type)
})
// 将裁剪结果转换为原文件类型,避免上传接口收到不可识别的临时文件名。
const handleConfirm = async () => {
const file = sourceFile
if (!file || loading.value) return
loading.value = true
try {
const canvas = await cropper.value?.getCropperSelection()?.$toCanvas()
if (!canvas) throw new Error('裁剪图片生成失败')
const blob = await canvasToBlob(canvas, file.type || 'image/jpeg')
// 导出期间若取消了本次裁剪,不再把结果交给上传组件。
if (!visible.value || sourceFile !== file) return
suppressClose = true
visible.value = false
emit('confirm', new File([blob], file.name, { type: blob.type }))
} catch (error) {
console.error('裁剪失败:', error)
push.error('裁剪失败,请重试')
} finally {
loading.value = false
}
}
const handleClose = () => {
clearCropState()
if (!suppressClose) {
emit('cancel')
}
suppressClose = false
}
onBeforeUnmount(() => {
clearCropState()
console.log(123)
})
defineExpose({ open })
</script>
...@@ -127,6 +127,48 @@ ...@@ -127,6 +127,48 @@
</el-button> </el-button>
</el-tooltip> </el-tooltip>
</UploadImgIcon> </UploadImgIcon>
<el-tooltip
v-if="type === ArticleTypeEnum.QUESTION"
content="进入大区域富文本编辑页"
placement="top"
>
<el-button
text
class="h-10 px-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 rounded-lg"
@click="goToRichEditor"
>
<el-icon size="18">
<IEpEditPen />
</el-icon>
<span class="ml-1 text-xs">使用富文本编辑</span>
</el-button>
</el-tooltip>
<!-- 实践可以设置查看权限:不选就是所有人可见,选了部门/人员之后只有被选中的能看到 -->
<DeptUserPicker
v-if="type === ArticleTypeEnum.PRACTICE"
v-model:dept-value="permissionDeptIds"
v-model:user-value="permissionUserIds"
selection-mode="both"
@confirm="(items) => (permissionSelection = items)"
v-slot="{ echoText }"
>
<el-tooltip
:content="echoText ? `仅以下部门/人员可见:${echoText}` : '设置查看权限(不设置则所有人可见)'"
placement="top"
>
<el-button
text
class="w-10 h-10 hover:bg-gray-100 rounded-lg"
:class="echoText ? 'text-blue-500' : 'text-gray-500 hover:text-gray-700'"
>
<el-icon size="18">
<IEpLock />
</el-icon>
</el-button>
</el-tooltip>
</DeptUserPicker>
</div> </div>
<!-- 右侧操作按钮 --> <!-- 右侧操作按钮 -->
...@@ -172,15 +214,20 @@ import { ...@@ -172,15 +214,20 @@ import {
import { useTagsStore } from '@/stores' import { useTagsStore } from '@/stores'
import { addOrUpdatePractice, addOrUpdateArticle } from '@/api' import { addOrUpdatePractice, addOrUpdateArticle } from '@/api'
import type { AddOrUpdatePracticeDto } from '@/api' import type { AddOrUpdatePracticeDto } from '@/api'
import { buildPracticeTypeUpdates } from '@/utils/practicePermission'
import type { ElButton } from 'element-plus' import type { ElButton } from 'element-plus'
import { useAnimate } from '@vueuse/core' import { useAnimate } from '@vueuse/core'
import RichTextarea from '../RichTextarea/index.vue' import RichTextarea from '../RichTextarea/index.vue'
import UploadImgIcon from '../UploadImgIcon/index.vue' import UploadImgIcon from '../UploadImgIcon/index.vue'
import DeptUserPicker from '../DeptUserPicker/index.vue'
import type { DepartmentUserSelection } from '../DepartmentUserModal/types'
import { push } from 'notivue' import { push } from 'notivue'
import { useTourStore } from '@/stores' import { useTourStore } from '@/stores'
import { useRouter } from 'vue-router'
const tourStore = useTourStore() const tourStore = useTourStore()
const { showAskTabTour } = storeToRefs(tourStore) const { showAskTabTour } = storeToRefs(tourStore)
const router = useRouter()
// 暂时只有 问吧 和 实践 需要发布框 这俩都需要实名 // 暂时只有 问吧 和 实践 需要发布框 这俩都需要实名
type ArticleType = ArticleTypeEnum.QUESTION | ArticleTypeEnum.PRACTICE type ArticleType = ArticleTypeEnum.QUESTION | ArticleTypeEnum.PRACTICE
...@@ -265,6 +312,11 @@ const { imgList, handleFileChange, handleDeleteImg, uploadPercent } = useUploadI ...@@ -265,6 +312,11 @@ const { imgList, handleFileChange, handleDeleteImg, uploadPercent } = useUploadI
toRef(form.value, 'imgUrl'), toRef(form.value, 'imgUrl'),
) )
// 实践"查看权限":不选就是公开,选了部门/人员之后只有被选中的能看到
const permissionDeptIds = ref<string[]>([])
const permissionUserIds = ref<string[]>([])
const permissionSelection = ref<DepartmentUserSelection[]>([])
const mainTagText = computed(() => { const mainTagText = computed(() => {
return tagList.value.find((tag) => tag.id === Number(form.value.mainTagId))?.title return tagList.value.find((tag) => tag.id === Number(form.value.mainTagId))?.title
}) })
...@@ -277,6 +329,11 @@ const handleAddTag = () => { ...@@ -277,6 +329,11 @@ const handleAddTag = () => {
selectTagsDialogRef.value?.open() selectTagsDialogRef.value?.open()
} }
// 快捷发布区空间有限,问吧的富文本内容统一在大区域编辑页完成。
const goToRichEditor = () => {
router.push('/publishLongArticle/question')
}
const closeAskTabTour = () => { const closeAskTabTour = () => {
showAskTabTour.value = false showAskTabTour.value = false
} }
...@@ -293,6 +350,8 @@ const validateForm = () => { ...@@ -293,6 +350,8 @@ const validateForm = () => {
return true return true
} }
const transformForm = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdatePracticeDto => { const transformForm = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdatePracticeDto => {
// 一个都没选就不传 typeUpdates,由后端按默认规则处理
const typeUpdates = buildPracticeTypeUpdates(permissionSelection.value)
return { return {
...form.value, ...form.value,
releaseStatus, releaseStatus,
...@@ -303,6 +362,7 @@ const transformForm = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdatePractic ...@@ -303,6 +362,7 @@ const transformForm = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdatePractic
tagId: Number(item), tagId: Number(item),
})), })),
type: type, type: type,
...(typeUpdates.length ? { typeUpdates } : {}),
} }
} }
const handlePublish = async (releaseStatus: ReleaseStatusTypeEnum) => { const handlePublish = async (releaseStatus: ReleaseStatusTypeEnum) => {
...@@ -315,6 +375,9 @@ const handlePublish = async (releaseStatus: ReleaseStatusTypeEnum) => { ...@@ -315,6 +375,9 @@ const handlePublish = async (releaseStatus: ReleaseStatusTypeEnum) => {
push.success(releaseStatus === ReleaseStatusTypeEnum.PUBLISH ? '发布成功' : '存草稿成功') push.success(releaseStatus === ReleaseStatusTypeEnum.PUBLISH ? '发布成功' : '存草稿成功')
// resetForm() // resetForm()
forReset() forReset()
permissionDeptIds.value = []
permissionUserIds.value = []
permissionSelection.value = []
// form.value.imgUrl = '' // form.value.imgUrl = ''
} catch (error) { } catch (error) {
console.error(error) console.error(error)
......
<template>
<div
class="bg-white rounded-lg p-6 shadow-[0_1px_3px_rgba(0,0,0,0.02)] border border-slate-100 relative"
>
<slot />
<el-skeleton :rows="5" animated :loading="loading" :throttle="{ leading: 0, trailing: 300 }">
<template #template>
<!-- 顶部标签行 -->
<div class="flex flex-wrap gap-2 mb-4 justify-between">
<!-- 标签 -->
<div class="flex gap-2">
<el-skeleton-item
v-for="i in 3"
:key="i"
variant="text"
style="width: 60px; height: 18px; border-radius: 9999px"
/>
</div>
<!-- 编辑按钮 -->
<el-skeleton-item variant="text" style="width: 40px; height: 18px" />
</div>
<!-- 发布人信息 -->
<div class="flex items-center gap-3 pb-3 border-b border-slate-100">
<!-- 头像 -->
<el-skeleton-item variant="circle" style="width: 40px; height: 40px" />
<!-- 用户名 + 时间 -->
<div class="flex flex-col gap-2">
<el-skeleton-item variant="text" style="width: 100px; height: 14px" />
<el-skeleton-item variant="text" style="width: 180px; height: 12px" />
</div>
</div>
<!-- 标题 -->
<div class="mt-3 mb-3">
<el-skeleton-item variant="text" style="width: 90%; height: 28px" />
</div>
<!-- 内容描述 -->
<div class="space-y-2">
<el-skeleton-item variant="text" style="width: 100%" />
<el-skeleton-item variant="text" style="width: 95%" />
<el-skeleton-item variant="text" style="width: 85%" />
</div>
<!-- 图片列表 -->
<!-- <div class="mt-3 flex gap-2 flex-wrap">
<el-skeleton-item
v-for="i in 3"
:key="i"
variant="image"
style="width: 96px; height: 96px; border-radius: 8px"
/>
</div> -->
<!-- 底部操作栏 -->
<div class="flex items-center justify-between mt-4">
<!-- 写回答按钮 -->
<el-skeleton-item variant="button" style="width: 88px; height: 32px" />
<!-- 右侧点赞 / 收藏 -->
<div class="flex items-center gap-6">
<el-skeleton-item variant="text" style="width: 48px; height: 16px" />
<el-skeleton-item variant="text" style="width: 48px; height: 16px" />
</div>
</div>
</template>
<template #default>
<!-- 顶部标签行 -->
<div class="flex flex-wrap gap-2 mb-4 justify-between">
<div>
<span
v-for="tag in questionDetail.tagNameList"
:key="tag"
class="px-2.5 py-0.5 rounded-full bg-blue-50 text-blue-500 text-xs font-semibold hover:bg-blue-100 transition-colors cursor-pointer mr-2"
>
#{{ tag }}
</span>
</div>
<!-- 后面加一个编辑 -->
<el-link
v-if="isAuthor"
type="primary"
:underline="false"
@click="
router.push(`/publishLongArticle/${questionDetail.type}?id=${questionDetail.id}`)
"
class="text-sm"
>
编辑
</el-link>
</div>
<!-- 👇 新增:发布人信息 -->
<div class="flex items-center gap-3 pb-3 border-b border-slate-100">
<el-avatar
:src="questionDetail.createUserAvatar"
:size="40"
class="flex-shrink-0 cursor-pointer"
@click="
!isPreview &&
jumpToUserHomePage({
userId: questionDetail.createUserId,
type: ArticleTypeEnum.QUESTION,
})
"
>
{{ questionDetail.createUserName }}
</el-avatar>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-sm text-slate-900">
{{ questionDetail.createUserName }}
</span>
<span class="text-sm text-slate-500 flex items-center gap-1">
{{ dayjs(questionDetail.createTime * 1000).format('YYYY-MM-DD HH:mm') }} ·
<span
v-if="questionDetail?.region"
class="text-sm text-slate-500 flex items-center gap-1"
>
{{ questionDetail.region }}
·
</span>
{{ questionDetail.viewCount }}阅读
</span>
</div>
<button
v-if="!isAuthor && questionDetail.createUserId"
type="button"
class="shrink-0 inline-flex items-center px-3 py-1.5 text-xs font-semibold text-blue-600 bg-blue-50 rounded-lg border border-blue-100 hover:bg-blue-100 hover:border-blue-200/80 active:scale-[0.98] transition-all cursor-pointer"
@click="emit('message')"
>
私信
</button>
</div>
<!-- 标题:主要信息,黑重粗 -->
<h1 class="text-2xl font-bold text-slate-900 mb-3 leading-snug tracking-tight">
{{ questionDetail.title }}
</h1>
<!-- 描述:次要信息,深灰 -->
<div class="transition-all duration-300">
<div
:class="{ 'line-clamp-3': !isExpand }"
ref="questionContentRef"
class="html-content"
v-html="parseEmoji(questionDetail.content)"
v-image-preview
v-parse-comment
></div>
<button
v-if="isOverThreeLine"
class="text-blue-500 text-sm font-medium hover:text-blue-600 mt-1 flex items-center gap-0.5 cursor-pointer"
@click="isExpand = !isExpand"
>
{{ isExpand ? '收起' : '显示全部' }}
<el-icon :class="{ 'rotate-180': isExpand }" class="transition-transform"
><IEpCaretBottom
/></el-icon>
</button>
</div>
<div
v-if="questionDetail.imgUrlList?.length"
class="mt-3 flex gap-2 flex-wrap items-center justify-start"
>
<el-image
v-for="(item, i) in questionDetail.imgUrlList"
:key="item"
:src="item"
fit="cover"
class="rounded-lg w-24 h-24 hover:scale-105 transition-transform cursor-pointer"
:preview-src-list="questionDetail.imgUrlList"
:initial-index="i"
:preview-teleported="true"
/>
</div>
<!-- 底部操作栏:预览时没有这些操作 -->
<div v-if="!isPreview" class="flex items-center justify-between mt-4">
<div class="flex gap-3">
<button
class="px-5 py-1.5 border !bg-blue-500 !text-white rounded-[4px] text-sm font-medium transition-colors flex items-center gap-1 cursor-pointer"
@click="emit('write')"
>
<el-icon><IEpEditPen /></el-icon>
写回答
</button>
</div>
<!-- 右侧数据 -->
<div class="flex items-center gap-1 text-slate-500 text-base select-none">
<el-button
text
class="!ml-0 flex items-center gap-1 transition-colors hover:text-blue-500"
:class="{ '!text-blue-500': questionDetail?.hasPraised }"
@click="emit('like')"
>
<el-icon size="17">
<svg-icon :name="questionDetail?.hasPraised ? 'praise_fill' : 'praise'"></svg-icon>
</el-icon>
<span class="text-15px">{{ questionDetail?.praiseCount || 0 }}</span>
</el-button>
<el-button
text
class="!ml-0 flex items-center gap-1 transition-colors hover:text-blue-500"
:class="{ '!text-blue-500': questionDetail?.hasCollect }"
@click="emit('collect')"
>
<el-icon size="17">
<svg-icon
:name="questionDetail?.hasCollect ? 'collection_fill' : 'collection'"
></svg-icon>
</el-icon>
<span class="text-15px">{{ questionDetail?.collectionCount || 0 }}</span>
</el-button>
<el-button
text
class="!ml-0 flex items-center gap-2 transition-colors hover:text-blue-500"
@click="emit('share')"
>
<el-icon size="17">
<svg-icon name="share" size="15"></svg-icon>
</el-icon>
<span class="text-15px">分享</span>
</el-button>
</div>
</div>
</template>
</el-skeleton>
<!-- 正文里的图片预览 -->
<el-image-viewer
v-if="showPreview"
:url-list="srcList"
show-progress
:teleported="true"
:initial-index="currentPreviewIndex"
@close="showPreview = false"
/>
</div>
</template>
<script setup lang="ts">
/**
* 问吧详情页顶部的问题卡片(标签、发布人、标题、正文、配图、点赞收藏分享)。
* questionDetail 页和发布页的预览共用这一个组件,保证预览跟真实页面长得一样;
* 预览时传 isPreview,头像不跳转、不展示底部操作栏。
* 点击这些操作只往外抛事件,具体怎么处理由页面决定。
*/
import dayjs from 'dayjs'
import type { ArticleItemDto } from '@/api'
import { ArticleTypeEnum } from '@/constants'
import { useImagePreview, useNavigation } from '@/hooks'
import { parseEmoji } from '@/utils/emoji'
const {
questionDetail,
isAuthor = false,
isPreview = false,
} = defineProps<{
questionDetail: ArticleItemDto
isAuthor?: boolean
isPreview?: boolean
}>()
const emit = defineEmits<{
write: []
like: []
collect: []
share: []
message: []
}>()
const router = useRouter()
const { jumpToUserHomePage } = useNavigation()
const { showPreview, currentPreviewIndex, srcList, vImagePreview } = useImagePreview()
const loading = computed(() => !questionDetail.title)
const questionContentRef = useTemplateRef<HTMLElement>('questionContentRef')
const isExpand = ref(false)
// 检测当前是否超过三行 要用到具体的dom
const isOverThreeLine = computed(() => {
if (!questionContentRef.value || !questionDetail.content) return false
const lineHeight = parseFloat(getComputedStyle(questionContentRef.value).lineHeight)
const height = questionContentRef.value!.scrollHeight
const maxHeight = lineHeight * 3
return height > maxHeight
})
</script>
<template> <template>
<el-upload <el-upload
v-bind="attrs"
v-model:file-list="fileList" v-model:file-list="fileList"
action="#" action="#"
ref="uploadRef" ref="uploadRef"
...@@ -21,21 +22,26 @@ ...@@ -21,21 +22,26 @@
<img class="w-full" :src="dialogImageUrl" alt="Preview Image" /> <img class="w-full" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog> </el-dialog>
</el-upload> </el-upload>
<ImageCropperDialog ref="cropDialogRef" @confirm="handleCropConfirm" @cancel="handleCropCancel" />
</template> </template>
<script lang="ts" setup generic="T extends string | string[]"> <script lang="ts" setup generic="T extends string | string[]">
import { useAttrs } from 'vue'
import { Plus } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import { uploadFile as uploadFileApi, resolveUploadErrorMessage } from '@/api' import { uploadFile as uploadFileApi, resolveUploadErrorMessage } from '@/api'
import type { UploadProps, UploadUserFile } from 'element-plus' import type { UploadProps, UploadRawFile, UploadUserFile } from 'element-plus'
import type { UploadFileProps } from './types' import type { UploadFileProps } from './types'
import { push } from 'notivue' import { push } from 'notivue'
import { useMessageBox } from '@/hooks' import { useMessageBox } from '@/hooks'
import ImageCropperDialog from '@/components/common/ImageCropperDialog/index.vue'
const props = withDefaults(defineProps<UploadFileProps>(), { defineOptions({
limit: 2, inheritAttrs: false,
multiple: false,
}) })
const attrs = useAttrs()
const { limit = 2, multiple = false, crop = false, aspectRatio } = defineProps<UploadFileProps>()
const modelValue = defineModel<T>({ const modelValue = defineModel<T>({
required: true, required: true,
}) })
...@@ -46,9 +52,12 @@ const uploadRef = useTemplateRef('uploadRef') ...@@ -46,9 +52,12 @@ const uploadRef = useTemplateRef('uploadRef')
const dialogImageUrl = ref('') const dialogImageUrl = ref('')
const dialogVisible = ref(false) const dialogVisible = ref(false)
const uploadPercent = ref(0) const uploadPercent = ref(0)
const cropDialogRef = useTemplateRef<InstanceType<typeof ImageCropperDialog>>('cropDialogRef')
// 仅保存取消裁剪时需要恢复的表单值,不参与界面渲染。
let cropPreviousModelValue: T | undefined
const isArrayType = computed(() => Array.isArray(modelValue.value)) const isArrayType = computed(() => Array.isArray(modelValue.value))
const hasReachedLimit = computed(() => fileList.value.length >= props.limit) const hasReachedLimit = computed(() => fileList.value.length >= limit)
const showUploadBtn = computed(() => (hasReachedLimit.value ? 'none' : 'flex')) const showUploadBtn = computed(() => (hasReachedLimit.value ? 'none' : 'flex'))
const isInternalUpdate = ref(false) const isInternalUpdate = ref(false)
...@@ -105,54 +114,39 @@ watch( ...@@ -105,54 +114,39 @@ watch(
) )
const handleExceed: UploadProps['onExceed'] = (uploadFiles) => { const handleExceed: UploadProps['onExceed'] = (uploadFiles) => {
if (uploadFiles.length > props.limit) { if (uploadFiles.length > limit) {
push.error(`最多上传 ${props.limit} 个文件`) push.error(`最多上传 ${limit} 个文件`)
return return
} }
} }
const handleChange: UploadProps['onChange'] = async (uploadFile, uploadFiles) => { // 统一处理文件上传,裁剪后的文件和普通文件共用这条链路。
if (uploadFiles.length > props.limit) { const uploadRawFile = async (rawFile: File, uid: number) => {
push.error(`最多上传 ${props.limit} 个文件`)
const index = fileList.value.findIndex((file) => file.uid === uploadFile.uid)
if (index !== -1) {
fileList.value.splice(index, 1)
}
return
}
if (uploadFile.raw && uploadFile.status === 'ready') {
const uid = uploadFile.uid
try { try {
let fileIndex = fileList.value.findIndex((file) => file.uid === uid) const fileIndex = fileList.value.findIndex((file) => file.uid === uid)
if (fileIndex !== -1) { if (fileIndex !== -1) {
fileList.value[fileIndex]!.status = 'uploading' fileList.value[fileIndex]!.status = 'uploading'
} }
const { promise } = uploadFileApi(uploadFile.raw, { const { promise } = uploadFileApi(rawFile, {
onProgress: (progress) => { onProgress: (progress) => {
uploadPercent.value = progress uploadPercent.value = progress
}, },
}) })
const data = await promise const data = await promise
const url = data.filePath || '' const url = data.filePath || ''
const name = data.finalName || '' const name = data.finalName || ''
const currentFileIndex = fileList.value.findIndex((file) => file.uid === uid)
fileIndex = fileList.value.findIndex((file) => file.uid === uid) if (currentFileIndex !== -1) {
fileList.value[currentFileIndex] = {
if (fileIndex !== -1) { ...fileList.value[currentFileIndex],
fileList.value[fileIndex] = {
...fileList.value[fileIndex],
url, url,
name, name,
status: 'success', status: 'success',
} }
push.success('上传成功') push.success('上传成功')
} else {
console.warn('找不到对应的文件,uid:', uid)
} }
} catch (error) { } catch (error) {
console.error('上传失败:', error) console.error('上传失败:', error)
...@@ -165,7 +159,61 @@ const handleChange: UploadProps['onChange'] = async (uploadFile, uploadFiles) => ...@@ -165,7 +159,61 @@ const handleChange: UploadProps['onChange'] = async (uploadFile, uploadFiles) =>
} finally { } finally {
uploadPercent.value = 0 uploadPercent.value = 0
} }
}
const handleChange: UploadProps['onChange'] = async (uploadFile, uploadFiles) => {
if (uploadFiles.length > limit) {
push.error(`最多上传 ${limit} 个文件`)
const index = fileList.value.findIndex((file) => file.uid === uploadFile.uid)
if (index !== -1) {
fileList.value.splice(index, 1)
}
return
}
if (uploadFile.raw && uploadFile.status === 'ready') {
if (crop) {
cropPreviousModelValue = modelValue.value
const fileIndex = fileList.value.findIndex((file) => file.uid === uploadFile.uid)
if (fileIndex !== -1) {
fileList.value.splice(fileIndex, 1)
}
cropDialogRef.value?.open(uploadFile.raw, aspectRatio)
return
}
await uploadRawFile(uploadFile.raw, uploadFile.uid)
}
}
const handleCropConfirm = async (croppedFile: File) => {
const uid = Date.now()
// 封面图只允许保留裁剪后的结果,确认时替换临时文件和旧封面。
fileList.value = []
fileList.value.push({
uid,
name: croppedFile.name,
status: 'ready',
raw: croppedFile as UploadRawFile,
})
cropPreviousModelValue = undefined
await uploadRawFile(croppedFile, uid)
}
const handleCropCancel = () => {
if (cropPreviousModelValue !== undefined) {
isInternalUpdate.value = true
modelValue.value = cropPreviousModelValue
nextTick(() => {
isInternalUpdate.value = false
syncModelToFileList()
})
} else {
fileList.value = []
} }
cropPreviousModelValue = undefined
} }
const handleBeforeRemove: UploadProps['beforeRemove'] = () => { const handleBeforeRemove: UploadProps['beforeRemove'] = () => {
......
export interface UploadFileProps { export interface UploadFileProps {
multiple?: boolean multiple?: boolean
limit?: number limit?: number
/** 仅用于需要裁剪后再上传的图片场景。 */
crop?: boolean
/** 固定裁剪比例,例如 3 / 2;不传时允许自由裁剪。 */
aspectRatio?: number
} }
<template> <template>
<div style="border: 1px solid #ccc" class="w-100%" > <div class="wang-editor">
<Toolbar <Toolbar
style="border-bottom: 1px solid #ccc" style="border-bottom: 1px solid #e5e7eb"
:editor="editorRef" :editor="editorRef"
:defaultConfig="toolbarConfig" :defaultConfig="toolbarConfig"
:mode="mode" :mode="mode"
/> />
<Editor <Editor
style="height: 70vh; overflow-y: hidden"
v-model="valueHtml" v-model="valueHtml"
:defaultConfig="editorConfig" :defaultConfig="editorConfig"
:mode="mode" :mode="mode"
:style="{ height, overflowY: 'auto' }"
@onCreated="handleCreated" @onCreated="handleCreated"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import '@wangeditor/editor/dist/css/style.css' // 引入 css import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue' import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { uploadFile } from '@/api' import { uploadFile } from '@/api'
const mode = 'default'
// 编辑器实例,必须用 shallowRef const { height = '70vh', maxLength = 0 } = defineProps<{
const editorRef = shallowRef() height?: string
// 富文本内容最大字符数,0 表示不限制。
maxLength?: number
}>()
// 内容 HTML const valueHtml = defineModel<string>({ default: '' })
const valueHtml = defineModel<string>() const editorRef = shallowRef()
const mode = 'default'
// 去掉上传视频的功能
const toolbarConfig = { const toolbarConfig = {
excludeKeys: ['group-video', 'group-more-video', 'group-more-video'], excludeKeys: ['group-video', 'group-more-video'],
} }
// 去掉上传视频
const editorConfig = { const editorConfig = computed(() => ({
placeholder: '请输入内容...', placeholder: '请输入内容...',
maxLength,
MENU_CONF: { MENU_CONF: {
uploadImage: { uploadImage: {
customUpload: async (file: File, insertFn: (url: string) => void) => { customUpload: async (file: File, insertFn: (url: string) => void) => {
...@@ -43,21 +47,27 @@ const editorConfig = { ...@@ -43,21 +47,27 @@ const editorConfig = {
}, },
}, },
}, },
}))
// 编辑器切换页面时及时销毁实例,避免重复编辑器残留和内存泄漏。
const handleCreated = (editor: typeof Editor) => {
editorRef.value = editor
} }
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => { onBeforeUnmount(() => {
const editor = editorRef.value editorRef.value?.destroy()
if (editor == null) return
editor.destroy()
}) })
const handleCreated = (editor: typeof Editor) => {
editorRef.value = editor // 记录 editor 实例,重要!
}
</script> </script>
<style scoped> <style scoped>
/* 使用 :deep() 来穿透 scoped 样式的限制, 直接为编辑器内容区域设置样式。这里的 p 标签是内容的最小单位,为其设置样式最精确。 */ .wang-editor {
overflow: hidden;
border: 1px solid #dbe3ef;
border-radius: 10px;
background: #fff;
}
/* 使用 :deep() 穿透 scoped 样式,统一设置编辑器正文的字号。 */
:deep(.w-e-text-container p) { :deep(.w-e-text-container p) {
font-size: 18px !important; font-size: 18px !important;
} }
......
...@@ -6,3 +6,5 @@ export * from './useUploadImg' ...@@ -6,3 +6,5 @@ export * from './useUploadImg'
export * from './useNavigation' export * from './useNavigation'
export * from './useMessageBox.tsx' export * from './useMessageBox.tsx'
export * from './useMediaInfo' export * from './useMediaInfo'
export * from './useImagePreview'
export * from './useLocateScroll'
\ No newline at end of file
import { ref, type Directive } from 'vue'
// 页面内共用一个预览弹窗,指令只挂在需要预览的正文 DOM 上。
export function useImagePreview() {
const showPreview = ref(false)
const currentPreviewIndex = ref(0)
const srcList = ref<string[]>([])
const imageHandlers = new WeakMap<HTMLElement, EventListener>()
// 使用捕获阶段的事件委托,动态富文本更新后无需重复扫描和绑定图片事件。
const vImagePreview: Directive = {
mounted(el: HTMLElement) {
const handler: EventListener = (event) => {
const target = event.target
if (!(target instanceof HTMLImageElement)) return
// 点击时只读取当前正文的图片,不跨问题收集,也不在更新钩子中写入响应式状态。
const imgs = Array.from(el.querySelectorAll('img')).filter((item) => item.src)
const index = imgs.indexOf(target)
if (index < 0) return
// 图片预览属于当前内容的独立操作,不能继续触发问吧卡片跳转。
event.stopPropagation()
event.preventDefault()
srcList.value = imgs.map((item) => item.src)
currentPreviewIndex.value = index
showPreview.value = true
}
imageHandlers.set(el, handler)
el.addEventListener('click', handler, true)
},
beforeUnmount(el: HTMLElement) {
const handler = imageHandlers.get(el)
if (handler) {
el.removeEventListener('click', handler, true)
imageHandlers.delete(el)
}
},
}
return {
showPreview,
currentPreviewIndex,
srcList,
vImagePreview,
}
}
// 顶部吸顶栏的高度,跟 useScrollTop 里的补偿保持一致
const HEADER_OFFSET = 52
const GAP = 12
/**
* 评论定位用的滚动:只滚一次。
*
* 目标是回复时,优先停在它所在楼层(context)的顶部,让楼层和回复在同一屏里都能看到;
* 一屏放不下(楼层内容很长、或者回复排在很后面)才改成让回复停在可视区靠上 1/3 的位置。
* 之前先滚楼层再单独滚回复,第二次滚动会把楼层顶出屏幕,所以这里先算好最终位置再滚。
*/
export const scrollToLocate = (contextEl: HTMLElement, targetEl: HTMLElement = contextEl) => {
const contextTop = contextEl.getBoundingClientRect().top + window.scrollY - HEADER_OFFSET - GAP
let top = contextTop
if (targetEl !== contextEl) {
const rect = targetEl.getBoundingClientRect()
const targetBottom = rect.bottom + window.scrollY
if (targetBottom > contextTop + window.innerHeight - GAP) {
const targetTop = rect.top + window.scrollY
top = targetTop - HEADER_OFFSET - (window.innerHeight - HEADER_OFFSET) / 3
}
}
top = Math.max(0, Math.round(top))
return new Promise<void>((resolve) => {
if (Math.abs(window.scrollY - top) < 2) {
resolve()
return
}
const done = () => {
clearTimeout(timer)
resolve()
}
// 滚动距离太短时浏览器不一定会触发 scrollend,加个超时兜底
const timer = setTimeout(() => {
window.removeEventListener('scrollend', done)
resolve()
}, 1000)
window.addEventListener('scrollend', done, { once: true })
window.scrollTo({ top, behavior: 'smooth' })
})
}
...@@ -55,6 +55,26 @@ ...@@ -55,6 +55,26 @@
</div> </div>
</RewardButton> </RewardButton>
<el-tooltip content="点击此处查看/下载最新版企业文化文件" placement="bottom">
<a
:href="cultureManualUrl"
target="_blank"
rel="noopener noreferrer"
class="group flex items-center cursor-pointer px-2 py-1 sm:px-3 sm:py-2 rounded-lg text-gray-700 no-underline transition-all duration-200 hover:shadow-lg hover:bg-white/60"
>
<img
class="w-7.5 h-7.5 object-contain flex-shrink-0 transition-transform duration-200 group-hover:scale-105"
src="@/assets/img/culture/culture_manual.png"
alt="文化手册"
/>
<span
class="ml-2 text-sm whitespace-nowrap hidden lg:inline transition-all duration-200 ease-out group-hover:translate-y--0.5"
>
文化手册
</span>
</a>
</el-tooltip>
<div <div
v-if="!isWareHouse" v-if="!isWareHouse"
class="group flex items-center cursor-pointer px-2 py-1 sm:px-3 sm:py-2 rounded-lg transition-all duration-200 hover:shadow-lg hover:bg-white/60" class="group flex items-center cursor-pointer px-2 py-1 sm:px-3 sm:py-2 rounded-lg transition-all duration-200 hover:shadow-lg hover:bg-white/60"
...@@ -185,6 +205,8 @@ const isHomePage = computed(() => route.path.includes('/homePage')) ...@@ -185,6 +205,8 @@ const isHomePage = computed(() => route.path.includes('/homePage'))
const search = ref('') const search = ref('')
const showSearchInupt = computed(() => route.path !== '/searchPage') const showSearchInupt = computed(() => route.path !== '/searchPage')
const cultureManualUrl =
'http://oatest.yswg.com.cn/oa-nas/OA/readName/pdf/2026/09/15/Common/1789437785012.pdf'
// 获取二级路由的 key // 获取二级路由的 key
const getSecondLevelKey = (route: RouteLocationNormalizedLoadedGeneric) => { const getSecondLevelKey = (route: RouteLocationNormalizedLoadedGeneric) => {
// 取第一级作为key homePage // 取第一级作为key homePage
......
...@@ -43,8 +43,15 @@ export function registerRouterGuards(router: Router) { ...@@ -43,8 +43,15 @@ export function registerRouterGuards(router: Router) {
push.error('登录失败,请刷新页面重试') push.error('登录失败,请刷新页面重试')
} }
// } // }
// 只去掉企微授权带回来的 code/state,其余 query 要留着:
// 比如评论通知链接上的 commentId,丢了就定位不到评论了
const query = { ...to.query }
delete query.code
delete query.state
return { return {
path: to.path, path: to.path,
query,
hash: to.hash,
replace: true, replace: true,
} }
} else { } else {
......
...@@ -260,6 +260,20 @@ export const constantsRoute = [ ...@@ -260,6 +260,20 @@ export const constantsRoute = [
component: () => import('@/views/backend/contentsMenu/questionManage/index.vue'), component: () => import('@/views/backend/contentsMenu/questionManage/index.vue'),
meta: { title: '问吧管理' }, meta: { title: '问吧管理' },
}, },
// 实践管理
{
path: 'contentsMenu/practiceManage',
name: 'ContentsPracticeManage',
component: () => import('@/views/backend/contentsMenu/practiceManage/index.vue'),
meta: { title: '实践管理' },
},
// 帖子管理
{
path: 'contentsMenu/postManage',
name: 'ContentsPostManage',
component: () => import('@/views/backend/contentsMenu/postManage/index.vue'),
meta: { title: '帖子管理' },
},
/** /**
* 配置管理下的子菜单 * 配置管理下的子菜单
*/ */
......
@import './reset.css'; @import './reset.css';
@import './uiLib.scss'; @import './uiLib.scss';
@import './rich-content.css';
/* 解决弹窗滚轮 */ /* 解决弹窗滚轮 */
/* 统一文章与问吧富文本内容的排版规则,保持已验证的 ArticleContent 样式不变。 */
.html-content {
font-size: 18px;
line-height: 1.8;
color: #374151;
word-break: break-word;
}
.html-content img {
cursor: pointer;
}
.html-content p {
margin: 1em 0;
}
.html-content h1,
.html-content h2,
.html-content h3,
.html-content h4,
.html-content h5,
.html-content h6 {
margin: 1.2em 0 0.6em;
line-height: 1.4;
font-weight: 600;
color: #111827;
}
.html-content h1 {
font-size: 1.7em;
}
.html-content h2 {
font-size: 1.5em;
}
.html-content h3 {
font-size: 1.3em;
}
.html-content ul,
.html-content ol {
margin: 1em 0;
padding-left: 1.5em;
}
.html-content ul {
list-style: disc;
}
.html-content ol {
list-style: decimal;
}
.html-content li {
margin: 0.35em 0;
}
.html-content blockquote {
margin: 1em 0;
padding: 0.6em 1em;
border-left: 4px solid #d1d5db;
color: #6b7280;
background: #f9fafb;
}
.html-content pre,
.html-content code {
font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
}
.html-content pre {
margin: 1em 0;
padding: 0.8em 1em;
border-radius: 6px;
overflow-x: auto;
background: #f3f4f6;
}
.html-content code {
padding: 0.1em 0.35em;
border-radius: 4px;
background: #f3f4f6;
}
.html-content pre code {
padding: 0;
background: transparent;
}
.html-content a {
color: #2563eb;
text-decoration: underline;
}
...@@ -7,7 +7,20 @@ type EmojiAsset = { ...@@ -7,7 +7,20 @@ type EmojiAsset = {
group: string group: string
} }
const emojiList: EmojiAsset[] = [...emojis, ...yayas] // 企业文化和同事吧的评论是互相同步的,同事吧只认 [恭喜] 这种没有前缀的套图写法,
// 所以这边发送时也用这个写法(yaya.json 里的名字),改名会导致同步过去的评论显示成文字。
// 以前这边发的是 [YAYA_恭喜] 带前缀的,历史评论里还有,显示时继续认:
// 绝大多数是加个 YAYA_ 前缀,只有下面两个同一张图两边名字起得不一样
const legacyNameAlias: Record<string, string> = {
Okk: 'YAYA_OKK',
恭喜2: 'YAYA_恭喜1',
}
const legacyYayas: EmojiAsset[] = yayas.map((item) => {
const name = item.name.slice(1, -1)
return { ...item, name: `[${legacyNameAlias[name] ?? `YAYA_${name}`}]` }
})
const emojiList: EmojiAsset[] = [...emojis, ...yayas, ...legacyYayas]
// function escapeHTML(str: string) { // function escapeHTML(str: string) {
// return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') // return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
......
[ [
{ {
"name": "[YAYA_OK]", "name": "[OK]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842153149.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842153149.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_OK1]", "name": "[OK1]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842183051.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842183051.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_OKK]", "name": "[Okk]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842204358.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842204358.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_拜托]", "name": "[拜托]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842337245.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842337245.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_点赞]", "name": "[点赞]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842370496.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842370496.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_非常感谢]", "name": "[非常感谢]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842403468.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842403468.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_恭喜]", "name": "[恭喜]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842433720.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842433720.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_恭喜1]", "name": "[恭喜2]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842456906.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842456906.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_好滴]", "name": "[好滴]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842482423.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842482423.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_加油]", "name": "[加油]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842512339.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842512339.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_焦急]", "name": "[焦急]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842540763.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842540763.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_开心]", "name": "[开心]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842564724.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842564724.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_哭泣]", "name": "[哭泣]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842593525.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842593525.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_努力]", "name": "[努力]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842629597.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842629597.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_谢谢]", "name": "[谢谢]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842649971.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842649971.png",
"group": "yaya" "group": "yaya"
}, },
{ {
"name": "[YAYA_亚声人]", "name": "[亚声人]",
"url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842693166.png", "url": "https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/png/2026/03/30/Common/1774842693166.png",
"group": "yaya" "group": "yaya"
} }
......
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
import type { ArticleTypeUpdateEchoDto } from '@/api/article/types'
import type { PracticeTypeUpdateDto } from '@/api/practice/types'
/**
* 实践「查看权限」选中的部门/人员 → addOrUpdatePractice 的 typeUpdates。
*
* - 一个都没选:返回空数组,调用方不传 typeUpdates,由后端按默认规则处理(不要传空数组硬凑)。
* - 选了:0 公开 / 1 部门 / 2 人员 三组都显式传。后端是按类型逐个比对增删的,没传的类型
* 原样保留——比如原来是公开(有一条 valueType=0)、现在改成指定部门,不显式传一个空的公开组,
* 那条公开记录就删不掉,文章照样所有人可见。
*/
export const buildPracticeTypeUpdates = (
selection: DepartmentUserSelection[],
): PracticeTypeUpdateDto[] => {
if (!selection.some((item) => item.valueType === 1 || item.valueType === 2)) return []
const pick = (valueType: 1 | 2) =>
selection
.filter((item) => item.valueType === valueType)
.map((item) => ({ valueId: item.valueId, valueName: item.valueName }))
return [
{ valueType: 0, finalPermissions: [] },
{ valueType: 1, finalPermissions: pick(1) },
{ valueType: 2, finalPermissions: pick(2) },
]
}
/**
* 详情接口回显的 typeUpdates → DeptUserPicker 认的选中项。
* 没设过范围时后端返回的是 valueType=0 的「公开」,不是部门也不是人,要跳过
*/
export const toPracticeSelection = (
typeUpdates?: ArticleTypeUpdateEchoDto[],
): DepartmentUserSelection[] =>
(typeUpdates ?? [])
.filter((group) => group.valueType === 1 || group.valueType === 2)
.flatMap((group) =>
group.finalPermissions.map((item) => ({
valueType: group.valueType as 1 | 2,
valueId: item.valueId,
valueName: item.valueName,
})),
)
...@@ -52,46 +52,3 @@ export function isWxShareCancel(error: unknown) { ...@@ -52,46 +52,3 @@ export function isWxShareCancel(error: unknown) {
msg?.includes('shareAppMessage:cancel'), msg?.includes('shareAppMessage:cancel'),
) )
} }
interface ISelectDepOrUser {
err_msg: string
result: IResult
}
interface IResult {
userList: ISelectUser[]
departmentList: ISelectDept[]
}
export interface ISelectUser {
id: string
name: string
avatar: string
}
export interface ISelectDept {
id: string
name: string
}
export async function selectDepOrUser(wxOption = {}): Promise<IResult> {
await initWxConfig()
const defaultOption = {
fromDepartmentId: -1,
mode: 'single',
type: ['user', 'department'],
}
const option = {
...defaultOption,
...wxOption,
}
return new Promise((resolve, reject) => {
ww.invoke('selectEnterpriseContact', option, function (res: ISelectDepOrUser) {
if (res.err_msg == 'selectEnterpriseContact:ok') {
resolve(res.result)
} else {
reject(res)
}
})
})
}
...@@ -12,12 +12,14 @@ ...@@ -12,12 +12,14 @@
<!-- 评论区 --> <!-- 评论区 -->
<Comment <Comment
v-if="articleDetail.type"
class="mt-6" class="mt-6"
ref="commentRef" ref="commentRef"
:id="id" :id="id"
v-model:total="articleDetail.replyCount" v-model:total="articleDetail.replyCount"
:type="articleDetail.type" :type="articleDetail.type"
:authorId="articleDetail.createUserId" :authorId="articleDetail.createUserId"
:immediate="!initialCommentId"
/> />
</div> </div>
</div> </div>
...@@ -29,21 +31,29 @@ import ActionButtons from './components/actionButtons.vue' ...@@ -29,21 +31,29 @@ import ActionButtons from './components/actionButtons.vue'
import Comment from '@/components/common/Comment/index.vue' import Comment from '@/components/common/Comment/index.vue'
import ArticleContent from '@/components/common/ArticleContent/index.vue' import ArticleContent from '@/components/common/ArticleContent/index.vue'
import BackButton from '@/components/common/BackButton/index.vue' import BackButton from '@/components/common/BackButton/index.vue'
const commentRef = useTemplateRef<typeof Comment | null>('commentRef') const commentRef = useTemplateRef<InstanceType<typeof Comment> | null>('commentRef')
const route = useRoute() const route = useRoute()
const id = Number(route.params.id) const id = Number(route.params.id)
// 消息通知链接带的评论 id。点通知都是新开页面重新加载,所以挂载时读一次就够了;
// 有的话评论区不先加载第一页,直接由定位去加载目标所在页
const initialCommentId = Number(route.query.commentId) || 0
const articleDetail = ref({} as ArticleItemDto) const articleDetail = ref({} as ArticleItemDto)
const initPage = () => { const initPage = () => {
Promise.allSettled([getArticleDetail(id)]).then(([r1]) => { Promise.allSettled([getArticleDetail(id)]).then(async ([r1]) => {
if (r1.status === 'fulfilled') { if (r1.status === 'fulfilled') {
articleDetail.value = r1.value.data articleDetail.value = r1.value.data
// 评论区要等文章详情(拿到 type)回来才渲染,所以定位也放到渲染之后
await nextTick()
if (initialCommentId) {
commentRef.value?.locateComment(initialCommentId)
}
} }
}) })
} }
onMounted(async () => { onMounted(() => {
initPage() initPage()
}) })
</script> </script>
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
import type { BackendCaseListItemDto, BackendEditCaseDto } from '@/api/backend/case/types' import type { BackendCaseListItemDto, BackendEditCaseDto } from '@/api/backend/case/types'
import { useResetData } from '@/hooks' import { useResetData } from '@/hooks'
import SelectTags from '@/components/common/SelectTags/index.vue' import SelectTags from '@/components/common/SelectTags/index.vue'
import DeptUserPicker from '@/components/common/DeptUserPicker/index.vue'
import type { TagItemDto } from '@/api/tag/types' import type { TagItemDto } from '@/api/tag/types'
import { TagLevelEnum, TagTypeEnum } from '@/constants' import { TagLevelEnum, TagTypeEnum } from '@/constants'
import { selectDepOrUser } from '@/utils'
import { backendEditCase } from '@/api/backend' import { backendEditCase } from '@/api/backend'
import { push } from 'notivue' import { push } from 'notivue'
const emit = defineEmits<{ const emit = defineEmits<{
...@@ -28,7 +28,8 @@ const [form] = useResetData<{ ...@@ -28,7 +28,8 @@ const [form] = useResetData<{
yearKeywordSecond: string yearKeywordSecond: string
sceneKeywordMain: string sceneKeywordMain: string
sceneKeywordSecond: number[] sceneKeywordSecond: number[]
departmentList: { id: string; name: string }[] deptId: string
deptName: string
}>({ }>({
id: 0, id: 0,
sourceProject: '', sourceProject: '',
...@@ -44,7 +45,8 @@ const [form] = useResetData<{ ...@@ -44,7 +45,8 @@ const [form] = useResetData<{
yearKeywordSecond: '', yearKeywordSecond: '',
sceneKeywordMain: '', sceneKeywordMain: '',
sceneKeywordSecond: [], sceneKeywordSecond: [],
departmentList: [], deptId: '',
deptName: '',
}) })
const visible = ref(false) const visible = ref(false)
...@@ -71,10 +73,8 @@ const open = (row: BackendCaseListItemDto) => { ...@@ -71,10 +73,8 @@ const open = (row: BackendCaseListItemDto) => {
sceneKeywordMain: String(row.sceneKeywordMain[0]?.id || ''), sceneKeywordMain: String(row.sceneKeywordMain[0]?.id || ''),
sceneKeywordSecond: row.sceneKeywordSecond.map((item) => item.id), sceneKeywordSecond: row.sceneKeywordSecond.map((item) => item.id),
// 关联部门 // 关联部门
departmentList: row.depIdList.map((id, index) => ({ deptId: row.depIdList.join(','),
id: String(id), deptName: row.depNameList.join(','),
name: row.depNameList[index] || '',
})),
} }
} }
...@@ -89,24 +89,6 @@ const filterTagsFn = (tagType: TagTypeEnum, allTags: TagItemDto[]) => { ...@@ -89,24 +89,6 @@ const filterTagsFn = (tagType: TagTypeEnum, allTags: TagItemDto[]) => {
return allTags return allTags
} }
// 选择部门
const selcetDept = async () => {
const { departmentList } = await selectDepOrUser({
mode: 'multi', // single multi
type: ['department'],
selectedDepartmentIds: form.value.departmentList.map((i) => i.id),
})
if (departmentList.length > 3) {
return push.warning('最多只能选择3个部门,请重新选择')
}
form.value.departmentList = departmentList
}
// 删除部门
const delDept = (id: string) => {
form.value.departmentList = form.value.departmentList.filter((i) => i.id !== id)
}
// 数据转换 // 数据转换
const transformData = () => { const transformData = () => {
const { const {
...@@ -116,15 +98,12 @@ const transformData = () => { ...@@ -116,15 +98,12 @@ const transformData = () => {
yearKeywordSecond, yearKeywordSecond,
sceneKeywordMain, sceneKeywordMain,
sceneKeywordSecond, sceneKeywordSecond,
departmentList,
...rest ...rest
} = form.value } = form.value
const data: BackendEditCaseDto = { const data: BackendEditCaseDto = {
...rest, ...rest,
tagRelationDtoList: [], tagRelationDtoList: [],
deptId: departmentList.map((i) => i.id).join(','),
deptName: departmentList.map((i) => i.name).join(','),
} }
// 添加文化关键词数据 // 添加文化关键词数据
...@@ -293,20 +272,21 @@ defineExpose({ ...@@ -293,20 +272,21 @@ defineExpose({
</el-form-item> </el-form-item>
<!-- 关联部门 --> <!-- 关联部门 -->
<el-form-item label="关联部门:"> <el-form-item label="关联部门:">
<div class="flex items-start gap-3"> <DeptUserPicker
<el-button class="button-new-tag" size="small" @click="selcetDept"> v-model:dept-value="form.deptId"
+ 添加部门 selection-mode="department"
</el-button> value-type="string"
<el-tag :max="3"
v-for="tag in form.departmentList" @confirm="(items) => (form.deptName = items.map((i) => i.valueName).join(','))"
:key="tag.id" v-slot="{ echoText }"
closable
type="primary"
@close="() => delDept(tag.id)"
> >
{{ tag.name }} <el-input
</el-tag> readonly
</div> class="w-full cursor-pointer"
placeholder="请选择部门"
:model-value="echoText"
/>
</DeptUserPicker>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-scrollbar> </el-scrollbar>
......
...@@ -11,11 +11,20 @@ const handleContent = (content: string) => { ...@@ -11,11 +11,20 @@ const handleContent = (content: string) => {
confirmButtonText: '确定', confirmButtonText: '确定',
}) })
} }
// 后端返回 errorFields(必填项校验失败的字段名数组),按字段精确标红对应单元格,而不是整行飘红
const getCellStyle = ({ row, column }: { row: any; column: any }) => {
const errorFields = row.errorFields as string[] | undefined
if (column.property && errorFields?.includes(column.property)) {
return { backgroundColor: 'var(--el-color-danger-light-9)', color: 'var(--el-color-danger)' }
}
return {}
}
</script> </script>
<template> <template>
<el-dialog v-model="visible" title="以下数据导入失败" width="80vw"> <el-dialog v-model="visible" title="以下数据导入失败" width="80vw">
<el-table :data="errorList" style="width: 100%" height="500"> <el-table :data="errorList" :cell-style="getCellStyle" style="width: 100%" height="500">
<el-table-column prop="errorMsg" label="错误信息" width="200"></el-table-column> <el-table-column prop="errorMsg" label="错误信息" width="200"></el-table-column>
<el-table-column label="来源对象" align="center"> <el-table-column label="来源对象" align="center">
<el-table-column prop="jobNo" label="工号" width="120"></el-table-column> <el-table-column prop="jobNo" label="工号" width="120"></el-table-column>
...@@ -35,9 +44,9 @@ const handleContent = (content: string) => { ...@@ -35,9 +44,9 @@ const handleContent = (content: string) => {
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="文化关键词(次关键词选填)" align="center"> <el-table-column label="文化关键词(次关键词选填)" align="center">
<el-table-column label="主关键词" width="160"> <el-table-column prop="cultureMain" label="主关键词" width="160">
<template #default="{ row }"> <template #default="{ row }">
<el-tag type="primary"> {{ row.cultureMain }}</el-tag> <el-tag v-if="row.cultureMain" type="primary"> {{ row.cultureMain }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="次关键词1" width="160"> <el-table-column label="次关键词1" width="160">
......
...@@ -47,8 +47,6 @@ ...@@ -47,8 +47,6 @@
<el-icon><IEpDownload /></el-icon>下载导入模板</el-button <el-icon><IEpDownload /></el-icon>下载导入模板</el-button
> >
<ImportExcelBtn :api="importCaseExcel" @success="refresh" @error="handleError" /> <ImportExcelBtn :api="importCaseExcel" @success="refresh" @error="handleError" />
<el-button type="primary" @click="handleBatchPublish"> 批量发布/隐藏 </el-button>
<el-button type="danger" @click="handleBatchDelete"> 批量删除 </el-button>
</div> </div>
<!-- 表格区域 --> <!-- 表格区域 -->
...@@ -248,10 +246,10 @@ ...@@ -248,10 +246,10 @@
</el-select> </el-select>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="100" fixed="right"> <el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button> <el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<!-- <el-button type="danger" link @click="handleDelete(row)">删除</el-button> --> <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
...@@ -280,11 +278,10 @@ ...@@ -280,11 +278,10 @@
import { usePageSearch } from '@/hooks' import { usePageSearch } from '@/hooks'
import { import {
getCaseList, getCaseList,
deleteColumn,
hideColumn,
changeUsageStatus, changeUsageStatus,
auditCase, auditCase,
importCaseExcel, importCaseExcel,
deleteCase,
} from '@/api/backend' } from '@/api/backend'
import type { BackendCaseListItemDto } from '@/api/backend' import type { BackendCaseListItemDto } from '@/api/backend'
import { import {
...@@ -332,22 +329,6 @@ const handleSelectionChange = (selection: BackendCaseListItemDto[]) => { ...@@ -332,22 +329,6 @@ const handleSelectionChange = (selection: BackendCaseListItemDto[]) => {
selectedRows.value = selection selectedRows.value = selection
} }
// 批量发布/隐藏
const handleBatchPublish = async () => {
await hideColumn(selectedRows.value.map((item) => item.id))
refresh()
selectedRows.value = []
push.success('发布/隐藏成功')
}
// 批量删除
const handleBatchDelete = async () => {
await deleteColumn(selectedRows.value.map((item) => item.id))
refresh()
selectedRows.value = []
push.success('删除成功')
}
const handleContent = (row: BackendCaseListItemDto) => { const handleContent = (row: BackendCaseListItemDto) => {
ElMessageBox.alert(row.content, '案例全部文本内容', { ElMessageBox.alert(row.content, '案例全部文本内容', {
confirmButtonText: '确定', confirmButtonText: '确定',
...@@ -383,6 +364,19 @@ const handleError = (list: BackendCaseListItemDto[]) => { ...@@ -383,6 +364,19 @@ const handleError = (list: BackendCaseListItemDto[]) => {
errorList.value = list errorList.value = list
} }
const handleDelete = async (row: BackendCaseListItemDto) => {
await confirm({
title: '提示',
message: '确定要删除该案例吗?',
confirmText: '确定',
cancelText: '取消',
type: 'danger',
})
await deleteCase(row.id)
push.success('删除案例库成功')
refresh()
}
const downloadImportTemplate = () => { const downloadImportTemplate = () => {
window.open( window.open(
'https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/readName/xlsx/2026/03/11/Common/1773219970707.xlsx', 'https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/readName/xlsx/2026/03/11/Common/1773219970707.xlsx',
......
<script setup lang="ts"> <script setup lang="ts">
import { Search, Upload } from '@element-plus/icons-vue' import ContentSearchBar from '../components/ContentSearchBar.vue'
import { useAdminDeleteArticle } from '../components/useAdminDeleteArticle'
import { Upload } from '@element-plus/icons-vue'
import { usePageSearch, useResetData } from '@/hooks' import { usePageSearch, useResetData } from '@/hooks'
import { addOrUpdateColumn } from '@/api/backend' import { addOrUpdateColumn } from '@/api/backend'
import { getArticleList, updateArticleRecommendAndSort } from '@/api' import { getArticleList, updateArticleRecommendAndSort, recallPush } from '@/api'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import type { BackendColumnListItemDto, AddOrUpdateColumnDto } from '@/api/backend' import type { BackendColumnListItemDto, AddOrUpdateColumnDto } from '@/api/backend'
import dayjs from 'dayjs' import dayjs from 'dayjs'
...@@ -10,8 +12,10 @@ import { ArticleTypeEnum, BooleanFlag } from '@/constants' ...@@ -10,8 +12,10 @@ import { ArticleTypeEnum, BooleanFlag } from '@/constants'
import type { ArticleItemDto } from '@/api/article/types' import type { ArticleItemDto } from '@/api/article/types'
import { push } from 'notivue' import { push } from 'notivue'
import { useMessageBox } from '@/hooks' import { useMessageBox } from '@/hooks'
import ArticleContentDialog from '@/components/common/ArticleContentDialog/index.vue'
const { prompt } = useMessageBox() const { prompt, confirm } = useMessageBox()
const contentDialogRef = useTemplateRef<InstanceType<typeof ArticleContentDialog>>('contentDialogRef')
const { loading, list, total, reset, goToPage, changePageSize, refresh, searchParams, search } = const { loading, list, total, reset, goToPage, changePageSize, refresh, searchParams, search } =
usePageSearch(getArticleList, { usePageSearch(getArticleList, {
defaultParams: { defaultParams: {
...@@ -19,6 +23,8 @@ const { loading, list, total, reset, goToPage, changePageSize, refresh, searchPa ...@@ -19,6 +23,8 @@ const { loading, list, total, reset, goToPage, changePageSize, refresh, searchPa
}, },
}) })
const handleDelete = useAdminDeleteArticle('专栏', () => refresh())
// 对话框 // 对话框
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogTitle = computed(() => (form.value.id ? '编辑' : '新增')) const dialogTitle = computed(() => (form.value.id ? '编辑' : '新增'))
...@@ -99,13 +105,43 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => { ...@@ -99,13 +105,43 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => {
selectedRows.value = selection selectedRows.value = selection
} }
// 函数弹窗 v-html // 查看内容:是不是富文本由弹窗组件自己检测,这里只管传内容进去
const handleShowContent = (row: ArticleItemDto) => { const handleShowContent = (row: ArticleItemDto) => {
ElMessageBox.alert(row.content, '内容', { contentDialogRef.value?.open(row.content)
showCancelButton: false, }
showConfirmButton: false,
dangerouslyUseHTMLString: true, // 推送状态文案:0 文章未设置推送 / 1 已推送 / 2 定时待推送 / 3 已全部撤回
const pushStatusTextMap: Record<number, string> = {
0: '文章未设置推送',
1: '已推送',
2: '定时待推送',
3: '已全部撤回',
}
const pushStatusText = (status: number) => pushStatusTextMap[status] ?? '未知'
// 撤回是有时间限制的(推送后 24 小时内才能撤回),超时后按钮会自动隐藏,这里加个提示说明规则
const PUSH_RECALL_TOOLTIP =
'已推送的内容仅支持在推送后 24 小时内撤回,超过 24 小时将无法撤回;定时待推送的可以随时取消'
// 撤回推送:能不能点由后端算好的 pushInfo.recallable 决定,定时待推送时文案改成"取消定时推送"
const handleRecallPush = async (row: ArticleItemDto) => {
const isScheduled = row.pushInfo?.pushStatus === 2
await confirm({
title: '提示',
message: isScheduled
? '确定要取消这次定时推送吗?'
: '确定要撤回这条推送吗?已经收到通知的用户不会撤回消息。',
type: 'warning',
}) })
// 后端撤回失败时不抛异常(部分批次成功的状态要保留),HTTP 还是 200,
// 所以这里必须看 failCount,否则会出现"提示撤回成功、状态却没变"
const { data } = await recallPush(row.id)
if (data?.failCount) {
push.warning(data.message || '部分批次撤回失败,可稍后重试')
} else {
push.success(data?.message || (isScheduled ? '取消定时推送成功' : '撤回成功'))
}
refresh()
} }
</script> </script>
...@@ -113,13 +149,7 @@ const handleShowContent = (row: ArticleItemDto) => { ...@@ -113,13 +149,7 @@ const handleShowContent = (row: ArticleItemDto) => {
<div class="official-tag-page"> <div class="official-tag-page">
<!-- 搜索栏 --> <!-- 搜索栏 -->
<div class="search-section"> <div class="search-section">
<el-input <ContentSearchBar v-model="searchParams" @search="refresh" @reset="reset" />
v-model="searchParams.title"
placeholder="请输入标题"
class="w-200px mr-12px"
></el-input>
<el-button type="primary" :icon="Search" @click="refresh">搜索</el-button>
<el-button @click="reset">重置</el-button>
</div> </div>
<!-- 表格区域 --> <!-- 表格区域 -->
...@@ -189,6 +219,38 @@ const handleShowContent = (row: ArticleItemDto) => { ...@@ -189,6 +219,38 @@ const handleShowContent = (row: ArticleItemDto) => {
{{ dayjs(row.createTime * 1000).format('YYYY-MM-DD HH:mm:ss') }} {{ dayjs(row.createTime * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</template> </template>
</el-table-column> </el-table-column>
<!-- 只有官方账号推送过的文章才会带 pushInfo -->
<el-table-column label="推送状态" min-width="220">
<template #header>
<div class="flex items-center gap-2">
<span>推送状态</span>
<el-tooltip :content="PUSH_RECALL_TOOLTIP" placement="top">
<el-icon><IEpInfoFilled /></el-icon>
</el-tooltip>
</div>
</template>
<template #default="{ row }">
<div v-if="row.pushInfo" class="flex items-center gap-2">
<span>{{ pushStatusText(row.pushInfo.pushStatus) }}</span>
<el-button
v-if="row.pushInfo.recallable"
type="danger"
link
@click="handleRecallPush(row)"
>
{{ row.pushInfo.pushStatus === 2 ? '取消定时推送' : '撤回推送' }}
</el-button>
</div>
<span v-else>{{ pushStatusText(0) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table> </el-table>
</div> </div>
...@@ -235,6 +297,8 @@ const handleShowContent = (row: ArticleItemDto) => { ...@@ -235,6 +297,8 @@ const handleShowContent = (row: ArticleItemDto) => {
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
<ArticleContentDialog ref="contentDialogRef" />
</div> </div>
</template> </template>
......
<template>
<div class="content-search-bar">
<el-input
v-model="params.title"
placeholder="请输入标题"
clearable
class="filter-item"
style="width: 200px"
@keyup.enter="emit('search')"
/>
<el-select
v-model="params.tagIdList"
multiple
collapse-tags
collapse-tags-tooltip
clearable
placeholder="请选择标签"
class="filter-item"
style="width: 240px"
>
<el-option v-for="tag in tagList" :key="tag.id" :label="tag.title" :value="tag.id" />
</el-select>
<el-input
v-model="params.createUserName"
placeholder="请输入创建人"
clearable
class="filter-item"
style="width: 200px"
@keyup.enter="emit('search')"
/>
<el-date-picker
v-model="createTimeRange"
type="daterange"
value-format="X"
range-separator="至"
start-placeholder="创建开始日期"
end-placeholder="创建结束日期"
class="filter-item"
style="width: 300px"
/>
<div class="filter-item flex items-center">
<el-button type="primary" :icon="Search" @click="emit('search')">搜索</el-button>
<el-button @click="emit('reset')">重置</el-button>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
/**
* 后台内容管理各个列表页(专栏/专访/视频/问吧/实践/帖子)共用的搜索栏:
* 标题、标签(多选)、创建人(模糊)、创建时间范围(年月日)。
* 筛选值直接写进传进来的 searchParams,搜索/重置由页面自己处理;页面特有的按钮放默认插槽。
*/
import { Search } from '@element-plus/icons-vue'
import { storeToRefs } from 'pinia'
import { useTagsStore } from '@/stores/tags'
import { formatSeconds } from '@/utils/app'
import type { ArticleSearchParams } from '@/api'
const params = defineModel<ArticleSearchParams>({ required: true })
const emit = defineEmits<{ search: []; reset: [] }>()
const { tagList } = storeToRefs(useTagsStore())
const createTimeRange = computed<string[] | null>({
get: () => {
const { createTimeStart, createTimeEnd } = params.value
return createTimeStart && createTimeEnd ? [String(createTimeStart), String(createTimeEnd)] : null
},
set: (range) => {
// 后端要秒级时间戳,结束时间转成当天 23:59:59;清空时必须是 undefined,设 0 会一条都查不出来
params.value.createTimeStart = range ? Number(range[0]) : undefined
params.value.createTimeEnd = range ? formatSeconds(Number(range[1])) : undefined
},
})
</script>
<style scoped>
.content-search-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
/* 选择框/日期选择器自带 flex-grow,不禁掉会把一整行撑满 */
.filter-item {
flex: none;
}
</style>
import { deleteArticleByAdmin } from '@/api'
import { useMessageBox } from '@/hooks'
import { push } from 'notivue'
/**
* 后台内容管理各列表页的「删除」:删了前台就看不到了,必须二次确认。
* typeLabel 是页面对应的类型名(帖子/专栏…),用在确认文案里。
*/
export const useAdminDeleteArticle = (typeLabel: string, onDeleted: () => void) => {
const { confirm } = useMessageBox()
return async (row: { id: number; title: string }) => {
await confirm({
title: '删除确认',
message: `确定要删除${typeLabel}《${row.title}》吗?删除后前台将无法查看,且无法恢复。`,
confirmText: '确认删除',
type: 'danger',
})
await deleteArticleByAdmin(row.id)
push.success('删除成功')
onDeleted()
}
}
<script setup lang="ts"> <script setup lang="ts">
import { Search, Upload } from '@element-plus/icons-vue' import ContentSearchBar from '../components/ContentSearchBar.vue'
import { useAdminDeleteArticle } from '../components/useAdminDeleteArticle'
import { Upload } from '@element-plus/icons-vue'
import { usePageSearch, useResetData } from '@/hooks' import { usePageSearch, useResetData } from '@/hooks'
import { addOrUpdateColumn } from '@/api/backend' import { addOrUpdateColumn } from '@/api/backend'
import { getArticleList, updateArticleRecommendAndSort } from '@/api' import { getArticleList, updateArticleRecommendAndSort, recallPush } from '@/api'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import type { BackendColumnListItemDto, AddOrUpdateColumnDto } from '@/api/backend' import type { BackendColumnListItemDto, AddOrUpdateColumnDto } from '@/api/backend'
import dayjs from 'dayjs' import dayjs from 'dayjs'
...@@ -10,8 +12,10 @@ import { ArticleTypeEnum, BooleanFlag } from '@/constants' ...@@ -10,8 +12,10 @@ import { ArticleTypeEnum, BooleanFlag } from '@/constants'
import type { ArticleItemDto } from '@/api/article/types' import type { ArticleItemDto } from '@/api/article/types'
import { push } from 'notivue' import { push } from 'notivue'
import { useMessageBox } from '@/hooks' import { useMessageBox } from '@/hooks'
import ArticleContentDialog from '@/components/common/ArticleContentDialog/index.vue'
const { prompt } = useMessageBox() const { prompt, confirm } = useMessageBox()
const contentDialogRef = useTemplateRef<InstanceType<typeof ArticleContentDialog>>('contentDialogRef')
const { loading, list, total, reset, goToPage, changePageSize, refresh, searchParams, search } = const { loading, list, total, reset, goToPage, changePageSize, refresh, searchParams, search } =
usePageSearch(getArticleList, { usePageSearch(getArticleList, {
defaultParams: { defaultParams: {
...@@ -19,6 +23,8 @@ const { loading, list, total, reset, goToPage, changePageSize, refresh, searchPa ...@@ -19,6 +23,8 @@ const { loading, list, total, reset, goToPage, changePageSize, refresh, searchPa
}, },
}) })
const handleDelete = useAdminDeleteArticle('专访', () => refresh())
// 对话框 // 对话框
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogTitle = computed(() => (form.value.id ? '编辑' : '新增')) const dialogTitle = computed(() => (form.value.id ? '编辑' : '新增'))
...@@ -100,13 +106,43 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => { ...@@ -100,13 +106,43 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => {
selectedRows.value = selection selectedRows.value = selection
} }
// 函数弹窗 v-html // 查看内容:是不是富文本由弹窗组件自己检测,这里只管传内容进去
const handleShowContent = (row: ArticleItemDto) => { const handleShowContent = (row: ArticleItemDto) => {
ElMessageBox.alert(row.content, '内容', { contentDialogRef.value?.open(row.content)
showCancelButton: false, }
showConfirmButton: false,
dangerouslyUseHTMLString: true, // 推送状态文案:0 文章未设置推送 / 1 已推送 / 2 定时待推送 / 3 已全部撤回
const pushStatusTextMap: Record<number, string> = {
0: '文章未设置推送',
1: '已推送',
2: '定时待推送',
3: '已全部撤回',
}
const pushStatusText = (status: number) => pushStatusTextMap[status] ?? '未知'
// 撤回是有时间限制的(推送后 24 小时内才能撤回),超时后按钮会自动隐藏,这里加个提示说明规则
const PUSH_RECALL_TOOLTIP =
'已推送的内容仅支持在推送后 24 小时内撤回,超过 24 小时将无法撤回;定时待推送的可以随时取消'
// 撤回推送:能不能点由后端算好的 pushInfo.recallable 决定,定时待推送时文案改成"取消定时推送"
const handleRecallPush = async (row: ArticleItemDto) => {
const isScheduled = row.pushInfo?.pushStatus === 2
await confirm({
title: '提示',
message: isScheduled
? '确定要取消这次定时推送吗?'
: '确定要撤回这条推送吗?已经收到通知的用户不会撤回消息。',
type: 'warning',
}) })
// 后端撤回失败时不抛异常(部分批次成功的状态要保留),HTTP 还是 200,
// 所以这里必须看 failCount,否则会出现"提示撤回成功、状态却没变"
const { data } = await recallPush(row.id)
if (data?.failCount) {
push.warning(data.message || '部分批次撤回失败,可稍后重试')
} else {
push.success(data?.message || (isScheduled ? '取消定时推送成功' : '撤回成功'))
}
refresh()
} }
</script> </script>
...@@ -114,13 +150,7 @@ const handleShowContent = (row: ArticleItemDto) => { ...@@ -114,13 +150,7 @@ const handleShowContent = (row: ArticleItemDto) => {
<div class="official-tag-page"> <div class="official-tag-page">
<!-- 搜索栏 --> <!-- 搜索栏 -->
<div class="search-section"> <div class="search-section">
<el-input <ContentSearchBar v-model="searchParams" @search="refresh" @reset="reset" />
v-model="searchParams.title"
placeholder="请输入标题"
class="w-200px mr-12px"
></el-input>
<el-button type="primary" :icon="Search" @click="refresh">搜索</el-button>
<el-button @click="reset">重置</el-button>
</div> </div>
<!-- 表格区域 --> <!-- 表格区域 -->
...@@ -190,6 +220,38 @@ const handleShowContent = (row: ArticleItemDto) => { ...@@ -190,6 +220,38 @@ const handleShowContent = (row: ArticleItemDto) => {
{{ dayjs(row.createTime * 1000).format('YYYY-MM-DD HH:mm:ss') }} {{ dayjs(row.createTime * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</template> </template>
</el-table-column> </el-table-column>
<!-- 只有官方账号推送过的文章才会带 pushInfo -->
<el-table-column label="推送状态" min-width="220">
<template #header>
<div class="flex items-center gap-2">
<span>推送状态</span>
<el-tooltip :content="PUSH_RECALL_TOOLTIP" placement="top">
<el-icon><IEpInfoFilled /></el-icon>
</el-tooltip>
</div>
</template>
<template #default="{ row }">
<div v-if="row.pushInfo" class="flex items-center gap-2">
<span>{{ pushStatusText(row.pushInfo.pushStatus) }}</span>
<el-button
v-if="row.pushInfo.recallable"
type="danger"
link
@click="handleRecallPush(row)"
>
{{ row.pushInfo.pushStatus === 2 ? '取消定时推送' : '撤回推送' }}
</el-button>
</div>
<span v-else>{{ pushStatusText(0) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table> </el-table>
</div> </div>
...@@ -236,6 +298,8 @@ const handleShowContent = (row: ArticleItemDto) => { ...@@ -236,6 +298,8 @@ const handleShowContent = (row: ArticleItemDto) => {
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
<ArticleContentDialog ref="contentDialogRef" />
</div> </div>
</template> </template>
......
<script setup lang="ts">
import ContentSearchBar from '../components/ContentSearchBar.vue'
import { useAdminDeleteArticle } from '../components/useAdminDeleteArticle'
import { Upload } from '@element-plus/icons-vue'
import { usePageSearch, useResetData } from '@/hooks'
import { addOrUpdateColumn } from '@/api/backend'
import {
getArticleList,
updateArticleRecommendAndSort,
recallPush,
} from '@/api'
import type { FormInstance, FormRules } from 'element-plus'
import type { BackendColumnListItemDto, AddOrUpdateColumnDto } from '@/api/backend'
import dayjs from 'dayjs'
import { ArticleTypeEnum, BooleanFlag } from '@/constants'
import type { ArticleItemDto } from '@/api/article/types'
import { push } from 'notivue'
import { useMessageBox } from '@/hooks'
import ArticleContentDialog from '@/components/common/ArticleContentDialog/index.vue'
const { prompt, confirm } = useMessageBox()
const contentDialogRef = useTemplateRef<InstanceType<typeof ArticleContentDialog>>('contentDialogRef')
const { loading, list, total, reset, goToPage, changePageSize, refresh, searchParams, search } =
usePageSearch(getArticleList, {
defaultParams: {
type: ArticleTypeEnum.POST,
},
})
const handleDelete = useAdminDeleteArticle('帖子', () => refresh())
// 对话框
const dialogVisible = ref(false)
const dialogTitle = computed(() => (form.value.id ? '编辑' : '新增'))
const formRef = ref<FormInstance>()
// 表单数据
const [form] = useResetData<AddOrUpdateColumnDto>({
title: '',
color: '#000000',
id: undefined,
sort: 0,
type: 'column',
})
// 表单验证规则
const formRules: FormRules = {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
color: [{ required: true, message: '请选择颜色', trigger: 'change' }],
sort: [{ required: true, message: '请输入排序', trigger: 'blur' }],
}
// 是否推荐改变
const handleIsRecommendChange = async (val: BooleanFlag, row: ArticleItemDto) => {
await updateArticleRecommendAndSort({
articleId: row.id,
recommendSort: 0, // 默认传 0
isRecommend: val,
})
push.success('修改成功')
refresh()
}
// 编辑排序
const handleEditSort = async (row: ArticleItemDto) => {
const value = await prompt({
title: '请输入排序值',
type: 'primary',
inputPattern: /^\d+$/,
inputErrorMessage: '请输入数字',
})
await updateArticleRecommendAndSort({
articleId: row.id,
recommendSort: Number(value),
isRecommend: BooleanFlag.YES,
})
push.success('修改成功')
refresh()
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (form.value.id) {
await addOrUpdateColumn(form.value)
} else {
await addOrUpdateColumn(form.value)
}
push.success(form.value.id ? '编辑成功' : '新增成功')
dialogVisible.value = false
if (form.value.id) {
search()
} else {
refresh()
}
} catch (error) {
console.error('表单验证失败:', error)
}
}
const selectedRows = ref<BackendColumnListItemDto[]>([])
// 选择
const handleSelectionChange = (selection: BackendColumnListItemDto[]) => {
selectedRows.value = selection
}
// 查看内容:是不是富文本由弹窗组件自己检测,这里只管传内容进去
const handleShowContent = (row: ArticleItemDto) => {
contentDialogRef.value?.open(row.content)
}
// 推送状态文案:0 文章未设置推送 / 1 已推送 / 2 定时待推送 / 3 已全部撤回
const pushStatusTextMap: Record<number, string> = {
0: '文章未设置推送',
1: '已推送',
2: '定时待推送',
3: '已全部撤回',
}
const pushStatusText = (status: number) => pushStatusTextMap[status] ?? '未知'
// 撤回是有时间限制的(推送后 24 小时内才能撤回),超时后按钮会自动隐藏,这里加个提示说明规则
const PUSH_RECALL_TOOLTIP =
'已推送的内容仅支持在推送后 24 小时内撤回,超过 24 小时将无法撤回;定时待推送的可以随时取消'
// 撤回推送:能不能点由后端算好的 pushInfo.recallable 决定,定时待推送时文案改成"取消定时推送"
const handleRecallPush = async (row: ArticleItemDto) => {
const isScheduled = row.pushInfo?.pushStatus === 2
await confirm({
title: '提示',
message: isScheduled
? '确定要取消这次定时推送吗?'
: '确定要撤回这条推送吗?已经收到通知的用户不会撤回消息。',
type: 'warning',
})
// 后端撤回失败时不抛异常(部分批次成功的状态要保留),HTTP 还是 200,
// 所以这里必须看 failCount,否则会出现"提示撤回成功、状态却没变"
const { data } = await recallPush(row.id)
if (data?.failCount) {
push.warning(data.message || '部分批次撤回失败,可稍后重试')
} else {
push.success(data?.message || (isScheduled ? '取消定时推送成功' : '撤回成功'))
}
refresh()
}
</script>
<template>
<div class="official-tag-page">
<!-- 搜索栏 -->
<div class="search-section">
<ContentSearchBar v-model="searchParams" @search="refresh" @reset="reset" />
</div>
<!-- 表格区域 -->
<div class="table-section">
<!-- 表格 -->
<div class="table-wrapper">
<el-table
v-loading="loading"
:data="list"
height="100%"
@selection-change="handleSelectionChange"
>
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="content" label="内容" min-width="200">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="handleShowContent(row)">
内容为富文本,点击查看
</el-link>
</template>
</el-table-column>
<el-table-column prop="releaseStatus" label="标签" min-width="200">
<template #default="{ row }">
<el-tag
v-for="tag in row.tagNameList"
:key="tag.tagId"
type="primary"
class="mr-2! mb-2!"
>
{{ tag }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="是否推荐" min-width="200">
<template #header>
<div class="flex items-center gap-2">
<span>是否推荐</span>
<el-tooltip content="推荐后前台将按照排序值从大到小展示" placement="top">
<el-icon><IEpInfoFilled /></el-icon>
</el-tooltip>
</div>
</template>
<template #default="{ row }">
<div class="flex items-center gap-2">
<el-switch
:model-value="row.isRecommend"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
@change="(val) => handleIsRecommendChange(val as BooleanFlag, row)"
/>
<el-link
v-if="row.isRecommend"
class="flex items-center gap-2"
type="primary"
@click="handleEditSort(row)"
:underline="false"
>排序值:{{ row.recommendSort }}</el-link
>
</div>
</template>
</el-table-column>
<el-table-column prop="showName" label="创建人" min-width="200" />
<el-table-column prop="createTime" label="创建时间" min-width="200">
<template #default="{ row }">
{{ dayjs(row.createTime * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</template>
</el-table-column>
<!-- 只有官方账号推送过的文章才会带 pushInfo -->
<el-table-column label="推送状态" min-width="220">
<template #header>
<div class="flex items-center gap-2">
<span>推送状态</span>
<el-tooltip :content="PUSH_RECALL_TOOLTIP" placement="top">
<el-icon><IEpInfoFilled /></el-icon>
</el-tooltip>
</div>
</template>
<template #default="{ row }">
<div v-if="row.pushInfo" class="flex items-center gap-2">
<span>{{ pushStatusText(row.pushInfo.pushStatus) }}</span>
<el-button
v-if="row.pushInfo.recallable"
type="danger"
link
@click="handleRecallPush(row)"
>
{{ row.pushInfo.pushStatus === 2 ? '取消定时推送' : '撤回推送' }}
</el-button>
</div>
<span v-else>{{ pushStatusText(0) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="searchParams.current"
v-model:page-size="searchParams.size"
:total="total"
:page-sizes="[10, 20, 30]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="changePageSize"
@current-change="goToPage"
/>
</div>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="颜色" prop="color">
<el-color-picker v-model="form.color" />
<span class="color-value">{{ form.color }}</span>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="100" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">
<el-icon class="btn-icon"><Upload /></el-icon>
保存
</el-button>
</template>
</el-dialog>
<ArticleContentDialog ref="contentDialogRef" />
</div>
</template>
<style scoped lang="scss">
.official-tag-page {
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
// 搜索区域
.search-section {
background: #fff;
border-radius: 8px;
padding: 20px;
display: flex;
flex-wrap: wrap;
gap: 12px 0;
flex-shrink: 0;
.search-select {
width: 200px;
}
}
// 表格区域
.table-section {
flex: 1;
background: #fff;
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-wrapper {
flex: 1;
min-height: 0;
.color-cell {
display: flex;
align-items: center;
gap: 12px;
.color-block {
width: 100%;
height: 36px;
border-radius: 4px;
border: 1px solid #e5e7eb;
flex: 1;
}
.color-text {
color: #fff;
font-size: 14px;
font-weight: 500;
position: absolute;
left: 50%;
transform: translateX(-50%);
text-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
}
}
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding-top: 16px;
flex-shrink: 0;
}
// 对话框内的颜色显示
.color-value {
margin-left: 12px;
color: #606266;
font-family: monospace;
}
.btn-icon {
margin-right: 4px;
}
</style>
<script setup lang="ts">
import ContentSearchBar from '../components/ContentSearchBar.vue'
import { useAdminDeleteArticle } from '../components/useAdminDeleteArticle'
import { Upload } from '@element-plus/icons-vue'
import { usePageSearch, useResetData } from '@/hooks'
import { addOrUpdateColumn } from '@/api/backend'
import { getArticleList, updateArticleRecommendAndSort, getArticleDetail, addOrUpdatePractice } from '@/api'
import type { FormInstance, FormRules } from 'element-plus'
import type { BackendColumnListItemDto, AddOrUpdateColumnDto } from '@/api/backend'
import dayjs from 'dayjs'
import { ArticleTypeEnum, BooleanFlag } from '@/constants'
import type { ArticleItemDto } from '@/api/article/types'
import { buildPracticeTypeUpdates, toPracticeSelection } from '@/utils/practicePermission'
import { push } from 'notivue'
import { useMessageBox } from '@/hooks'
import ArticleContentDialog from '@/components/common/ArticleContentDialog/index.vue'
import DeptUserPicker from '@/components/common/DeptUserPicker/index.vue'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
const { prompt } = useMessageBox()
const contentDialogRef = useTemplateRef<InstanceType<typeof ArticleContentDialog>>('contentDialogRef')
const { loading, list, total, reset, goToPage, changePageSize, refresh, searchParams, search } =
usePageSearch(getArticleList, {
defaultParams: {
type: ArticleTypeEnum.PRACTICE,
},
})
const handleDelete = useAdminDeleteArticle('实践', () => refresh())
// 对话框
const dialogVisible = ref(false)
const dialogTitle = computed(() => (form.value.id ? '编辑' : '新增'))
const formRef = ref<FormInstance>()
// 表单数据
const [form] = useResetData<AddOrUpdateColumnDto>({
title: '',
color: '#000000',
id: undefined,
sort: 0,
type: 'column',
})
// 表单验证规则
const formRules: FormRules = {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
color: [{ required: true, message: '请选择颜色', trigger: 'change' }],
sort: [{ required: true, message: '请输入排序', trigger: 'blur' }],
}
// 是否推荐改变
const handleIsRecommendChange = async (val: BooleanFlag, row: ArticleItemDto) => {
await updateArticleRecommendAndSort({
articleId: row.id,
recommendSort: 0, // 默认传 0
isRecommend: val,
})
push.success('修改成功')
refresh()
}
// 编辑排序
const handleEditSort = async (row: ArticleItemDto) => {
const value = await prompt({
title: '请输入排序值',
type: 'primary',
inputPattern: /^\d+$/,
inputErrorMessage: '请输入数字',
})
await updateArticleRecommendAndSort({
articleId: row.id,
recommendSort: Number(value),
isRecommend: BooleanFlag.YES,
})
push.success('修改成功')
refresh()
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (form.value.id) {
await addOrUpdateColumn(form.value)
} else {
await addOrUpdateColumn(form.value)
}
push.success(form.value.id ? '编辑成功' : '新增成功')
dialogVisible.value = false
if (form.value.id) {
search()
} else {
refresh()
}
} catch (error) {
console.error('表单验证失败:', error)
}
}
const selectedRows = ref<BackendColumnListItemDto[]>([])
// 选择
const handleSelectionChange = (selection: BackendColumnListItemDto[]) => {
selectedRows.value = selection
}
// 查看内容:实践的内容是纯文本,不是富文本,是不是富文本由弹窗组件自己检测,这里只管传内容进去
const handleShowContent = (row: ArticleItemDto) => {
contentDialogRef.value?.open(row.content)
}
// 查看权限:管理员在后台也能改,点开先拉一次详情拿到完整数据(保存时要把其他字段原样带回去)
const permissionDialogVisible = ref(false)
const permissionSubmitting = ref(false)
const permissionDetail = ref<ArticleItemDto>()
const permissionDeptIds = ref<string[]>([])
const permissionUserIds = ref<string[]>([])
const permissionSelection = ref<DepartmentUserSelection[]>([])
const handleEditPermission = async (row: ArticleItemDto) => {
const { data } = await getArticleDetail(row.id)
permissionDetail.value = data
const items = toPracticeSelection(data.typeUpdates)
permissionDeptIds.value = items.filter((item) => item.valueType === 1).map((item) => item.valueId)
permissionUserIds.value = items.filter((item) => item.valueType === 2).map((item) => item.valueId)
permissionSelection.value = items
permissionDialogVisible.value = true
}
const handleSubmitPermission = async () => {
if (!permissionDetail.value) return
const typeUpdates = buildPracticeTypeUpdates(permissionSelection.value)
const detail = permissionDetail.value
permissionSubmitting.value = true
try {
await addOrUpdatePractice({
id: detail.id,
title: detail.title,
content: detail.content,
faceUrl: detail.faceUrl,
imgUrl: detail.imgUrl,
releaseStatus: detail.releaseStatus,
mainTagId: detail.tagIdList[0] ?? '',
tagList: detail.tagIdList.map((tagId, index) => ({ tagId, sort: index })),
sendType: detail.sendType,
sendTime: detail.sendTime,
deptId: detail.deptId,
...(typeUpdates.length ? { typeUpdates } : {}),
})
push.success('修改成功')
permissionDialogVisible.value = false
search()
} finally {
permissionSubmitting.value = false
}
}
</script>
<template>
<div class="official-tag-page">
<!-- 搜索栏 -->
<div class="search-section">
<ContentSearchBar v-model="searchParams" @search="refresh" @reset="reset" />
</div>
<!-- 表格区域 -->
<div class="table-section">
<!-- 表格 -->
<div class="table-wrapper">
<el-table
v-loading="loading"
:data="list"
height="100%"
@selection-change="handleSelectionChange"
>
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="content" label="内容" min-width="200">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="handleShowContent(row)">
点击查看内容
</el-link>
</template>
</el-table-column>
<el-table-column prop="releaseStatus" label="标签" min-width="200">
<template #default="{ row }">
<el-tag
v-for="tag in row.tagNameList"
:key="tag.tagId"
type="primary"
class="mr-2! mb-2!"
>
{{ tag }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="是否推荐" min-width="200">
<template #header>
<div class="flex items-center gap-2">
<span>是否推荐</span>
<el-tooltip content="推荐后前台将按照排序值从大到小展示" placement="top">
<el-icon><IEpInfoFilled /></el-icon>
</el-tooltip>
</div>
</template>
<template #default="{ row }">
<div class="flex items-center gap-2">
<el-switch
:model-value="row.isRecommend"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
@change="(val) => handleIsRecommendChange(val as BooleanFlag, row)"
/>
<el-link
v-if="row.isRecommend"
class="flex items-center gap-2"
type="primary"
@click="handleEditSort(row)"
:underline="false"
>排序值:{{ row.recommendSort }}</el-link
>
</div>
</template>
</el-table-column>
<el-table-column label="查看权限" min-width="120">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="handleEditPermission(row)">
设置权限
</el-link>
</template>
</el-table-column>
<el-table-column prop="showName" label="创建人" min-width="200" />
<el-table-column prop="createTime" label="创建时间" min-width="200">
<template #default="{ row }">
{{ dayjs(row.createTime * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="searchParams.current"
v-model:page-size="searchParams.size"
:total="total"
:page-sizes="[10, 20, 30]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="changePageSize"
@current-change="goToPage"
/>
</div>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="颜色" prop="color">
<el-color-picker v-model="form.color" />
<span class="color-value">{{ form.color }}</span>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="100" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">
<el-icon class="btn-icon"><Upload /></el-icon>
保存
</el-button>
</template>
</el-dialog>
<ArticleContentDialog ref="contentDialogRef" />
<!-- 查看权限:本人和管理员都能改,这里是后台管理员改的入口 -->
<el-dialog
v-model="permissionDialogVisible"
title="设置查看权限"
width="500px"
:close-on-click-modal="false"
>
<DeptUserPicker
class="w-full"
v-model:dept-value="permissionDeptIds"
v-model:user-value="permissionUserIds"
selection-mode="both"
@confirm="(items) => (permissionSelection = items)"
v-slot="{ echoText }"
>
<el-input
readonly
class="w-full cursor-pointer"
placeholder="不设置则所有人可见"
:model-value="echoText"
/>
</DeptUserPicker>
<template #footer>
<el-button @click="permissionDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="permissionSubmitting" @click="handleSubmitPermission">
保存
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.official-tag-page {
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
// 搜索区域
.search-section {
background: #fff;
border-radius: 8px;
padding: 20px;
display: flex;
flex-wrap: wrap;
gap: 12px 0;
flex-shrink: 0;
.search-select {
width: 200px;
}
}
// 表格区域
.table-section {
flex: 1;
background: #fff;
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-wrapper {
flex: 1;
min-height: 0;
.color-cell {
display: flex;
align-items: center;
gap: 12px;
.color-block {
width: 100%;
height: 36px;
border-radius: 4px;
border: 1px solid #e5e7eb;
flex: 1;
}
.color-text {
color: #fff;
font-size: 14px;
font-weight: 500;
position: absolute;
left: 50%;
transform: translateX(-50%);
text-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
}
}
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding-top: 16px;
flex-shrink: 0;
}
// 对话框内的颜色显示
.color-value {
margin-left: 12px;
color: #606266;
font-family: monospace;
}
.btn-icon {
margin-right: 4px;
}
</style>
<script setup lang="ts"> <script setup lang="ts">
import { Search, Upload } from '@element-plus/icons-vue' import ContentSearchBar from '../components/ContentSearchBar.vue'
import { useAdminDeleteArticle } from '../components/useAdminDeleteArticle'
import { Upload } from '@element-plus/icons-vue'
import { usePageSearch, useResetData } from '@/hooks' import { usePageSearch, useResetData } from '@/hooks'
import { addOrUpdateColumn } from '@/api/backend' import { addOrUpdateColumn } from '@/api/backend'
import { getArticleList, updateArticleRecommendAndSort } from '@/api' import { getArticleList, updateArticleRecommendAndSort, recallPush } from '@/api'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import type { BackendColumnListItemDto, AddOrUpdateColumnDto } from '@/api/backend' import type { BackendColumnListItemDto, AddOrUpdateColumnDto } from '@/api/backend'
import dayjs from 'dayjs' import dayjs from 'dayjs'
...@@ -10,8 +12,10 @@ import { ArticleTypeEnum, BooleanFlag } from '@/constants' ...@@ -10,8 +12,10 @@ import { ArticleTypeEnum, BooleanFlag } from '@/constants'
import type { ArticleItemDto } from '@/api/article/types' import type { ArticleItemDto } from '@/api/article/types'
import { push } from 'notivue' import { push } from 'notivue'
import { useMessageBox } from '@/hooks' import { useMessageBox } from '@/hooks'
import ArticleContentDialog from '@/components/common/ArticleContentDialog/index.vue'
const { prompt } = useMessageBox() const { prompt, confirm } = useMessageBox()
const contentDialogRef = useTemplateRef<InstanceType<typeof ArticleContentDialog>>('contentDialogRef')
const { loading, list, total, reset, goToPage, changePageSize, refresh, searchParams, search } = const { loading, list, total, reset, goToPage, changePageSize, refresh, searchParams, search } =
usePageSearch(getArticleList, { usePageSearch(getArticleList, {
defaultParams: { defaultParams: {
...@@ -19,6 +23,8 @@ const { loading, list, total, reset, goToPage, changePageSize, refresh, searchPa ...@@ -19,6 +23,8 @@ const { loading, list, total, reset, goToPage, changePageSize, refresh, searchPa
}, },
}) })
const handleDelete = useAdminDeleteArticle('问吧', () => refresh())
// 对话框 // 对话框
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogTitle = computed(() => (form.value.id ? '编辑' : '新增')) const dialogTitle = computed(() => (form.value.id ? '编辑' : '新增'))
...@@ -98,19 +104,52 @@ const selectedRows = ref<BackendColumnListItemDto[]>([]) ...@@ -98,19 +104,52 @@ const selectedRows = ref<BackendColumnListItemDto[]>([])
const handleSelectionChange = (selection: BackendColumnListItemDto[]) => { const handleSelectionChange = (selection: BackendColumnListItemDto[]) => {
selectedRows.value = selection selectedRows.value = selection
} }
// 查看内容:是不是富文本由弹窗组件自己检测,这里只管传内容进去
const handleShowContent = (row: ArticleItemDto) => {
contentDialogRef.value?.open(row.content)
}
// 推送状态文案:0 文章未设置推送 / 1 已推送 / 2 定时待推送 / 3 已全部撤回
const pushStatusTextMap: Record<number, string> = {
0: '文章未设置推送',
1: '已推送',
2: '定时待推送',
3: '已全部撤回',
}
const pushStatusText = (status: number) => pushStatusTextMap[status] ?? '未知'
// 撤回是有时间限制的(推送后 24 小时内才能撤回),超时后按钮会自动隐藏,这里加个提示说明规则
const PUSH_RECALL_TOOLTIP =
'已推送的内容仅支持在推送后 24 小时内撤回,超过 24 小时将无法撤回;定时待推送的可以随时取消'
// 撤回推送:能不能点由后端算好的 pushInfo.recallable 决定,定时待推送时文案改成"取消定时推送"
const handleRecallPush = async (row: ArticleItemDto) => {
const isScheduled = row.pushInfo?.pushStatus === 2
await confirm({
title: '提示',
message: isScheduled
? '确定要取消这次定时推送吗?'
: '确定要撤回这条推送吗?已经收到通知的用户不会撤回消息。',
type: 'warning',
})
// 后端撤回失败时不抛异常(部分批次成功的状态要保留),HTTP 还是 200,
// 所以这里必须看 failCount,否则会出现"提示撤回成功、状态却没变"
const { data } = await recallPush(row.id)
if (data?.failCount) {
push.warning(data.message || '部分批次撤回失败,可稍后重试')
} else {
push.success(data?.message || (isScheduled ? '取消定时推送成功' : '撤回成功'))
}
refresh()
}
</script> </script>
<template> <template>
<div class="official-tag-page"> <div class="official-tag-page">
<!-- 搜索栏 --> <!-- 搜索栏 -->
<div class="search-section"> <div class="search-section">
<el-input <ContentSearchBar v-model="searchParams" @search="refresh" @reset="reset" />
v-model="searchParams.title"
placeholder="请输入标题"
class="w-200px mr-12px"
></el-input>
<el-button type="primary" :icon="Search" @click="refresh">搜索</el-button>
<el-button @click="reset">重置</el-button>
</div> </div>
<!-- 表格区域 --> <!-- 表格区域 -->
...@@ -124,20 +163,12 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => { ...@@ -124,20 +163,12 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => {
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
> >
<el-table-column prop="title" label="标题" min-width="200" /> <el-table-column prop="title" label="标题" min-width="200" />
<el-table-column <el-table-column prop="content" label="内容" min-width="200">
prop="content" <template #default="{ row }">
label="内容"
min-width="200"
:show-overflow-tooltip="{
placement: 'top',
popperClass: 'max-w-300',
}"
>
<!-- <template #default="{ row }">
<el-link type="primary" :underline="false" @click="handleShowContent(row)"> <el-link type="primary" :underline="false" @click="handleShowContent(row)">
内容为富文本,点击查看 内容为富文本,点击查看
</el-link> </el-link>
</template> --> </template>
</el-table-column> </el-table-column>
<el-table-column prop="releaseStatus" label="标签" min-width="200"> <el-table-column prop="releaseStatus" label="标签" min-width="200">
<template #default="{ row }"> <template #default="{ row }">
...@@ -188,6 +219,38 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => { ...@@ -188,6 +219,38 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => {
{{ dayjs(row.createTime * 1000).format('YYYY-MM-DD HH:mm:ss') }} {{ dayjs(row.createTime * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</template> </template>
</el-table-column> </el-table-column>
<!-- 只有官方账号推送过的文章才会带 pushInfo -->
<el-table-column label="推送状态" min-width="220">
<template #header>
<div class="flex items-center gap-2">
<span>推送状态</span>
<el-tooltip :content="PUSH_RECALL_TOOLTIP" placement="top">
<el-icon><IEpInfoFilled /></el-icon>
</el-tooltip>
</div>
</template>
<template #default="{ row }">
<div v-if="row.pushInfo" class="flex items-center gap-2">
<span>{{ pushStatusText(row.pushInfo.pushStatus) }}</span>
<el-button
v-if="row.pushInfo.recallable"
type="danger"
link
@click="handleRecallPush(row)"
>
{{ row.pushInfo.pushStatus === 2 ? '取消定时推送' : '撤回推送' }}
</el-button>
</div>
<span v-else>{{ pushStatusText(0) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table> </el-table>
</div> </div>
...@@ -234,6 +297,8 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => { ...@@ -234,6 +297,8 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => {
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
<ArticleContentDialog ref="contentDialogRef" />
</div> </div>
</template> </template>
......
<template>
<el-dialog v-model="visible" title="修改视频分类" width="500px" :close-on-click-modal="false">
<el-select
v-model="selectedIds"
multiple
:multiple-limit="5"
collapse-tags
collapse-tags-tooltip
placeholder="最多选择5个分类,不选则不属于任何分类"
class="w-full"
>
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
/**
* 后台视频列表点击「视频分类」列,修改这条视频所属的分类。
*
* 下拉选项统一只展示公开分类(跟前台下拉共用 listPublic):如果在这里能选到不公开的分类,
* 那条视频挂着的分类在前台分类栏里却看不到对应入口,会很confusing,产品那边确认过统一改成
* 只看公开的。但这条视频原本可能已经挂着被设为不公开的分类——那些不在 listPublic 里,直接用
* listPublic 当选项会导致它们的名字显示不出来(变成裸 id)。这里把 row 自带的
* categoryIdList/categoryNameList 里、不在公开列表里的那些也合并进选项,只是为了回显,
* 不代表用户可以新选一个不公开的分类。
*/
import { listVideoCategoryPublic, bindVideoCategoryToArticle } from '@/api'
import type { VideoCategoryItemDto, ArticleItemDto } from '@/api'
import { push } from 'notivue'
const emit = defineEmits<{ changed: [] }>()
const visible = ref(false)
const submitting = ref(false)
const options = ref<VideoCategoryItemDto[]>([])
const selectedIds = ref<number[]>([])
const articleId = ref<number>()
const open = async (row: ArticleItemDto) => {
articleId.value = row.id
selectedIds.value = row.categoryIdList ? [...row.categoryIdList] : []
const { data } = await listVideoCategoryPublic()
const publicOptions = data
const hiddenBound = (row.categoryIdList || [])
.map((id, index) => ({ id, name: row.categoryNameList?.[index] }))
.filter(
(item): item is { id: number; name: string } =>
!!item.name && !publicOptions.some((option) => option.id === item.id),
)
.map((item) => ({ id: item.id, name: item.name, sort: 0, isPublic: 0 }) as VideoCategoryItemDto)
options.value = [...publicOptions, ...hiddenBound]
visible.value = true
}
const handleSubmit = async () => {
if (articleId.value === undefined) return
submitting.value = true
try {
await bindVideoCategoryToArticle(articleId.value, selectedIds.value)
push.success('修改成功')
visible.value = false
emit('changed')
} finally {
submitting.value = false
}
}
defineExpose({ open })
</script>
<template>
<el-dialog v-model="visible" title="视频分类管理" width="600px" :close-on-click-modal="false">
<div class="mb-3 text-13px text-gray-500">排前的分类优先展示,公开后用户才可选择加入该分类</div>
<div class="flex gap-2 mb-3">
<el-input
v-model="searchName"
placeholder="搜索分类名称"
clearable
@keyup.enter="fetchList"
@clear="fetchList"
/>
<el-button type="primary" @click="fetchList">搜索</el-button>
</div>
<div class="flex gap-2 mb-3">
<el-input
v-model="newName"
placeholder="输入分类名称,回车新增"
maxlength="50"
@keyup.enter="handleAdd"
/>
<el-button type="primary" :loading="adding" @click="handleAdd">新增分类</el-button>
</div>
<div v-loading="loading" class="max-h-400px overflow-y-auto border border-gray-100 rounded-lg">
<div
v-for="(item, index) in list"
:key="item.id"
class="flex items-center gap-2 px-3 py-2 border-b border-gray-100 last:border-b-0 bg-white"
:class="{ 'opacity-40': dragIndex === index }"
draggable="true"
@dragstart="handleDragStart(index)"
@dragover.prevent
@drop="handleDrop(index)"
@dragend="dragIndex = undefined"
>
<span class="cursor-move select-none text-gray-400 px-1" title="拖动排序">⠿⠿</span>
<el-input
v-if="editingId === item.id"
v-model="editingName"
size="small"
class="flex-1"
maxlength="50"
@keyup.enter="handleConfirmRename(item)"
@blur="handleConfirmRename(item)"
/>
<span v-else class="flex-1 truncate">{{ item.name }}</span>
<el-button
v-if="editingId !== item.id"
link
type="primary"
@click="handleStartRename(item)"
>
改名
</el-button>
<el-switch
:model-value="item.isPublic"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
active-text="公开"
@change="(val) => handleChangePublic(item, val as BooleanFlag)"
/>
<el-button link type="danger" @click="handleDelete(item)">删除</el-button>
</div>
<el-empty v-if="!loading && !list.length" description="暂无分类" />
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
/**
* 后台「视频分类管理」弹框:增删改查 + 拖动排序 + 公开开关。
*
* 拖动排序用原生 HTML5 drag & drop(项目里没有引入拖拽库),drop 时把整份
* 拖完序的列表按 id 顺序传给 updateVideoCategorySort,不是只传被拖的那一条。
*/
import {
listVideoCategoryForManage,
addOrUpdateVideoCategory,
deleteVideoCategory,
changeVideoCategoryPublic,
updateVideoCategorySort,
} from '@/api'
import type { VideoCategoryItemDto } from '@/api'
import { BooleanFlag } from '@/constants'
import { push } from 'notivue'
import { useMessageBox } from '@/hooks'
const { confirm } = useMessageBox()
// 分类的增/删/改/排序/公开状态发生变化后通知外层:视频列表里缓存的分类名字可能要跟着刷新
const emit = defineEmits<{ changed: [] }>()
const visible = ref(false)
const loading = ref(false)
const list = ref<VideoCategoryItemDto[]>([])
const searchName = ref('')
const fetchList = async () => {
loading.value = true
try {
const { data } = await listVideoCategoryForManage(searchName.value || undefined)
list.value = data
} finally {
loading.value = false
}
}
const open = () => {
searchName.value = ''
newName.value = ''
editingId.value = undefined
visible.value = true
fetchList()
}
// 新增:新增的分类后端自动排在最后,前端不用传 sort
const newName = ref('')
const adding = ref(false)
const handleAdd = async () => {
const name = newName.value.trim()
if (!name) return
adding.value = true
try {
await addOrUpdateVideoCategory({ name })
newName.value = ''
push.success('新增成功')
await fetchList()
emit('changed')
} finally {
adding.value = false
}
}
// 改名
const editingId = ref<number>()
const editingName = ref('')
const handleStartRename = (item: VideoCategoryItemDto) => {
editingId.value = item.id
editingName.value = item.name
}
const handleConfirmRename = async (item: VideoCategoryItemDto) => {
// enter 和 blur 都会触发这个函数,第一次进来就把 editingId 清空,
// 后触发的那次会因为 id 对不上直接 return,避免重复提交
if (editingId.value !== item.id) return
editingId.value = undefined
const name = editingName.value.trim()
if (!name || name === item.name) return
await addOrUpdateVideoCategory({ id: item.id, name })
push.success('修改成功')
await fetchList()
emit('changed')
}
// 删除:同时移除该分类下所有视频的关联,视频本身不受影响
const handleDelete = async (item: VideoCategoryItemDto) => {
await confirm({
title: '提示',
message: `确定要删除分类"${item.name}"吗?该分类下的视频不受影响,仅会解除关联。`,
type: 'danger',
})
await deleteVideoCategory(item.id)
push.success('删除成功')
await fetchList()
emit('changed')
}
// 公开开关:不公开只是前台不展示这个分类入口,分类下的视频照常可见可搜
const handleChangePublic = async (item: VideoCategoryItemDto, val: BooleanFlag) => {
await changeVideoCategoryPublic(item.id, val)
item.isPublic = val
push.success('修改成功')
emit('changed')
}
// 拖动排序
const dragIndex = ref<number>()
const handleDragStart = (index: number) => {
dragIndex.value = index
}
const handleDrop = async (targetIndex: number) => {
const from = dragIndex.value
dragIndex.value = undefined
if (from === undefined || from === targetIndex) return
const [moved] = list.value.splice(from, 1)
if (!moved) return
list.value.splice(targetIndex, 0, moved)
await updateVideoCategorySort(list.value.map((item) => item.id))
emit('changed')
}
defineExpose({ open })
</script>
<script setup lang="ts"> <script setup lang="ts">
import { Search, Upload } from '@element-plus/icons-vue' import ContentSearchBar from '../components/ContentSearchBar.vue'
import { useAdminDeleteArticle } from '../components/useAdminDeleteArticle'
import { Upload } from '@element-plus/icons-vue'
import { usePageSearch, useResetData } from '@/hooks' import { usePageSearch, useResetData } from '@/hooks'
import { addOrUpdateColumn } from '@/api/backend' import { addOrUpdateColumn } from '@/api/backend'
import { getArticleList, updateArticleRecommendAndSort, updateArticleShowLatest } from '@/api' import {
getArticleList,
updateArticleRecommendAndSort,
updateArticleShowLatest,
recallPush,
} from '@/api'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import type { BackendColumnListItemDto, AddOrUpdateColumnDto } from '@/api/backend' import type { BackendColumnListItemDto, AddOrUpdateColumnDto } from '@/api/backend'
import dayjs from 'dayjs' import dayjs from 'dayjs'
...@@ -10,7 +17,14 @@ import { ArticleTypeEnum, BooleanFlag } from '@/constants' ...@@ -10,7 +17,14 @@ import { ArticleTypeEnum, BooleanFlag } from '@/constants'
import type { ArticleItemDto } from '@/api/article/types' import type { ArticleItemDto } from '@/api/article/types'
import { push } from 'notivue' import { push } from 'notivue'
import { useMessageBox } from '@/hooks' import { useMessageBox } from '@/hooks'
const { prompt } = useMessageBox() import CategoryManageDialog from './CategoryManageDialog.vue'
import BindCategoryDialog from './BindCategoryDialog.vue'
const { prompt, confirm } = useMessageBox()
const categoryManageDialogRef =
useTemplateRef<InstanceType<typeof CategoryManageDialog>>('categoryManageDialogRef')
const bindCategoryDialogRef =
useTemplateRef<InstanceType<typeof BindCategoryDialog>>('bindCategoryDialogRef')
const { loading, list, total, reset, goToPage, changePageSize, refresh, searchParams, search } = const { loading, list, total, reset, goToPage, changePageSize, refresh, searchParams, search } =
usePageSearch(getArticleList, { usePageSearch(getArticleList, {
...@@ -19,6 +33,8 @@ const { loading, list, total, reset, goToPage, changePageSize, refresh, searchPa ...@@ -19,6 +33,8 @@ const { loading, list, total, reset, goToPage, changePageSize, refresh, searchPa
}, },
}) })
const handleDelete = useAdminDeleteArticle('视频', () => refresh())
// 对话框 // 对话框
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogTitle = computed(() => (form.value.id ? '编辑' : '新增')) const dialogTitle = computed(() => (form.value.id ? '编辑' : '新增'))
...@@ -107,19 +123,54 @@ const selectedRows = ref<BackendColumnListItemDto[]>([]) ...@@ -107,19 +123,54 @@ const selectedRows = ref<BackendColumnListItemDto[]>([])
const handleSelectionChange = (selection: BackendColumnListItemDto[]) => { const handleSelectionChange = (selection: BackendColumnListItemDto[]) => {
selectedRows.value = selection selectedRows.value = selection
} }
// 推送状态文案:0 文章未设置推送 / 1 已推送 / 2 定时待推送 / 3 已全部撤回
const pushStatusTextMap: Record<number, string> = {
0: '文章未设置推送',
1: '已推送',
2: '定时待推送',
3: '已全部撤回',
}
const pushStatusText = (status: number) => pushStatusTextMap[status] ?? '未知'
// 撤回是有时间限制的(推送后 24 小时内才能撤回),超时后按钮会自动隐藏,这里加个提示说明规则
const PUSH_RECALL_TOOLTIP =
'已推送的内容仅支持在推送后 24 小时内撤回,超过 24 小时将无法撤回;定时待推送的可以随时取消'
// 撤回推送:能不能点由后端算好的 pushInfo.recallable 决定,定时待推送时文案改成"取消定时推送"
const handleRecallPush = async (row: ArticleItemDto) => {
const isScheduled = row.pushInfo?.pushStatus === 2
await confirm({
title: '提示',
message: isScheduled
? '确定要取消这次定时推送吗?'
: '确定要撤回这条推送吗?已经收到通知的用户不会撤回消息。',
type: 'warning',
})
// 后端撤回失败时不抛异常(部分批次成功的状态要保留),HTTP 还是 200,
// 所以这里必须看 failCount,否则会出现"提示撤回成功、状态却没变"
const { data } = await recallPush(row.id)
if (data?.failCount) {
push.warning(data.message || '部分批次撤回失败,可稍后重试')
} else {
push.success(data?.message || (isScheduled ? '取消定时推送成功' : '撤回成功'))
}
refresh()
}
// 点击「视频分类」列,改这条视频的分类
const handleEditCategory = (row: ArticleItemDto) => {
bindCategoryDialogRef.value?.open(row)
}
</script> </script>
<template> <template>
<div class="official-tag-page"> <div class="official-tag-page">
<!-- 搜索栏 --> <!-- 搜索栏 -->
<div class="search-section"> <div class="search-section">
<el-input <ContentSearchBar v-model="searchParams" @search="refresh" @reset="reset">
v-model="searchParams.title" <el-button @click="categoryManageDialogRef?.open()">视频分类管理</el-button>
placeholder="请输入标题" </ContentSearchBar>
class="w-200px mr-12px"
></el-input>
<el-button type="primary" :icon="Search" @click="refresh">搜索</el-button>
<el-button @click="reset">重置</el-button>
</div> </div>
<!-- 表格区域 --> <!-- 表格区域 -->
...@@ -164,6 +215,28 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => { ...@@ -164,6 +215,28 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => {
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="视频分类" min-width="180">
<template #default="{ row }">
<el-tooltip
v-if="row.categoryNameList?.length"
:content="row.categoryNameList.join('、')"
placement="top"
>
<el-link
type="primary"
:underline="false"
class="truncate max-w-full block"
@click="handleEditCategory(row)"
>
{{ row.categoryNameList.join('、') }}
</el-link>
</el-tooltip>
<el-link v-else type="primary" :underline="false" @click="handleEditCategory(row)">
设置分类
</el-link>
</template>
</el-table-column>
<el-table-column prop="isShowLatest" label="是否在最新出现" min-width="160"> <el-table-column prop="isShowLatest" label="是否在最新出现" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
<el-switch <el-switch
...@@ -211,6 +284,38 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => { ...@@ -211,6 +284,38 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => {
{{ dayjs(row.createTime * 1000).format('YYYY-MM-DD HH:mm:ss') }} {{ dayjs(row.createTime * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</template> </template>
</el-table-column> </el-table-column>
<!-- 只有官方账号推送过的文章才会带 pushInfo -->
<el-table-column label="推送状态" min-width="220">
<template #header>
<div class="flex items-center gap-2">
<span>推送状态</span>
<el-tooltip :content="PUSH_RECALL_TOOLTIP" placement="top">
<el-icon><IEpInfoFilled /></el-icon>
</el-tooltip>
</div>
</template>
<template #default="{ row }">
<div v-if="row.pushInfo" class="flex items-center gap-2">
<span>{{ pushStatusText(row.pushInfo.pushStatus) }}</span>
<el-button
v-if="row.pushInfo.recallable"
type="danger"
link
@click="handleRecallPush(row)"
>
{{ row.pushInfo.pushStatus === 2 ? '取消定时推送' : '撤回推送' }}
</el-button>
</div>
<span v-else>{{ pushStatusText(0) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table> </el-table>
</div> </div>
...@@ -257,6 +362,9 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => { ...@@ -257,6 +362,9 @@ const handleSelectionChange = (selection: BackendColumnListItemDto[]) => {
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
<CategoryManageDialog ref="categoryManageDialogRef" @changed="refresh" />
<BindCategoryDialog ref="bindCategoryDialogRef" @changed="refresh" />
</div> </div>
</template> </template>
......
...@@ -33,6 +33,8 @@ export default defineComponent(() => { ...@@ -33,6 +33,8 @@ export default defineComponent(() => {
{ path: '/backend/contentsMenu/interviewManage', title: '专访管理' }, { path: '/backend/contentsMenu/interviewManage', title: '专访管理' },
{ path: '/backend/contentsMenu/videoManage', title: '视频管理' }, { path: '/backend/contentsMenu/videoManage', title: '视频管理' },
{ path: '/backend/contentsMenu/questionManage', title: '问吧管理' }, { path: '/backend/contentsMenu/questionManage', title: '问吧管理' },
{ path: '/backend/contentsMenu/practiceManage', title: '实践管理' },
{ path: '/backend/contentsMenu/postManage', title: '帖子管理' },
], ],
}, },
// 栏目管理 // 栏目管理
......
...@@ -94,9 +94,10 @@ ...@@ -94,9 +94,10 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" fixed="right"> <el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button> <el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="primary" link @click="handleCopy(row)">复制</el-button>
<el-button type="primary" link @click="handleAuctionRecord(row)">竞拍记录</el-button> <el-button type="primary" link @click="handleAuctionRecord(row)">竞拍记录</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button> <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template> </template>
...@@ -339,6 +340,18 @@ const handleEdit = (row: BackendAddOrUpdateAuctionItemDto) => { ...@@ -339,6 +340,18 @@ const handleEdit = (row: BackendAddOrUpdateAuctionItemDto) => {
dialogVisible.value = true dialogVisible.value = true
} }
// 复制:相当于快捷新增,把这一行的数据填充到"新增"弹框里,不带 id/竞拍状态(这两个只有编辑已有数据时才有意义)
const handleCopy = (row: BackendAddOrUpdateAuctionItemDto) => {
resetForm()
form.value = {
...row,
id: undefined,
status: undefined,
}
dialogVisible.value = true
push.info('已将该竞拍的信息复制到新增弹框,请检查后再提交')
}
// 删除 // 删除
const handleDelete = async (row: BackendAuctionListItemDto) => { const handleDelete = async (row: BackendAuctionListItemDto) => {
const confirmDelete = row.currentPrice > 0 const confirmDelete = row.currentPrice > 0
......
...@@ -26,13 +26,23 @@ ...@@ -26,13 +26,23 @@
<el-icon class="mr-2"><IEpSetting /></el-icon> <el-icon class="mr-2"><IEpSetting /></el-icon>
抽奖配置 抽奖配置
</el-button> </el-button>
<el-button type="danger" @click="handleBatchDelete" :disabled="selectedRows.length === 0">
批量删除
</el-button>
</div> </div>
<!-- 表格区域 --> <!-- 表格区域 -->
<div class="table-section"> <div class="table-section">
<!-- 表格 --> <!-- 表格 -->
<div class="table-wrapper"> <div class="table-wrapper">
<el-table v-loading="loading" :data="list" height="100%"> <el-table
v-loading="loading"
:data="list"
height="100%"
@selection-change="handleSelectionChange"
>
<!-- 本期奖品不能编辑/删除,选择框也一并禁用,避免勾进批量删除里 -->
<el-table-column type="selection" width="55" :selectable="(row) => !row.isCurrent" />
<el-table-column prop="name" label="名称" /> <el-table-column prop="name" label="名称" />
<el-table-column prop="imageUrl" label="图片"> <el-table-column prop="imageUrl" label="图片">
<template #default="{ row }"> <template #default="{ row }">
...@@ -224,6 +234,25 @@ const lotteryConfigRef = ref<InstanceType<typeof LotteryConfig>>() ...@@ -224,6 +234,25 @@ const lotteryConfigRef = ref<InstanceType<typeof LotteryConfig>>()
const handleLotteryConfig = () => { const handleLotteryConfig = () => {
lotteryConfigRef.value?.open() lotteryConfigRef.value?.open()
} }
const selectedRows = ref<BackendAddOrUpdateLotteryPrizeDto[]>([])
// 选择
const handleSelectionChange = (selection: BackendAddOrUpdateLotteryPrizeDto[]) => {
selectedRows.value = selection
}
// 批量删除:deleteLotteryPrize 本来就接收 idList,不用新加接口
const handleBatchDelete = async () => {
await confirm({
title: '提示',
message: `确定要删除选中的 ${selectedRows.value.length} 个奖品吗?`,
type: 'danger',
})
await deleteLotteryPrize(selectedRows.value.map((item) => item.id!))
refresh()
push.success('删除成功')
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
......
...@@ -45,6 +45,12 @@ ...@@ -45,6 +45,12 @@
<el-icon><IEpPlus /></el-icon> <el-icon><IEpPlus /></el-icon>
新增 新增
</el-button> </el-button>
<el-button type="primary" @click="handleBatchEnable" :disabled="selectedRows.length === 0">
批量修改展示
</el-button>
<el-button type="danger" @click="handleBatchDelete" :disabled="selectedRows.length === 0">
批量删除
</el-button>
</div> </div>
<!-- 表格区域 --> <!-- 表格区域 -->
...@@ -185,9 +191,15 @@ ...@@ -185,9 +191,15 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="tsx">
import { usePageSearch, useResetData } from '@/hooks' import { usePageSearch, useResetData } from '@/hooks'
import { getShopItemList, addOrUpdateShopItem, deleteShopItem } from '@/api/backend' import {
getShopItemList,
addOrUpdateShopItem,
deleteShopItem,
changeProductEnable,
batchDeleteProduct,
} from '@/api/backend'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import type { BackendColumnListItemDto } from '@/api/backend' import type { BackendColumnListItemDto } from '@/api/backend'
import dayjs from 'dayjs' import dayjs from 'dayjs'
...@@ -302,6 +314,53 @@ const handleEnableChange = async (row: BackendColumnListItemDto) => { ...@@ -302,6 +314,53 @@ const handleEnableChange = async (row: BackendColumnListItemDto) => {
push.success('修改成功') push.success('修改成功')
refresh() refresh()
} }
// 批量修改是否展示在前台:商城这边的展示字段叫 enable,接口是 changeProductEnable
const handleBatchEnable = async () => {
const enable = ref<BooleanFlag>(BooleanFlag.YES)
await ElMessageBox({
title: '是否展示在前台',
message: () => (
<el-radio-group v-model={enable.value}>
<el-radio value={BooleanFlag.YES}>是</el-radio>
<el-radio value={BooleanFlag.NO}>否</el-radio>
</el-radio-group>
),
showCancelButton: true,
async beforeClose(action, instance, done) {
if (action === 'confirm') {
instance.confirmButtonLoading = true
try {
await changeProductEnable({
idList: selectedRows.value.map((item) => item.id),
enable: enable.value,
})
push.success('批量修改展示成功')
refresh()
done()
} catch (error) {
console.error(error)
} finally {
instance.confirmButtonLoading = false
}
} else {
done()
}
},
})
}
// 批量删除
const handleBatchDelete = async () => {
await confirm({
title: '提示',
message: `确定要删除选中的 ${selectedRows.value.length} 个商品吗?`,
type: 'danger',
})
await batchDeleteProduct(selectedRows.value.map((item) => item.id))
refresh()
push.success('删除成功')
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
......
...@@ -36,12 +36,24 @@ ...@@ -36,12 +36,24 @@
<el-icon class="mr-2"><IEpInfoFilled /></el-icon> <el-icon class="mr-2"><IEpInfoFilled /></el-icon>
配置提示 配置提示
</el-button> </el-button>
<el-button type="primary" @click="handleBatchEnable" :disabled="selectedRows.length === 0">
批量修改启用
</el-button>
<el-button type="danger" @click="handleBatchDelete" :disabled="selectedRows.length === 0">
批量删除
</el-button>
</div> </div>
<!-- 表格区域 --> <!-- 表格区域 -->
<div class="table-section"> <div class="table-section">
<div class="table-wrapper"> <div class="table-wrapper">
<el-table v-loading="loading" :data="list" height="100%"> <el-table
v-loading="loading"
:data="list"
height="100%"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="名称" /> <el-table-column prop="name" label="名称" />
<el-table-column prop="imageUrl" label="图片"> <el-table-column prop="imageUrl" label="图片">
<template #default="{ row }"> <template #default="{ row }">
...@@ -202,7 +214,12 @@ ...@@ -202,7 +214,12 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { usePageSearch, useResetData } from '@/hooks' import { usePageSearch, useResetData } from '@/hooks'
import { getWheelPrizeList, addOrUpdateWheelPrize, deleteWheelPrize } from '@/api/backend' import {
getWheelPrizeList,
addOrUpdateWheelPrize,
deleteWheelPrize,
batchUpdateWheelPrizeStatus,
} from '@/api/backend'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import type { BackendWheelPrizeListItemDto, BackendAddOrUpdateWheelPrizeDto } from '@/api/backend' import type { BackendWheelPrizeListItemDto, BackendAddOrUpdateWheelPrizeDto } from '@/api/backend'
import UploadFile from '@/components/common/UploadFile/index.vue' import UploadFile from '@/components/common/UploadFile/index.vue'
...@@ -305,6 +322,61 @@ const wheelConfigRef = ref<InstanceType<typeof WheelConfig>>() ...@@ -305,6 +322,61 @@ const wheelConfigRef = ref<InstanceType<typeof WheelConfig>>()
const handleWheelConfig = () => { const handleWheelConfig = () => {
wheelConfigRef.value?.open() wheelConfigRef.value?.open()
} }
const selectedRows = ref<BackendWheelPrizeListItemDto[]>([])
// 选择
const handleSelectionChange = (selection: BackendWheelPrizeListItemDto[]) => {
selectedRows.value = selection
}
// 批量删除:deleteWheelPrize 本来就接收 idList,不用新加接口
const handleBatchDelete = async () => {
await confirm({
title: '提示',
message: `确定要删除选中的 ${selectedRows.value.length} 个奖品吗?`,
type: 'danger',
})
await deleteWheelPrize(selectedRows.value.map((item) => item.id))
refresh()
push.success('删除成功')
}
// 批量修改启用:是「设置」不是「切换」,传 1 就全部启用、传 0 就全部禁用。
// 启用会一次性校验总概率,超过 100% 整批失败,后端错误信息由请求拦截器自动弹出来,这里不用额外处理。
const handleBatchEnable = async () => {
const isEnabled = ref<BooleanFlag>(BooleanFlag.YES)
await ElMessageBox({
title: '是否启用',
message: () => (
<el-radio-group v-model={isEnabled.value}>
<el-radio value={BooleanFlag.YES}>是</el-radio>
<el-radio value={BooleanFlag.NO}>否</el-radio>
</el-radio-group>
),
showCancelButton: true,
async beforeClose(action, instance, done) {
if (action === 'confirm') {
instance.confirmButtonLoading = true
try {
await batchUpdateWheelPrizeStatus({
idList: selectedRows.value.map((item) => item.id),
isEnabled: isEnabled.value,
})
push.success('批量修改启用成功')
refresh()
done()
} catch (error) {
console.error(error)
} finally {
instance.confirmButtonLoading = false
}
} else {
done()
}
},
})
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
......
...@@ -45,26 +45,30 @@ ...@@ -45,26 +45,30 @@
</h2> </h2>
<div class="mb-3"> <div class="mb-3">
<p <!-- 虽然带了html-content的class 但是还是重置了字体样式 为了让列表页的ui看起来好一点 -->
<!-- 评论摘要只展示内容,不启用图片预览。 -->
<div
v-if="item.cultureCommentListVo?.replyName || item.cultureCommentListVo?.hiddenName"
:ref="(e) => (contentRefList[index] = e as HTMLElement)" :ref="(e) => (contentRefList[index] = e as HTMLElement)"
class="text-gray-600 text-sm sm:text-base leading-relaxed transition-all duration-300" class="html-content transition-all duration-300 text-gray-600 text-sm sm:text-base leading-relaxed"
:class="{ 'line-clamp-3': !item.isExpand }" :class="{ 'line-clamp-3': !item.isExpand }"
@click="openNewPage(`/questionDetail/${item.id}`)" @click="openNewPage(`/questionDetail/${item.id}`)"
> >
<template
v-if="
item.cultureCommentListVo?.replyName || item.cultureCommentListVo?.hiddenName
"
>
{{ {{
item.cultureCommentListVo?.replyName || item.cultureCommentListVo?.hiddenName item.cultureCommentListVo?.replyName || item.cultureCommentListVo?.hiddenName
}}: }}:
<span v-html="parseEmoji(item.cultureCommentListVo?.content)"></span> <span v-html="parseEmoji(item.cultureCommentListVo?.content || '')"></span>
</template> </div>
<template v-else> <!-- 正文的样式、HTML 内容和预览指令统一放在同一个 DOM 上。 -->
<span v-html="parseEmoji(item.content)"></span> <div
</template> v-else
</p> :ref="(e) => (contentRefList[index] = e as HTMLElement)"
class="html-content transition-all duration-300 text-gray-600 text-sm sm:text-base leading-relaxed"
:class="{ 'line-clamp-3': !item.isExpand }"
v-html="parseEmoji(item.content)"
v-image-preview
@click="openNewPage(`/questionDetail/${item.id}`)"
></div>
<div class="flex justify-end" v-if="isOverThreeLine(index)"> <div class="flex justify-end" v-if="isOverThreeLine(index)">
<el-button @click.stop="handleExpand(item)" type="primary" text size="small"> <el-button @click.stop="handleExpand(item)" type="primary" text size="small">
{{ item.isExpand ? '收起' : '阅读全文' }} {{ item.isExpand ? '收起' : '阅读全文' }}
...@@ -201,13 +205,21 @@ ...@@ -201,13 +205,21 @@
</template> </template>
</div> </div>
<CommentDialog ref="commentDialogRef" @commentSuccess="search" /> <CommentDialog ref="commentDialogRef" @commentSuccess="search" />
<el-image-viewer
v-if="showPreview"
:url-list="srcList"
show-progress
:teleported="true"
:initial-index="currentPreviewIndex"
@close="showPreview = false"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Tabs from '@/components/common/Tabs' import Tabs from '@/components/common/Tabs'
import Comment from '@/components/common/Comment/index.vue' import Comment from '@/components/common/Comment/index.vue'
import { useScrollTop, usePageSearch } from '@/hooks' import { useImagePreview, usePageSearch, useScrollTop } from '@/hooks'
import { TABS_REF_KEY } from '@/constants' import { TABS_REF_KEY } from '@/constants'
import PublishBox from '@/components/common/PublishBox/index.vue' import PublishBox from '@/components/common/PublishBox/index.vue'
import { ArticleTypeEnum } from '@/constants' import { ArticleTypeEnum } from '@/constants'
...@@ -228,6 +240,7 @@ const { shouldShowAskTabTour } = storeToRefs(tourStore) ...@@ -228,6 +240,7 @@ const { shouldShowAskTabTour } = storeToRefs(tourStore)
const router = useRouter() const router = useRouter()
const publishBoxRef = useTemplateRef('publishBoxRef') const publishBoxRef = useTemplateRef('publishBoxRef')
const commentDialogRef = useTemplateRef<typeof CommentDialog>('commentDialogRef') const commentDialogRef = useTemplateRef<typeof CommentDialog>('commentDialogRef')
const { showPreview, currentPreviewIndex, srcList, vImagePreview } = useImagePreview()
const activeTab = ref('最新') const activeTab = ref('最新')
const tabs = [ const tabs = [
......
<template>
<div class="flex items-start gap-2 pb-3">
<div
ref="containerRef"
class="flex flex-wrap gap-2 flex-1 min-w-0"
:style="!expanded && overflowing ? { maxHeight: `${rowHeight}px`, overflow: 'hidden' } : {}"
>
<button
v-for="item in options"
:key="item.id ?? 'all'"
class="px-4 py-1.5 rounded-full text-sm whitespace-nowrap border transition-colors duration-200 cursor-pointer"
:class="
modelValue === item.id
? 'bg-orange-400 border-orange-400 text-white shadow-md'
: 'bg-white border-gray-200 text-gray-600 hover:text-orange-500 hover:border-orange-300 hover:bg-orange-50'
"
@click="$emit('update:modelValue', item.id)"
>
{{ item.name }}
</button>
</div>
<button
v-if="overflowing"
class="px-3 py-1.5 text-sm text-gray-600 hover:text-orange-500 whitespace-nowrap shrink-0 cursor-pointer"
@click="expanded = !expanded"
>
{{ expanded ? '收起' : '更多' }}
</button>
</div>
</template>
<script setup lang="ts">
/**
* 前台视频分类栏:数据用 listPublic(已按后台顺序排好,不用再排),加一个「全部」项
* (选中时不传 categoryId)。超过两行时收起,展示「更多」按钮点开。
*/
import { listVideoCategoryPublic } from '@/api'
defineProps<{ modelValue?: number }>()
defineEmits<{ 'update:modelValue': [value: number | undefined] }>()
const categories = ref<{ id?: number; name: string }[]>([])
const options = computed(() => [{ id: undefined, name: '全部' }, ...categories.value])
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
const expanded = ref(false)
const overflowing = ref(false)
const rowHeight = ref(0)
const checkOverflow = () => {
const el = containerRef.value
const firstButton = el?.firstElementChild as HTMLElement | undefined
if (!el || !firstButton) return
// gap-2 = 8px:两行的高度是「按钮高度 * 2 + 行间距」,超出这个高度才需要收起
rowHeight.value = firstButton.offsetHeight * 2 + 8
overflowing.value = el.scrollHeight > rowHeight.value + 1
}
let resizeObserver: ResizeObserver | undefined
onMounted(async () => {
const { data } = await listVideoCategoryPublic()
categories.value = data
await nextTick()
checkOverflow()
resizeObserver = new ResizeObserver(checkOverflow)
if (containerRef.value) resizeObserver.observe(containerRef.value)
})
onUnmounted(() => resizeObserver?.disconnect())
</script>
...@@ -44,7 +44,7 @@ ...@@ -44,7 +44,7 @@
</div> </div>
<span class="text-gray-500 font-medium ml-0"> <span class="text-gray-500 font-medium ml-0">
<span class="hidden sm:inline">{{ <span class="hidden sm:inline">{{
dayjs(item.createTime * 1000).format('YYYY-MM-DD HH:mm') dayjs(item.publishTime * 1000).format('YYYY-MM-DD HH:mm')
}}</span> }}</span>
</span> </span>
<span class="text-sm text-gray-500">{{ <span class="text-sm text-gray-500">{{
......
<template> <template>
<div> <div>
<!-- 视频分类栏 -->
<div class="max-w-7xl mx-auto px-4">
<VideoCategoryBar :model-value="searchParams.categoryId" @update:model-value="changeCategory" />
</div>
<!-- tabs --> <!-- tabs -->
<div class="shadow-sm"> <div class="shadow-sm">
<div class="max-w-7xl mx-auto px-4"> <div class="max-w-7xl mx-auto px-4">
...@@ -55,6 +59,7 @@ ...@@ -55,6 +59,7 @@
<!-- 时长 --> <!-- 时长 -->
<div <div
v-if="list[0]?.videoDuration"
class="absolute bottom-4 right-4 bg-black/80 text-white px-3 py-1.5 rounded-lg text-sm" class="absolute bottom-4 right-4 bg-black/80 text-white px-3 py-1.5 rounded-lg text-sm"
> >
{{ list[0]?.videoDuration }} {{ list[0]?.videoDuration }}
...@@ -131,7 +136,7 @@ ...@@ -131,7 +136,7 @@
<!-- 时长 --> <!-- 时长 -->
<div <div
v-if="!smallerThanXl" v-if="!smallerThanXl && item?.videoDuration"
class="absolute bottom-3 right-3 bg-black/80 text-white px-2 py-1 rounded-lg text-xs" class="absolute bottom-3 right-3 bg-black/80 text-white px-2 py-1 rounded-lg text-xs"
> >
{{ item?.videoDuration }} {{ item?.videoDuration }}
...@@ -214,7 +219,7 @@ ...@@ -214,7 +219,7 @@
<!-- 时长 --> <!-- 时长 -->
<div <div
v-if="!smallerThanXl" v-if="!smallerThanXl && item.videoDuration"
class="absolute bottom-3 right-3 bg-black/80 text-white px-2 py-1 rounded-lg text-xs" class="absolute bottom-3 right-3 bg-black/80 text-white px-2 py-1 rounded-lg text-xs"
> >
{{ item.videoDuration }} {{ item.videoDuration }}
...@@ -308,7 +313,7 @@ ...@@ -308,7 +313,7 @@
<!-- 时长 --> <!-- 时长 -->
<div <div
v-if="!smallerThanXl" v-if="!smallerThanXl && item?.videoDuration"
class="absolute bottom-3 right-3 bg-black/80 text-white px-2 py-1 rounded-lg text-xs" class="absolute bottom-3 right-3 bg-black/80 text-white px-2 py-1 rounded-lg text-xs"
> >
{{ item?.videoDuration }} {{ item?.videoDuration }}
...@@ -422,6 +427,7 @@ import { getArticleList } from '@/api' ...@@ -422,6 +427,7 @@ import { getArticleList } from '@/api'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useNavigation } from '@/hooks' import { useNavigation } from '@/hooks'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core' import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import VideoCategoryBar from './VideoCategoryBar.vue'
const breakpoints = useBreakpoints(breakpointsTailwind) const breakpoints = useBreakpoints(breakpointsTailwind)
const smallerThanXl = breakpoints.smaller('xl') const smallerThanXl = breakpoints.smaller('xl')
...@@ -448,6 +454,11 @@ const toggleTab = (sortLogic: number) => { ...@@ -448,6 +454,11 @@ const toggleTab = (sortLogic: number) => {
searchParams.value.sortLogic = sortLogic searchParams.value.sortLogic = sortLogic
refresh() refresh()
} }
// 分类栏点击某个分类:跟「最新发布/最多播放」排序是叠加的,两个参数一起传给 listByPage
const changeCategory = (categoryId: number | undefined) => {
searchParams.value.categoryId = categoryId
refresh()
}
defineExpose({ defineExpose({
refresh: () => { refresh: () => {
......
...@@ -59,6 +59,7 @@ ...@@ -59,6 +59,7 @@
<!-- 时长 --> <!-- 时长 -->
<div <div
v-if="item?.videoDuration"
class="absolute bottom-3 right-3 bg-black/80 text-white px-2 py-1 rounded-lg text-xs" class="absolute bottom-3 right-3 bg-black/80 text-white px-2 py-1 rounded-lg text-xs"
> >
{{ item?.videoDuration }} {{ item?.videoDuration }}
...@@ -162,6 +163,7 @@ ...@@ -162,6 +163,7 @@
<!-- 时长 --> <!-- 时长 -->
<div <div
v-if="i.videoDuration"
class="absolute bottom-3 right-3 bg-black/80 text-white px-2 py-1 rounded-lg text-xs" class="absolute bottom-3 right-3 bg-black/80 text-white px-2 py-1 rounded-lg text-xs"
> >
{{ i.videoDuration }} {{ i.videoDuration }}
......
...@@ -66,20 +66,21 @@ ...@@ -66,20 +66,21 @@
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="关联部门" class="mb-6"> <el-form-item label="关联部门" class="mb-6">
<div class="flex items-start gap-3"> <DeptUserPicker
<el-button class="button-new-tag" size="small" @click="selcetDept"> v-model:dept-value="form.deptId"
+ 添加部门 selection-mode="department"
</el-button> value-type="string"
<el-tag :max="3"
v-for="tag in form.departmentList" @confirm="(items) => (form.deptName = items.map((i) => i.valueName).join(','))"
:key="tag.id" v-slot="{ echoText }"
closable
type="primary"
@close="() => delDept(tag.id)"
> >
{{ tag.name }} <el-input
</el-tag> readonly
</div> class="w-full cursor-pointer"
placeholder="请选择部门"
:model-value="echoText"
/>
</DeptUserPicker>
</el-form-item> </el-form-item>
<!-- 是否同步发布 --> <!-- 是否同步发布 -->
...@@ -126,10 +127,9 @@ import { ...@@ -126,10 +127,9 @@ import {
ReleaseStatusTypeEnum, ReleaseStatusTypeEnum,
AuditStatusEnum, AuditStatusEnum,
} from '@/constants' } from '@/constants'
import { selectDepOrUser } from '@/utils' import DeptUserPicker from '@/components/common/DeptUserPicker/index.vue'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import type { ISelectDept } from '@/utils/wxUtil'
import BackButton from '@/components/common/BackButton/index.vue' import BackButton from '@/components/common/BackButton/index.vue'
import { push } from 'notivue' import { push } from 'notivue'
import { useMessageBox } from '@/hooks' import { useMessageBox } from '@/hooks'
...@@ -155,25 +155,10 @@ const [form, resetForm] = useResetData({ ...@@ -155,25 +155,10 @@ const [form, resetForm] = useResetData({
relatedScenariosMainTagId: '', relatedScenariosMainTagId: '',
relatedScenariosSubTagIds: [] as number[], relatedScenariosSubTagIds: [] as number[],
isSync: BooleanFlag.NO, isSync: BooleanFlag.NO,
departmentList: [] as ISelectDept[], deptId: '',
deptName: '',
}) })
// 选择部门
const selcetDept = async () => {
const { departmentList } = await selectDepOrUser({
mode: 'multi', // single multi
type: ['department'],
selectedDepartmentIds: form.value.departmentList.map((i) => i.id),
})
if (departmentList.length > 3) {
return push.warning('最多只能选择3个部门,请重新选择')
}
form.value.departmentList = departmentList
}
const delDept = (id: string) => {
form.value.departmentList = form.value.departmentList.filter((i) => i.id !== id)
}
// 取消 // 取消
const handleCancel = () => { const handleCancel = () => {
resetForm() resetForm()
...@@ -187,7 +172,6 @@ const transformData = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdateCaseDto ...@@ -187,7 +172,6 @@ const transformData = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdateCaseDto
subTagIds, subTagIds,
relatedScenariosMainTagId, relatedScenariosMainTagId,
relatedScenariosSubTagIds, relatedScenariosSubTagIds,
departmentList,
...rest ...rest
} = form.value } = form.value
const obj: AddOrUpdateCaseDto = { const obj: AddOrUpdateCaseDto = {
...@@ -195,8 +179,6 @@ const transformData = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdateCaseDto ...@@ -195,8 +179,6 @@ const transformData = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdateCaseDto
releaseStatus, releaseStatus,
tagRelationDtoList: [], tagRelationDtoList: [],
sourceUser: '', sourceUser: '',
deptId: '',
deptName: '',
} }
// 添加文化标签内容 // 添加文化标签内容
obj.tagRelationDtoList.push({ obj.tagRelationDtoList.push({
...@@ -225,10 +207,6 @@ const transformData = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdateCaseDto ...@@ -225,10 +207,6 @@ const transformData = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdateCaseDto
}) })
}) })
// 添加部门相关字段
obj.deptId = departmentList.map((i) => i.id).join(',')
obj.deptName = departmentList.map((i) => i.name).join(',')
obj.sourceUser = userInfo.value.userId obj.sourceUser = userInfo.value.userId
return obj return obj
} }
...@@ -357,10 +335,8 @@ onActivated(async () => { ...@@ -357,10 +335,8 @@ onActivated(async () => {
.filter((i) => i.keywordType === TagLevelEnum.SUB_TAG) .filter((i) => i.keywordType === TagLevelEnum.SUB_TAG)
.map((i) => i.tagId), .map((i) => i.tagId),
isSync: data.isSync, isSync: data.isSync,
departmentList: data.depIdList.map((id, index) => ({ deptId: data.depIdList.join(','),
id: String(id), deptName: data.depNameList.join(','),
name: data.depNameList[index] || '',
})),
} }
form.value = obj form.value = obj
......
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<!-- 封面图:不管什么身份都必填(跟帖子不一样,帖子普通用户可以不传) -->
<el-form-item label="封面图" prop="faceUrl">
<UploadFile class="w-fit" v-model="form.faceUrl" :limit="1" :aspect-ratio="3 / 2" crop />
</el-form-item>
<!-- 主/副标签:专栏必填 -->
<el-form-item label="主标签" prop="mainTagId">
<SelectTags v-model="form.mainTagId" class="w-full" />
</el-form-item>
<el-form-item label="副标签 (最多3个)">
<SelectTags
v-model="form.tagList"
:filter-tags-fn="filterTagsFn"
:max-selected-tags="3"
class="w-full"
/>
</el-form-item>
<!-- 所属栏目 -->
<el-form-item label="所属栏目" prop="relateColumnId">
<el-select v-model="form.relateColumnId" placeholder="请选择专栏栏目" class="w-full">
<el-option
v-for="item in columnList"
:key="item.id"
:value="item.id"
:label="item.title"
/>
</el-select>
</el-form-item>
<el-form-item label="是否推荐" prop="isRecommend">
<el-radio-group v-model="form.isRecommend">
<el-radio :value="BooleanFlag.YES">是</el-radio>
<el-radio :value="BooleanFlag.NO">否</el-radio>
</el-radio-group>
</el-form-item>
<!-- 同步同事吧:专栏独有,专访没有这个字段 -->
<el-form-item label="同步同事吧" prop="isRelateColleague">
<el-radio-group v-model="form.isRelateColleague">
<el-radio :value="BooleanFlag.YES">是</el-radio>
<el-radio :value="BooleanFlag.NO">否</el-radio>
</el-radio-group>
</el-form-item>
<!-- 官方账号才有:推送配置 + 插入视频(问吧/帖子的官方账号只有推送,没有插入视频) -->
<template v-if="userInfo.isOfficialAccount">
<el-form-item label="推送给">
<div class="w-full flex items-center gap-2">
<el-radio-group v-model="form.isPushAll">
<el-radio :value="BooleanFlag.YES">全员</el-radio>
<el-radio :value="BooleanFlag.NO">指定部门/人员</el-radio>
</el-radio-group>
</div>
<div v-show="form.isPushAll === BooleanFlag.NO" class="flex items-center gap-2">
<DeptUserPicker
v-model:dept-value="pushDeptIds"
v-model:user-value="pushUserIds"
selection-mode="both"
@confirm="(items) => (pushSelection = items)"
v-slot="{ echoText }"
>
<el-input
readonly
class="w-80 cursor-pointer"
placeholder="请选择部门/人员"
:model-value="echoText"
/>
</DeptUserPicker>
</div>
</el-form-item>
<el-form-item label="推送类型" prop="pushType">
<el-radio-group v-model="form.pushType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即推送</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时推送</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.pushType === SendTypeEnum.SCHEDULED" prop="pushTime">
<el-date-picker
v-model="form.pushTime"
type="datetime"
placeholder="选择推送时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item label="推送摘要" prop="summary">
<el-input
v-model="form.summary"
type="textarea"
:maxlength="50"
show-word-limit
placeholder="简单描述推送的信息"
/>
</el-form-item>
<el-form-item label="插入视频" prop="videoLocation">
<el-radio-group v-model="form.videoLocation">
<el-radio :value="VideoPositionEnum.TOP">顶部</el-radio>
<el-radio :value="VideoPositionEnum.BOTTOM">底部</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="视频" prop="articleVideoUrl">
<UploadVideo v-model="form.articleVideoUrl" btnClass="flex-col" />
</el-form-item>
</template>
<!-- 发布时间 + 官方账号的 IP 信息:所有类型都有,规则完全一样,每个类型自己维护一份 -->
<el-form-item label="发布时间" prop="sendType">
<el-radio-group v-model="form.sendType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即发布</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时发布</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.sendType === SendTypeEnum.SCHEDULED" prop="sendTime">
<el-date-picker
v-model="form.sendTime"
type="datetime"
placeholder="选择发布时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item v-if="userInfo.isOfficialAccount" label="IP信息" prop="regionHide">
<el-switch
v-model="form.regionHide"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
active-text="隐藏地区"
inactive-text="显示地区"
/>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
/**
* 专栏(COLUMN)专属表单——自己管自己的整个抽屉表单,不依赖外层的公共表单。
*
* 专栏的特殊规则:
* - 封面图:不管什么身份都必填(跟帖子不一样,帖子普通用户可以不传)
* - 主/副标签必填,还要选择所属栏目、是否推荐
* - "同步同事吧"是专栏独有的字段,专访没有
* - 官方账号才有:推送配置(推送给谁/推送类型/推送摘要)+ 插入视频(顶部/底部 + 视频文件)——
* 这个"插入视频"能力只有专栏、专访才有,帖子、问吧的官方账号只有推送、没有插入视频
*/
import type { FormInstance, FormItemRule } from 'element-plus'
import { BooleanFlag, SendTypeEnum, VideoPositionEnum } from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import UploadVideo from '@/components/common/UploadVideo/index.vue'
import SelectTags from '@/components/common/SelectTags/index.vue'
import DeptUserPicker from '@/components/common/DeptUserPicker/index.vue'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
import type { ArticleItemDto } from '@/api/article/types'
import { useResetData } from '@/hooks'
import { useUserStore } from '@/stores/user'
import { useColumnStore } from '@/stores/column'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const columnStore = useColumnStore()
const { columnList } = storeToRefs(columnStore)
const formRef = useTemplateRef<FormInstance>('formRef')
const [form, resetForm] = useResetData({
faceUrl: '',
mainTagId: '',
tagList: [] as number[],
relateColumnId: '' as string | number,
isRecommend: BooleanFlag.NO,
isRelateColleague: BooleanFlag.NO,
isPushAll: BooleanFlag.YES,
pushType: SendTypeEnum.IMMEDIATE,
pushTime: '',
summary: '',
videoLocation: VideoPositionEnum.TOP,
articleVideoUrl: '',
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
regionHide: BooleanFlag.NO,
})
const pushDeptIds = ref<string[]>([])
const pushUserIds = ref<string[]>([])
const pushSelection = ref<DepartmentUserSelection[]>([])
const disabledSecondsOnlyZero = () => Array.from({ length: 59 }, (_, i) => i + 1)
const disabledMinutesOnlyTens = () =>
Array.from({ length: 60 }, (_, i) => i).filter((m) => m % 10 !== 0)
const filterTagsFn = (allTags: any[]) => allTags.filter((tag) => tag.id !== Number(form.value.mainTagId))
const rules: Record<string, FormItemRule[]> = {
faceUrl: [{ required: true, message: '请上传封面图', trigger: 'blur' }],
mainTagId: [{ required: true, message: '请选择主标签', trigger: 'blur' }],
relateColumnId: [{ required: true, message: '请选择对应的栏目', trigger: 'trigger' }],
isRecommend: [{ required: true, message: '是否推荐', trigger: 'trigger' }],
isRelateColleague: [{ required: true, message: '是否同步同事吧', trigger: 'trigger' }],
pushType: [{ required: true, message: '请选择推送类型', trigger: 'trigger' }],
pushTime: [{ required: true, message: '请选择推送时间', trigger: 'trigger' }],
sendType: [{ required: true, message: '请选择发布类型', trigger: 'blur' }],
sendTime: [
{
validator: (rule, value, callback) => {
if (form.value.sendType === SendTypeEnum.SCHEDULED && !value) {
callback(new Error('请选择发布时间'))
} else {
callback()
}
},
trigger: 'trigger',
},
],
regionHide: [{ required: true, message: '请选择是否隐藏IP信息', trigger: 'trigger' }],
}
const buildPushList = () =>
form.value.isPushAll === BooleanFlag.YES
? [{ valueId: '1', valueType: 1, valueName: '亚声威格' }]
: pushSelection.value.map((item) => ({
valueId: item.valueId,
valueType: item.valueType,
valueName: item.valueName,
}))
defineExpose({
validate: () => formRef.value!.validate(),
getSubmitData: () => ({
faceUrl: form.value.faceUrl,
mainTagId: form.value.mainTagId,
tagList: [form.value.mainTagId, ...form.value.tagList].map((item, index) => ({
tagId: Number(item),
sort: index,
})),
relateColumnId: form.value.relateColumnId,
isRecommend: form.value.isRecommend,
isRelateColleague: form.value.isRelateColleague,
sendType: form.value.sendType,
sendTime: form.value.sendTime,
regionHide: form.value.regionHide,
...(userInfo.value.isOfficialAccount
? {
isPushAll: form.value.isPushAll,
pushType: form.value.pushType,
pushTime: form.value.pushTime,
summary: form.value.summary,
pushList: buildPushList(),
videoLocation: form.value.videoLocation,
articleVideoUrl: form.value.articleVideoUrl,
}
: {}),
}),
setEchoData: (data: ArticleItemDto) => {
form.value.faceUrl = data.faceUrl
form.value.mainTagId = data.tagIdList[0] ? String(data.tagIdList[0]) : ''
form.value.tagList = data.tagIdList.slice(1) || []
form.value.relateColumnId = data.relateColumnId
form.value.isRecommend = data.isRecommend
form.value.isRelateColleague = data.isRelateColleague
form.value.sendType = data.sendType
form.value.sendTime = data.sendTime
form.value.regionHide = data.regionHide
if (data.articleVideoUrl) form.value.articleVideoUrl = data.articleVideoUrl
if (!userInfo.value.isOfficialAccount) return
form.value.summary = data.summary
if (data.pushList?.find((item) => item.valueId == '1')) {
form.value.isPushAll = BooleanFlag.YES
pushDeptIds.value = []
pushUserIds.value = []
pushSelection.value = []
} else {
form.value.isPushAll = BooleanFlag.NO
const items: DepartmentUserSelection[] = (data.pushList || []).map((item) => ({
valueType: item.valueType as 1 | 2,
valueId: String(item.valueId),
valueName: item.valueName,
}))
pushDeptIds.value = items.filter((item) => item.valueType === 1).map((item) => item.valueId)
pushUserIds.value = items.filter((item) => item.valueType === 2).map((item) => item.valueId)
pushSelection.value = items
}
},
reset: () => {
resetForm()
pushDeptIds.value = []
pushUserIds.value = []
pushSelection.value = []
},
})
</script>
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<!-- 封面图:不管什么身份都必填(跟帖子不一样,帖子普通用户可以不传) -->
<el-form-item label="封面图" prop="faceUrl">
<UploadFile class="w-fit" v-model="form.faceUrl" :limit="1" :aspect-ratio="3 / 2" crop />
</el-form-item>
<!-- 主/副标签:专访必填 -->
<el-form-item label="主标签" prop="mainTagId">
<SelectTags v-model="form.mainTagId" class="w-full" />
</el-form-item>
<el-form-item label="副标签 (最多3个)">
<SelectTags
v-model="form.tagList"
:filter-tags-fn="filterTagsFn"
:max-selected-tags="3"
class="w-full"
/>
</el-form-item>
<!-- 所属栏目 -->
<el-form-item label="所属栏目" prop="relateColumnId">
<el-select v-model="form.relateColumnId" placeholder="请选择专访栏目" class="w-full">
<el-option
v-for="item in interviewList"
:key="item.id"
:value="item.id"
:label="item.title"
/>
</el-select>
</el-form-item>
<el-form-item label="是否推荐" prop="isRecommend">
<el-radio-group v-model="form.isRecommend">
<el-radio :value="BooleanFlag.YES">是</el-radio>
<el-radio :value="BooleanFlag.NO">否</el-radio>
</el-radio-group>
</el-form-item>
<!-- 专访没有"同步同事吧"这个字段,这是专栏独有的 -->
<!-- 官方账号才有:推送配置 + 插入视频 -->
<template v-if="userInfo.isOfficialAccount">
<el-form-item label="推送给">
<div class="w-full flex items-center gap-2">
<el-radio-group v-model="form.isPushAll">
<el-radio :value="BooleanFlag.YES">全员</el-radio>
<el-radio :value="BooleanFlag.NO">指定部门/人员</el-radio>
</el-radio-group>
</div>
<div v-show="form.isPushAll === BooleanFlag.NO" class="flex items-center gap-2">
<DeptUserPicker
v-model:dept-value="pushDeptIds"
v-model:user-value="pushUserIds"
selection-mode="both"
@confirm="(items) => (pushSelection = items)"
v-slot="{ echoText }"
>
<el-input
readonly
class="w-80 cursor-pointer"
placeholder="请选择部门/人员"
:model-value="echoText"
/>
</DeptUserPicker>
</div>
</el-form-item>
<el-form-item label="推送类型" prop="pushType">
<el-radio-group v-model="form.pushType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即推送</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时推送</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.pushType === SendTypeEnum.SCHEDULED" prop="pushTime">
<el-date-picker
v-model="form.pushTime"
type="datetime"
placeholder="选择推送时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item label="推送摘要" prop="summary">
<el-input
v-model="form.summary"
type="textarea"
:maxlength="50"
show-word-limit
placeholder="简单描述推送的信息"
/>
</el-form-item>
<el-form-item label="插入视频" prop="videoLocation">
<el-radio-group v-model="form.videoLocation">
<el-radio :value="VideoPositionEnum.TOP">顶部</el-radio>
<el-radio :value="VideoPositionEnum.BOTTOM">底部</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="视频" prop="articleVideoUrl">
<UploadVideo v-model="form.articleVideoUrl" btnClass="flex-col" />
</el-form-item>
</template>
<!-- 发布时间 + 官方账号的 IP 信息:所有类型都有,规则完全一样,每个类型自己维护一份 -->
<el-form-item label="发布时间" prop="sendType">
<el-radio-group v-model="form.sendType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即发布</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时发布</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.sendType === SendTypeEnum.SCHEDULED" prop="sendTime">
<el-date-picker
v-model="form.sendTime"
type="datetime"
placeholder="选择发布时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item v-if="userInfo.isOfficialAccount" label="IP信息" prop="regionHide">
<el-switch
v-model="form.regionHide"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
active-text="隐藏地区"
inactive-text="显示地区"
/>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
/**
* 专访(INTERVIEW)专属表单——自己管自己的整个抽屉表单,不依赖外层的公共表单。
* 跟专栏几乎一样,唯一区别:
* - 没有"同步同事吧"字段(那是专栏独有的)
* 其余规则(封面图必填、标签必填、所属栏目、官方账号推送+插入视频)跟专栏完全一致。
*/
import type { FormInstance, FormItemRule } from 'element-plus'
import { BooleanFlag, SendTypeEnum, VideoPositionEnum } from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import UploadVideo from '@/components/common/UploadVideo/index.vue'
import SelectTags from '@/components/common/SelectTags/index.vue'
import DeptUserPicker from '@/components/common/DeptUserPicker/index.vue'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
import type { ArticleItemDto } from '@/api/article/types'
import { useResetData } from '@/hooks'
import { useUserStore } from '@/stores/user'
import { useInterviewStore } from '@/stores/interview'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const interviewStore = useInterviewStore()
const { interviewList } = storeToRefs(interviewStore)
const formRef = useTemplateRef<FormInstance>('formRef')
const [form, resetForm] = useResetData({
faceUrl: '',
mainTagId: '',
tagList: [] as number[],
relateColumnId: '' as string | number,
isRecommend: BooleanFlag.NO,
isPushAll: BooleanFlag.YES,
pushType: SendTypeEnum.IMMEDIATE,
pushTime: '',
summary: '',
videoLocation: VideoPositionEnum.TOP,
articleVideoUrl: '',
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
regionHide: BooleanFlag.NO,
})
const pushDeptIds = ref<string[]>([])
const pushUserIds = ref<string[]>([])
const pushSelection = ref<DepartmentUserSelection[]>([])
const disabledSecondsOnlyZero = () => Array.from({ length: 59 }, (_, i) => i + 1)
const disabledMinutesOnlyTens = () =>
Array.from({ length: 60 }, (_, i) => i).filter((m) => m % 10 !== 0)
const filterTagsFn = (allTags: any[]) => allTags.filter((tag) => tag.id !== Number(form.value.mainTagId))
const rules: Record<string, FormItemRule[]> = {
faceUrl: [{ required: true, message: '请上传封面图', trigger: 'blur' }],
mainTagId: [{ required: true, message: '请选择主标签', trigger: 'blur' }],
relateColumnId: [{ required: true, message: '请选择对应的栏目', trigger: 'trigger' }],
isRecommend: [{ required: true, message: '是否推荐', trigger: 'trigger' }],
pushType: [{ required: true, message: '请选择推送类型', trigger: 'trigger' }],
pushTime: [{ required: true, message: '请选择推送时间', trigger: 'trigger' }],
sendType: [{ required: true, message: '请选择发布类型', trigger: 'blur' }],
sendTime: [
{
validator: (rule, value, callback) => {
if (form.value.sendType === SendTypeEnum.SCHEDULED && !value) {
callback(new Error('请选择发布时间'))
} else {
callback()
}
},
trigger: 'trigger',
},
],
regionHide: [{ required: true, message: '请选择是否隐藏IP信息', trigger: 'trigger' }],
}
const buildPushList = () =>
form.value.isPushAll === BooleanFlag.YES
? [{ valueId: '1', valueType: 1, valueName: '亚声威格' }]
: pushSelection.value.map((item) => ({
valueId: item.valueId,
valueType: item.valueType,
valueName: item.valueName,
}))
defineExpose({
validate: () => formRef.value!.validate(),
getSubmitData: () => ({
faceUrl: form.value.faceUrl,
mainTagId: form.value.mainTagId,
tagList: [form.value.mainTagId, ...form.value.tagList].map((item, index) => ({
tagId: Number(item),
sort: index,
})),
relateColumnId: form.value.relateColumnId,
isRecommend: form.value.isRecommend,
sendType: form.value.sendType,
sendTime: form.value.sendTime,
regionHide: form.value.regionHide,
...(userInfo.value.isOfficialAccount
? {
isPushAll: form.value.isPushAll,
pushType: form.value.pushType,
pushTime: form.value.pushTime,
summary: form.value.summary,
pushList: buildPushList(),
videoLocation: form.value.videoLocation,
articleVideoUrl: form.value.articleVideoUrl,
}
: {}),
}),
setEchoData: (data: ArticleItemDto) => {
form.value.faceUrl = data.faceUrl
form.value.mainTagId = data.tagIdList[0] ? String(data.tagIdList[0]) : ''
form.value.tagList = data.tagIdList.slice(1) || []
form.value.relateColumnId = data.relateColumnId
form.value.isRecommend = data.isRecommend
form.value.sendType = data.sendType
form.value.sendTime = data.sendTime
form.value.regionHide = data.regionHide
if (data.articleVideoUrl) form.value.articleVideoUrl = data.articleVideoUrl
if (!userInfo.value.isOfficialAccount) return
form.value.summary = data.summary
if (data.pushList?.find((item) => item.valueId == '1')) {
form.value.isPushAll = BooleanFlag.YES
pushDeptIds.value = []
pushUserIds.value = []
pushSelection.value = []
} else {
form.value.isPushAll = BooleanFlag.NO
const items: DepartmentUserSelection[] = (data.pushList || []).map((item) => ({
valueType: item.valueType as 1 | 2,
valueId: String(item.valueId),
valueName: item.valueName,
}))
pushDeptIds.value = items.filter((item) => item.valueType === 1).map((item) => item.valueId)
pushUserIds.value = items.filter((item) => item.valueType === 2).map((item) => item.valueId)
pushSelection.value = items
}
},
reset: () => {
resetForm()
pushDeptIds.value = []
pushUserIds.value = []
pushSelection.value = []
},
})
</script>
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<!-- 封面图:官方账号/管理员必填;普通用户选填(这点跟专栏/专访不一样,那两个不管什么身份都必填) -->
<el-form-item label="封面图" prop="faceUrl" :rules="faceUrlRules">
<UploadFile class="w-fit" v-model="form.faceUrl" :limit="1" :aspect-ratio="3 / 2" crop />
</el-form-item>
<!-- 主/副标签:帖子跟专栏/专访/问吧/实践不一样,都是选填,不选也能发布 -->
<el-form-item label="主标签">
<SelectTags v-model="form.mainTagId" class="w-full" />
</el-form-item>
<el-form-item label="副标签 (最多3个)">
<SelectTags
v-model="form.tagList"
:filter-tags-fn="filterTagsFn"
:max-selected-tags="3"
class="w-full"
/>
</el-form-item>
<!-- 同步同事吧:只有官方账号能同步,普通用户看不到这个开关(专栏是因为只有管理员能发,所以没加判断) -->
<el-form-item v-if="userInfo.isOfficialAccount" label="同步同事吧" prop="isRelateColleague">
<el-radio-group v-model="form.isRelateColleague">
<el-radio :value="BooleanFlag.YES">是</el-radio>
<el-radio :value="BooleanFlag.NO">否</el-radio>
</el-radio-group>
</el-form-item>
<!-- 帖子没有关联栏目——只有官方账号才有推送配置 -->
<template v-if="userInfo.isOfficialAccount">
<el-form-item label="推送给">
<div class="w-full flex items-center gap-2">
<el-radio-group v-model="form.isPushAll">
<el-radio :value="BooleanFlag.YES">全员</el-radio>
<el-radio :value="BooleanFlag.NO">指定部门/人员</el-radio>
</el-radio-group>
</div>
<div v-show="form.isPushAll === BooleanFlag.NO" class="flex items-center gap-2">
<DeptUserPicker
v-model:dept-value="pushDeptIds"
v-model:user-value="pushUserIds"
selection-mode="both"
@confirm="(items) => (pushSelection = items)"
v-slot="{ echoText }"
>
<el-input
readonly
class="w-80 cursor-pointer"
placeholder="请选择部门/人员"
:model-value="echoText"
/>
</DeptUserPicker>
</div>
</el-form-item>
<el-form-item label="推送类型" prop="pushType">
<el-radio-group v-model="form.pushType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即推送</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时推送</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.pushType === SendTypeEnum.SCHEDULED" prop="pushTime">
<el-date-picker
v-model="form.pushTime"
type="datetime"
placeholder="选择推送时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item label="推送摘要" prop="summary">
<el-input
v-model="form.summary"
type="textarea"
:maxlength="50"
show-word-limit
placeholder="简单描述推送的信息"
/>
</el-form-item>
</template>
<!-- 发布时间 + 官方账号的 IP 信息:所有类型都有,规则完全一样,每个类型自己维护一份 -->
<el-form-item label="发布时间" prop="sendType">
<el-radio-group v-model="form.sendType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即发布</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时发布</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.sendType === SendTypeEnum.SCHEDULED" prop="sendTime">
<el-date-picker
v-model="form.sendTime"
type="datetime"
placeholder="选择发布时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item v-if="userInfo.isOfficialAccount" label="IP信息" prop="regionHide">
<el-switch
v-model="form.regionHide"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
active-text="隐藏地区"
inactive-text="显示地区"
/>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
/**
* 帖子(POST)专属表单——自己管自己的整个抽屉表单(字段、校验、提交数据、编辑回显),
* 不依赖外层拆出去的公共表单,改这个类型的规则只用改这一个文件。
*
* 帖子的特殊规则:
* - 封面图:官方账号 / 管理员必填;普通用户选填(专栏、专访不管身份都必填,问吧、实践用的是"图片"不是"封面图")
* - 主/副标签:跟专栏/专访/问吧/实践不一样,都是选填,一个都不选也能发布,没有关联栏目
* - 只有官方账号才看得到"推送"配置(推送给谁 / 推送类型 / 推送摘要)和"同步同事吧",非官方账号完全没有这些字段
* - "同步同事吧"原来只有专栏有,现在帖子也能同步;专栏那边没判官方账号,是因为专栏本来就只有管理员能发
* - 没有"插入视频"功能,这个只有专栏、专访的官方账号才有
* - 发布时间、官方账号的 IP 显示设置:所有类型都一样,这里也自己维护一份
*/
import type { FormInstance, FormItemRule } from 'element-plus'
import { BooleanFlag, SendTypeEnum } from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import SelectTags from '@/components/common/SelectTags/index.vue'
import DeptUserPicker from '@/components/common/DeptUserPicker/index.vue'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
import type { ArticleItemDto } from '@/api/article/types'
import { useResetData } from '@/hooks'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const formRef = useTemplateRef<FormInstance>('formRef')
const [form, resetForm] = useResetData({
faceUrl: '',
mainTagId: '',
tagList: [] as number[],
isRelateColleague: BooleanFlag.NO,
isPushAll: BooleanFlag.YES,
pushType: SendTypeEnum.IMMEDIATE,
pushTime: '',
summary: '',
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
regionHide: BooleanFlag.NO,
})
// “推送给”选中的部门/人员:*Ids 是给 DeptUserPicker 用的 id 数组,
// pushSelection 是 @confirm 拿到的完整详情(带名字),提交时直接用它拼 pushList。
const pushDeptIds = ref<string[]>([])
const pushUserIds = ref<string[]>([])
const pushSelection = ref<DepartmentUserSelection[]>([])
// 时间选择只允许整十分(:00、:10...:50),秒固定为 0
const disabledSecondsOnlyZero = () => Array.from({ length: 59 }, (_, i) => i + 1)
const disabledMinutesOnlyTens = () =>
Array.from({ length: 60 }, (_, i) => i).filter((m) => m % 10 !== 0)
const faceUrlRules = computed<FormItemRule[]>(() =>
userInfo.value.isOfficialAccount || userInfo.value.isAdmin
? [{ required: true, message: '请上传封面图', trigger: 'blur' }]
: [],
)
const filterTagsFn = (allTags: any[]) => allTags.filter((tag) => tag.id !== Number(form.value.mainTagId))
// 主/副标签都选填,合并成 tagList 时要把没选的部分过滤掉,不能硬凑一个空 tagId
const buildTagList = () =>
[form.value.mainTagId, ...form.value.tagList]
.filter((id) => id !== '' && id !== undefined && id !== null)
.map((tagId, index) => ({ tagId: Number(tagId), sort: index }))
const rules: Record<string, FormItemRule[]> = {
isRelateColleague: [{ required: true, message: '是否同步同事吧', trigger: 'trigger' }],
pushType: [{ required: true, message: '请选择推送类型', trigger: 'trigger' }],
pushTime: [{ required: true, message: '请选择推送时间', trigger: 'trigger' }],
sendType: [{ required: true, message: '请选择发布类型', trigger: 'blur' }],
sendTime: [
{
validator: (rule, value, callback) => {
if (form.value.sendType === SendTypeEnum.SCHEDULED && !value) {
callback(new Error('请选择发布时间'))
} else {
callback()
}
},
trigger: 'trigger',
},
],
regionHide: [{ required: true, message: '请选择是否隐藏IP信息', trigger: 'trigger' }],
}
const buildPushList = () =>
form.value.isPushAll === BooleanFlag.YES
? [{ valueId: '1', valueType: 1, valueName: '亚声威格' }]
: pushSelection.value.map((item) => ({
valueId: item.valueId,
valueType: item.valueType,
valueName: item.valueName,
}))
defineExpose({
validate: () => formRef.value!.validate(),
getSubmitData: () => ({
faceUrl: form.value.faceUrl,
mainTagId: form.value.mainTagId,
tagList: buildTagList(),
sendType: form.value.sendType,
sendTime: form.value.sendTime,
regionHide: form.value.regionHide,
...(userInfo.value.isOfficialAccount
? {
isRelateColleague: form.value.isRelateColleague,
isPushAll: form.value.isPushAll,
pushType: form.value.pushType,
pushTime: form.value.pushTime,
summary: form.value.summary,
pushList: buildPushList(),
}
: {}),
}),
setEchoData: (data: ArticleItemDto) => {
form.value.faceUrl = data.faceUrl
form.value.mainTagId = data.tagIdList?.[0] ? String(data.tagIdList[0]) : ''
form.value.tagList = data.tagIdList?.slice(1) || []
form.value.sendType = data.sendType
form.value.sendTime = data.sendTime
form.value.regionHide = data.regionHide
form.value.isRelateColleague = data.isRelateColleague
if (!userInfo.value.isOfficialAccount) return
form.value.summary = data.summary
if (data.pushList?.find((item) => item.valueId == '1')) {
form.value.isPushAll = BooleanFlag.YES
pushDeptIds.value = []
pushUserIds.value = []
pushSelection.value = []
} else {
form.value.isPushAll = BooleanFlag.NO
const items: DepartmentUserSelection[] = (data.pushList || []).map((item) => ({
valueType: item.valueType as 1 | 2,
valueId: String(item.valueId),
valueName: item.valueName,
}))
pushDeptIds.value = items.filter((item) => item.valueType === 1).map((item) => item.valueId)
pushUserIds.value = items.filter((item) => item.valueType === 2).map((item) => item.valueId)
pushSelection.value = items
}
},
reset: () => {
resetForm()
pushDeptIds.value = []
pushUserIds.value = []
pushSelection.value = []
},
})
</script>
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<!-- 实践跟问吧一样,用"图片"(内容图)不是"封面图" -->
<el-form-item label="图片" prop="imgUrl">
<UploadFile v-model="form.imgUrl" :limit="1" class="w-full" />
</el-form-item>
<!-- 主/副标签:实践必填 -->
<el-form-item label="主标签" prop="mainTagId">
<SelectTags v-model="form.mainTagId" class="w-full" />
</el-form-item>
<el-form-item label="副标签 (最多3个)">
<SelectTags
v-model="form.tagList"
:filter-tags-fn="filterTagsFn"
:max-selected-tags="3"
class="w-full"
/>
</el-form-item>
<!-- 查看权限:不选就是所有人可见,选了部门/人员之后只有被选中的能看到,本人和管理员都能改 -->
<el-form-item label="查看权限">
<DeptUserPicker
class="w-full"
v-model:dept-value="permissionDeptIds"
v-model:user-value="permissionUserIds"
selection-mode="both"
@confirm="(items) => (permissionSelection = items)"
v-slot="{ echoText }"
>
<el-input
readonly
class="w-full cursor-pointer"
placeholder="不设置则所有人可见"
:model-value="echoText"
/>
</DeptUserPicker>
</el-form-item>
<!-- 实践没有推送功能(不管是不是官方账号);官方账号可以选一个所属一级部门,选填 -->
<el-form-item v-if="userInfo.isOfficialAccount" label="所属一级部门">
<el-select v-model="form.deptId" placeholder="请选择一级部门" clearable>
<el-option
v-for="item in firstDepListOptions"
:key="item.id"
:value="item.id"
:label="item.name"
/>
</el-select>
</el-form-item>
<!-- 发布时间 + 官方账号的 IP 信息:所有类型都有,规则完全一样,每个类型自己维护一份 -->
<el-form-item label="发布时间" prop="sendType">
<el-radio-group v-model="form.sendType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即发布</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时发布</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.sendType === SendTypeEnum.SCHEDULED" prop="sendTime">
<el-date-picker
v-model="form.sendTime"
type="datetime"
placeholder="选择发布时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item v-if="userInfo.isOfficialAccount" label="IP信息" prop="regionHide">
<el-switch
v-model="form.regionHide"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
active-text="隐藏地区"
inactive-text="显示地区"
/>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
/**
* 实践(PRACTICE)专属表单——自己管自己的整个抽屉表单,不依赖外层的公共表单。
*
* 实践的特殊规则:
* - 用"图片"(imgUrl)不是"封面图",选填;提交时自动取第一张图当 faceUrl,跟问吧一样
* - 主/副标签必填
* - 完全没有推送功能,不管是不是官方账号——这是跟帖子/问吧/专栏/专访最大的区别
* - 官方账号可以选一个"所属一级部门",选填,不是必填项
* - 查看权限:不选部门/人员就是公开,选了之后只有被选中的能看到,作者本人和管理员都能改;
* 这个不区分官方账号,任何人发实践都能设置
* - 提交走的是单独的 addOrUpdatePractice 接口(不是 addOrUpdateArticle),这个在外层
* publishLongArticle/index.vue 里按 type 分支处理,这个组件只管收集字段
*/
import type { FormInstance, FormItemRule } from 'element-plus'
import { firstDepListOptions, BooleanFlag, SendTypeEnum } from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import SelectTags from '@/components/common/SelectTags/index.vue'
import DeptUserPicker from '@/components/common/DeptUserPicker/index.vue'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
import type { ArticleItemDto } from '@/api/article/types'
import { buildPracticeTypeUpdates, toPracticeSelection } from '@/utils/practicePermission'
import { useResetData } from '@/hooks'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const formRef = useTemplateRef<FormInstance>('formRef')
const [form, resetForm] = useResetData({
imgUrl: '',
mainTagId: '',
tagList: [] as number[],
deptId: '',
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
regionHide: BooleanFlag.NO,
})
// 查看权限选中的部门/人员
const permissionDeptIds = ref<string[]>([])
const permissionUserIds = ref<string[]>([])
const permissionSelection = ref<DepartmentUserSelection[]>([])
const filterTagsFn = (allTags: any[]) =>
allTags.filter((tag) => tag.id !== Number(form.value.mainTagId))
// 时间选择只允许整十分(:00、:10...:50),秒固定为 0
const disabledSecondsOnlyZero = () => Array.from({ length: 59 }, (_, i) => i + 1)
const disabledMinutesOnlyTens = () =>
Array.from({ length: 60 }, (_, i) => i).filter((m) => m % 10 !== 0)
const rules: Record<string, FormItemRule[]> = {
mainTagId: [{ required: true, message: '请选择主标签', trigger: 'blur' }],
sendType: [{ required: true, message: '请选择发布类型', trigger: 'blur' }],
sendTime: [
{
validator: (rule, value, callback) => {
if (form.value.sendType === SendTypeEnum.SCHEDULED && !value) {
callback(new Error('请选择发布时间'))
} else {
callback()
}
},
trigger: 'trigger',
},
],
regionHide: [{ required: true, message: '请选择是否隐藏IP信息', trigger: 'trigger' }],
}
defineExpose({
validate: () => formRef.value!.validate(),
getSubmitData: () => {
const typeUpdates = buildPracticeTypeUpdates(permissionSelection.value)
return {
imgUrl: form.value.imgUrl,
faceUrl: form.value.imgUrl ? form.value.imgUrl.split(',')[0] || '' : '',
mainTagId: form.value.mainTagId,
tagList: [form.value.mainTagId, ...form.value.tagList].map((item, index) => ({
tagId: Number(item),
sort: index,
})),
sendType: form.value.sendType,
sendTime: form.value.sendTime,
regionHide: form.value.regionHide,
...(userInfo.value.isOfficialAccount ? { deptId: form.value.deptId || '' } : {}),
...(typeUpdates.length ? { typeUpdates } : {}),
}
},
setEchoData: (data: ArticleItemDto) => {
form.value.imgUrl = data.imgUrl
form.value.mainTagId = data.tagIdList[0] ? String(data.tagIdList[0]) : ''
form.value.tagList = data.tagIdList.slice(1) || []
form.value.sendType = data.sendType
form.value.sendTime = data.sendTime
form.value.regionHide = data.regionHide
if (userInfo.value.isOfficialAccount) {
form.value.deptId = data.deptId === '0' ? '' : data.deptId
}
const items = toPracticeSelection(data.typeUpdates)
permissionDeptIds.value = items
.filter((item) => item.valueType === 1)
.map((item) => item.valueId)
permissionUserIds.value = items
.filter((item) => item.valueType === 2)
.map((item) => item.valueId)
permissionSelection.value = items
},
reset: () => {
resetForm()
permissionDeptIds.value = []
permissionUserIds.value = []
permissionSelection.value = []
},
})
</script>
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<!-- 问吧用的是"图片"(内容图),不是"封面图"——提交时会自动把第一张图片当封面图传给后端 -->
<el-form-item label="图片" prop="imgUrl">
<UploadFile v-model="form.imgUrl" :limit="1" class="w-full" />
</el-form-item>
<!-- 主/副标签:问吧必填 -->
<el-form-item label="主标签" prop="mainTagId">
<SelectTags v-model="form.mainTagId" class="w-full" />
</el-form-item>
<el-form-item label="副标签 (最多3个)">
<SelectTags
v-model="form.tagList"
:filter-tags-fn="filterTagsFn"
:max-selected-tags="3"
class="w-full"
/>
</el-form-item>
<!-- 官方账号才有推送配置;跟帖子一样没有"插入视频"(那是专栏/专访独有的) -->
<template v-if="userInfo.isOfficialAccount">
<el-form-item label="推送给">
<div class="w-full flex items-center gap-2">
<el-radio-group v-model="form.isPushAll">
<el-radio :value="BooleanFlag.YES">全员</el-radio>
<el-radio :value="BooleanFlag.NO">指定部门/人员</el-radio>
</el-radio-group>
</div>
<div v-show="form.isPushAll === BooleanFlag.NO" class="flex items-center gap-2">
<DeptUserPicker
v-model:dept-value="pushDeptIds"
v-model:user-value="pushUserIds"
selection-mode="both"
@confirm="(items) => (pushSelection = items)"
v-slot="{ echoText }"
>
<el-input
readonly
class="w-80 cursor-pointer"
placeholder="请选择部门/人员"
:model-value="echoText"
/>
</DeptUserPicker>
</div>
</el-form-item>
<el-form-item label="推送类型" prop="pushType">
<el-radio-group v-model="form.pushType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即推送</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时推送</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.pushType === SendTypeEnum.SCHEDULED" prop="pushTime">
<el-date-picker
v-model="form.pushTime"
type="datetime"
placeholder="选择推送时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item label="推送摘要" prop="summary">
<el-input
v-model="form.summary"
type="textarea"
:maxlength="50"
show-word-limit
placeholder="简单描述推送的信息"
/>
</el-form-item>
</template>
<!-- 发布时间 + 官方账号的 IP 信息:所有类型都有,规则完全一样,每个类型自己维护一份 -->
<el-form-item label="发布时间" prop="sendType">
<el-radio-group v-model="form.sendType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即发布</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时发布</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.sendType === SendTypeEnum.SCHEDULED" prop="sendTime">
<el-date-picker
v-model="form.sendTime"
type="datetime"
placeholder="选择发布时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item v-if="userInfo.isOfficialAccount" label="IP信息" prop="regionHide">
<el-switch
v-model="form.regionHide"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
active-text="隐藏地区"
inactive-text="显示地区"
/>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
/**
* 问吧(QUESTION)专属表单——自己管自己的整个抽屉表单,不依赖外层的公共表单。
*
* 问吧的特殊规则:
* - 用"图片"(imgUrl,内容图)而不是"封面图",提交时自动取第一张图当 faceUrl 一起传给后端,
* 编辑回显时也是回显 imgUrl,不是 faceUrl(这点直接影响 setEchoData 怎么写)
* - 主/副标签必填,跟专栏/专访一样,但没有关联栏目
* - 官方账号才有推送配置,跟帖子的推送规则一模一样(没有插入视频)
*/
import type { FormInstance, FormItemRule } from 'element-plus'
import { BooleanFlag, SendTypeEnum } from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import SelectTags from '@/components/common/SelectTags/index.vue'
import DeptUserPicker from '@/components/common/DeptUserPicker/index.vue'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
import type { ArticleItemDto } from '@/api/article/types'
import { useResetData } from '@/hooks'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const formRef = useTemplateRef<FormInstance>('formRef')
const [form, resetForm] = useResetData({
imgUrl: '',
mainTagId: '',
tagList: [] as number[],
isPushAll: BooleanFlag.YES,
pushType: SendTypeEnum.IMMEDIATE,
pushTime: '',
summary: '',
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
regionHide: BooleanFlag.NO,
})
const pushDeptIds = ref<string[]>([])
const pushUserIds = ref<string[]>([])
const pushSelection = ref<DepartmentUserSelection[]>([])
const disabledSecondsOnlyZero = () => Array.from({ length: 59 }, (_, i) => i + 1)
const disabledMinutesOnlyTens = () =>
Array.from({ length: 60 }, (_, i) => i).filter((m) => m % 10 !== 0)
const filterTagsFn = (allTags: any[]) => allTags.filter((tag) => tag.id !== Number(form.value.mainTagId))
const rules: Record<string, FormItemRule[]> = {
imgUrl: [{ required: true, message: '请上传内容图片', trigger: 'blur' }],
mainTagId: [{ required: true, message: '请选择主标签', trigger: 'blur' }],
pushType: [{ required: true, message: '请选择推送类型', trigger: 'trigger' }],
pushTime: [{ required: true, message: '请选择推送时间', trigger: 'trigger' }],
sendType: [{ required: true, message: '请选择发布类型', trigger: 'blur' }],
sendTime: [
{
validator: (rule, value, callback) => {
if (form.value.sendType === SendTypeEnum.SCHEDULED && !value) {
callback(new Error('请选择发布时间'))
} else {
callback()
}
},
trigger: 'trigger',
},
],
regionHide: [{ required: true, message: '请选择是否隐藏IP信息', trigger: 'trigger' }],
}
const buildPushList = () =>
form.value.isPushAll === BooleanFlag.YES
? [{ valueId: '1', valueType: 1, valueName: '亚声威格' }]
: pushSelection.value.map((item) => ({
valueId: item.valueId,
valueType: item.valueType,
valueName: item.valueName,
}))
defineExpose({
validate: () => formRef.value!.validate(),
getSubmitData: () => ({
imgUrl: form.value.imgUrl,
// 问吧没有单独的封面图字段,取内容图片第一张当封面图
faceUrl: form.value.imgUrl ? form.value.imgUrl.split(',')[0] || '' : '',
mainTagId: form.value.mainTagId,
tagList: [form.value.mainTagId, ...form.value.tagList].map((item, index) => ({
tagId: Number(item),
sort: index,
})),
sendType: form.value.sendType,
sendTime: form.value.sendTime,
regionHide: form.value.regionHide,
...(userInfo.value.isOfficialAccount
? {
isPushAll: form.value.isPushAll,
pushType: form.value.pushType,
pushTime: form.value.pushTime,
summary: form.value.summary,
pushList: buildPushList(),
}
: {}),
}),
setEchoData: (data: ArticleItemDto) => {
// 回显 imgUrl,不是 faceUrl——问吧详情接口返回的封面图是从 imgUrl 派生的,直接用 imgUrl 才对得上
form.value.imgUrl = data.imgUrl
form.value.mainTagId = data.tagIdList[0] ? String(data.tagIdList[0]) : ''
form.value.tagList = data.tagIdList.slice(1) || []
form.value.sendType = data.sendType
form.value.sendTime = data.sendTime
form.value.regionHide = data.regionHide
if (!userInfo.value.isOfficialAccount) return
form.value.summary = data.summary
if (data.pushList?.find((item) => item.valueId == '1')) {
form.value.isPushAll = BooleanFlag.YES
pushDeptIds.value = []
pushUserIds.value = []
pushSelection.value = []
} else {
form.value.isPushAll = BooleanFlag.NO
const items: DepartmentUserSelection[] = (data.pushList || []).map((item) => ({
valueType: item.valueType as 1 | 2,
valueId: String(item.valueId),
valueName: item.valueName,
}))
pushDeptIds.value = items.filter((item) => item.valueType === 1).map((item) => item.valueId)
pushUserIds.value = items.filter((item) => item.valueType === 2).map((item) => item.valueId)
pushSelection.value = items
}
},
reset: () => {
resetForm()
pushDeptIds.value = []
pushUserIds.value = []
pushSelection.value = []
},
})
</script>
<template> <template>
<div class="min-h-screen bg-gradient-to-br pb-10 relative"> <div class="min-h-screen bg-gradient-to-br pb-10 relative">
<BackButton /> <BackButton />
<!-- 主编辑区域 --> <!-- 主编辑区域:预览时用 v-show 藏起来而不是销毁,返回编辑时编辑器里的内容和光标状态都还在 -->
<div class="bg-white rounded-lg shadow-lg border border-gray-100/50 overflow-hidden"> <div
v-show="!previewing"
class="bg-white rounded-lg shadow-lg border border-gray-100/50 overflow-hidden"
>
<div class="px-10 py-5"> <div class="px-10 py-5">
<!-- 标题输入 --> <!-- 标题输入 -->
<el-form-item prop="title" class="mb-8"> <el-form-item class="mb-8">
<el-input <el-input
v-model="form.title" v-model="sharedForm.title"
:placeholder="`在这里输入你的${text}标题...`" :placeholder="`在这里输入你的${text}标题...`"
class="title-input text-3xl font-bold" class="title-input text-3xl font-bold"
show-word-limit show-word-limit
...@@ -17,18 +20,14 @@ ...@@ -17,18 +20,14 @@
/> />
</el-form-item> </el-form-item>
<!-- 富文本编辑器 --> <!-- 富文本编辑器:只有实践是纯文本,其它类型都是富文本,这个差异跟"官方账号/推送"那些规则
没关系,就是内容编辑器本身的选择,所以没有拆到子组件里,直接在这判断 -->
<div> <div>
<el-form-item prop="content"> <el-form-item>
<!-- 问吧和实践不是富文本 --> <template v-if="type === ArticleTypeEnum.PRACTICE">
<template
v-if="
form.type === ArticleTypeEnum.PRACTICE || form.type === ArticleTypeEnum.QUESTION
"
>
<el-input <el-input
:placeholder="`请输入${text}内容`" :placeholder="`请输入${text}内容`"
v-model="form.content" v-model="sharedForm.content"
type="textarea" type="textarea"
:rows="30" :rows="30"
:maxlength="2000" :maxlength="2000"
...@@ -37,7 +36,7 @@ ...@@ -37,7 +36,7 @@
</template> </template>
<template v-else> <template v-else>
<!-- 回显回来可能会有bug 所以需要key 重新渲染一下--> <!-- 回显回来可能会有bug 所以需要key 重新渲染一下-->
<WangEditor :key v-model="form.content" /> <WangEditor :key="editorKey" v-model="sharedForm.content" />
</template> </template>
</el-form-item> </el-form-item>
</div> </div>
...@@ -45,11 +44,21 @@ ...@@ -45,11 +44,21 @@
</div> </div>
<!-- 底部固定按钮栏 --> <!-- 底部固定按钮栏 -->
<div class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg"> <div
v-show="!previewing"
class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg"
>
<div class="max-w-[1200px] mx-auto px-4 py-3"> <div class="max-w-[1200px] mx-auto px-4 py-3">
<div class="flex justify-center gap-4"> <div class="flex justify-center gap-4">
<el-button @click="handleClosed" class="rounded-lg min-w[120px]"> 取消 </el-button> <el-button @click="handleClosed" class="rounded-lg min-w[120px]"> 取消 </el-button>
<el-button <el-button
:disabled="!sharedForm.content"
@click="previewing = true"
class="rounded-lg min-w[120px]"
>
预览
</el-button>
<el-button
type="primary" type="primary"
:disabled="!canPublish" :disabled="!canPublish"
@click="openDrawer" @click="openDrawer"
...@@ -69,275 +78,31 @@ ...@@ -69,275 +78,31 @@
size="500px" size="500px"
:before-close="handleDrawerClose" :before-close="handleDrawerClose"
> >
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="px-4"> <div class="px-4">
<!-- 基础设置 --> <!-- 文章类型:只读展示,来自路由参数,不需要表单校验 -->
<div> <div class="mb-6">
<!-- 文章类型 -->
<el-form-item label="文章类型" prop="type">
<el-button <el-button
type="primary" type="primary"
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 min-w[120px]" 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 min-w[120px]"
> >
{{ text }} {{ text }}
</el-button> </el-button>
</el-form-item>
<!-- 内容图片 实践和问吧需要上传内容图片 然后默认第一张是封面图 -->
<template
v-if="form.type === ArticleTypeEnum.PRACTICE || form.type === ArticleTypeEnum.QUESTION"
>
<el-form-item label="图片">
<UploadFile v-model="form.imgUrl" :limit="1" class="w-full" />
</el-form-item>
</template>
<!-- 帖子 专栏专访 是富文本 需要上传封面图 封面图 帖子的封面不必填-->
<template v-else>
<el-form-item
label="封面图"
prop="faceUrl"
:rules="
form.type === ArticleTypeEnum.POST &&
!userInfo.isOfficialAccount &&
!userInfo.isAdmin
? []
: [{ required: true, message: '请上传封面图', trigger: 'blur' }]
"
>
<UploadFile class="w-fit" v-model="form.faceUrl" :limit="1" />
</el-form-item>
</template>
<!-- 标签和主标签 除了帖子 都需要上传 -->
<template v-if="form.type !== ArticleTypeEnum.POST">
<el-form-item label="主标签" prop="mainTagId">
<SelectTags v-model="form.mainTagId" class="w-full" />
</el-form-item>
<el-form-item label="副标签 (最多3个)">
<SelectTags
v-model="form.tagList"
:filter-tags-fn="filterTagsFn"
:max-selected-tags="3"
class="w-full"
/>
</el-form-item>
</template>
</div> </div>
<!-- 专栏配置 (条件显示) --> <!-- 各文章类型自己的表单:字段、校验规则、发布时间/IP信息、提交/回显逻辑都在各自的组件里维护,
<template 这里不再拆一个"公共表单"出来,每个组件就是一个完整、独立的抽屉表单 -->
v-if="form.type === ArticleTypeEnum.COLUMN || form.type === ArticleTypeEnum.INTERVIEW" <PostForm v-if="type === ArticleTypeEnum.POST" ref="subFormRef" />
> <ColumnForm v-else-if="type === ArticleTypeEnum.COLUMN" ref="subFormRef" />
<div> <InterviewForm v-else-if="type === ArticleTypeEnum.INTERVIEW" ref="subFormRef" />
<el-form-item label="所属栏目" prop="relateColumnId"> <QuestionForm v-else-if="type === ArticleTypeEnum.QUESTION" ref="subFormRef" />
<el-select <PracticeForm v-else-if="type === ArticleTypeEnum.PRACTICE" ref="subFormRef" />
v-model="form.relateColumnId"
:placeholder="`请选择${text}栏目`"
class="w-full"
>
<el-option
v-for="item in relateColumnList"
:key="item.id"
:value="item.id"
:label="item.title"
/>
</el-select>
</el-form-item>
<!-- <el-form-item label="主标签" prop="mainTagId">
<SelectTags v-model="form.mainTagId" class="w-full" />
</el-form-item>
<el-form-item label="副标签 (最多3个)">
<SelectTags
v-model="form.tagList"
:filter-tags-fn="filterTagsFn"
:max-selected-tags="3"
class="w-full"
/>
</el-form-item> -->
<el-form-item label="是否推荐" prop="isRecommend">
<!-- 改为radio单选框 -->
<el-radio-group v-model="form.isRecommend">
<el-radio :value="BooleanFlag.YES">是</el-radio>
<el-radio :value="BooleanFlag.NO">否</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
label="同步同事吧"
prop="isRelateColleague"
v-if="form.type === ArticleTypeEnum.COLUMN"
>
<el-radio-group v-model="form.isRelateColleague">
<el-radio :value="BooleanFlag.YES">是</el-radio>
<el-radio :value="BooleanFlag.NO">否</el-radio>
</el-radio-group>
</el-form-item>
<!-- 如果是官方账号的话还有推送相关的配置 以及能上传视频-->
<template v-if="userInfo.isOfficialAccount">
<el-form-item label="推送给">
<div class="w-full flex items-center gap-2">
<el-radio-group v-model="form.isPushAll">
<el-radio :value="BooleanFlag.YES">全员</el-radio>
<el-radio :value="BooleanFlag.NO">指定部门/人员</el-radio>
</el-radio-group>
</div>
<div v-show="form.isPushAll === BooleanFlag.NO" class="flex items-center gap-2">
<el-button class="button-new-tag" size="small" @click="handleSelectUserAndDept">
+ 添加部门/人员
</el-button>
<span class="ml-2 text-sm text-gray-600"> {{ selectedText }}</span>
</div>
</el-form-item>
<el-form-item label="推送类型" prop="pushType">
<el-radio-group v-model="form.pushType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即推送</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时推送</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.pushType === SendTypeEnum.SCHEDULED" prop="pushTime">
<el-date-picker
v-model="form.pushTime"
type="datetime"
placeholder="选择推送时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item label="推送摘要" prop="summary">
<el-input
v-model="form.summary"
type="textarea"
:maxlength="50"
show-word-limit
placeholder="简单描述推送的信息"
/>
</el-form-item>
<!-- 插入视频 顶部或者底部 radio -->
<el-form-item label="插入视频" prop="videoLocation">
<el-radio-group v-model="form.videoLocation">
<el-radio :value="VideoPositionEnum.TOP">顶部</el-radio>
<el-radio :value="VideoPositionEnum.BOTTOM">底部</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="视频" prop="articleVideoUrl">
<UploadVideo v-model="form.articleVideoUrl" btnClass="flex-col" />
</el-form-item>
</template>
</div>
</template>
<!-- 如果是官方账号的话还有推送相关的配置 帖子 和问吧也要加推送-->
<template
v-if="
userInfo.isOfficialAccount &&
(form.type === ArticleTypeEnum.POST || form.type === ArticleTypeEnum.QUESTION)
"
>
<el-form-item label="推送给">
<div class="w-full flex items-center gap-2">
<el-radio-group v-model="form.isPushAll">
<el-radio :value="BooleanFlag.YES">全员</el-radio>
<el-radio :value="BooleanFlag.NO">指定部门/人员</el-radio>
</el-radio-group>
</div>
<div v-show="form.isPushAll === BooleanFlag.NO" class="flex items-center gap-2">
<el-button class="button-new-tag" size="small" @click="handleSelectUserAndDept">
+ 添加部门/人员
</el-button>
<span class="ml-2 text-sm text-gray-600"> {{ selectedText }}</span>
</div>
</el-form-item>
<el-form-item label="推送类型" prop="pushType">
<el-radio-group v-model="form.pushType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即推送</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时推送</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.pushType === SendTypeEnum.SCHEDULED" prop="pushTime">
<el-date-picker
v-model="form.pushTime"
type="datetime"
placeholder="选择推送时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item label="推送摘要" prop="summary">
<el-input
v-model="form.summary"
type="textarea"
:maxlength="50"
show-word-limit
placeholder="简单描述推送的信息"
/>
</el-form-item>
</template>
<!-- 选一级部门 如果是官方账号 并且是 实践 选填 -->
<template v-if="userInfo.isOfficialAccount && form.type === ArticleTypeEnum.PRACTICE">
<el-form-item label="所属一级部门">
<el-select v-model="form.deptId" :placeholder="`请选择一级部门`" clearable>
<el-option
v-for="item in firstDepListOptions"
:key="item.id"
:value="item.id"
:label="item.name"
/>
</el-select>
</el-form-item>
</template>
<!-- 发布设置 -->
<div>
<el-form-item label="发布时间" prop="sendType">
<el-radio-group v-model="form.sendType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即发布</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时发布</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.sendType === SendTypeEnum.SCHEDULED" prop="sendTime">
<el-date-picker
v-model="form.sendTime"
type="datetime"
placeholder="选择发布时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
</div> </div>
<!-- 针对官方账号新增是否显示ip -->
<template v-if="userInfo.isOfficialAccount">
<el-form-item label="IP信息" prop="regionHide">
<el-switch
v-model="form.regionHide"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
active-text="隐藏地区"
inactive-text="显示地区"
/>
</el-form-item>
</template>
</el-form>
<!-- 抽屉底部按钮 --> <!-- 抽屉底部按钮 -->
<template #footer> <template #footer>
<div class="flex gap-3 justify-end"> <div class="flex gap-3 justify-end">
<el-button @click="handleDrawerClose" class="rounded-lg">取消</el-button> <el-button @click="handleDrawerClose" class="rounded-lg">取消</el-button>
<el-button @click="handlePreviewFromDrawer" class="rounded-lg">预览</el-button>
<el-button <el-button
:loading="loading" :loading="loading"
@click="handleSubmit(ReleaseStatusTypeEnum.DRAFT)" @click="handleSubmit(ReleaseStatusTypeEnum.DRAFT)"
...@@ -356,40 +121,55 @@ ...@@ -356,40 +121,55 @@
</div> </div>
</template> </template>
</el-drawer> </el-drawer>
<!-- 预览:直接用详情页的 ArticleContent 渲染,所见即所得,不用先保存也不需要接口 -->
<div v-if="previewing" class="space-y-4">
<!-- 问吧详情页的样子跟其它图文类完全不同,用它自己的问题卡片;其它类型用 ArticleContent -->
<QuestionCard v-if="type === ArticleTypeEnum.QUESTION" :questionDetail="previewDetail" isPreview />
<ArticleContent v-else :articleDetail="previewDetail" :isAudit="true" :isPreview="true" />
<div class="flex justify-end gap-3">
<el-button @click="previewing = false">返回编辑</el-button>
<el-button type="primary" :disabled="!canPublish" @click="openDrawer">
{{ isEdit ? '编辑' : '发布' }}
</el-button>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { FormInstance } from 'element-plus' /**
* 发文入口页(专栏/专访/帖子/问吧/实践共用这一个路由 publishLongArticle/:type,视频是单独的 publishVideo 页面)。
*
* 这里只负责标题、内容编辑器、抽屉/按钮壳子、以及提交/编辑回显的整体编排——这几样是真正
* "所有类型完全一样、拆出来也没有独立意义"的部分。除此之外,每个类型的抽屉表单(字段、
* 校验规则、发布时间/IP信息、提交数据、编辑回显)都完整放在 components/ 下对应的
* XxxForm.vue 里自己维护,不再抽一个"公共表单"出来跟子组件拆着管——即使发布时间/IP信息
* 这种规则一样的字段,也是每个类型自己各留一份,改某个类型的东西只用改那一个文件。
*
* 提交流程:校验子组件自己的表单,通过了以后把标题/内容/id 这些公共字段和子组件返回的
* 数据合并起来,按 type 决定调 addOrUpdateArticle 还是 addOrUpdatePractice(实践走的是
* 单独的接口,跟其它类型不一样)。
*/
import WangEditor from '@/components/common/WangEditor/index.vue' import WangEditor from '@/components/common/WangEditor/index.vue'
import { import { ArticleTypeEnum, ReleaseStatusTypeEnum, articleTypeListOptions } from '@/constants'
ArticleTypeEnum,
SendTypeEnum,
BooleanFlag,
ReleaseStatusTypeEnum,
articleTypeListOptions,
VideoPositionEnum,
firstDepListOptions,
} from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import UploadVideo from '@/components/common/UploadVideo/index.vue'
import { useResetData } from '@/hooks' import { useResetData } from '@/hooks'
import { useColumnStore } from '@/stores/column'
import { useInterviewStore } from '@/stores/interview'
import { storeToRefs } from 'pinia'
import { addOrUpdateArticle, addOrUpdatePractice, getArticleDetail } from '@/api' import { addOrUpdateArticle, addOrUpdatePractice, getArticleDetail } from '@/api'
import type { ArticleItemDto } from '@/api'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { selectDepOrUser } from '@/utils'
import type { FormItemRule } from 'element-plus'
import { push } from 'notivue' import { push } from 'notivue'
import BackButton from '@/components/common/BackButton/index.vue' import BackButton from '@/components/common/BackButton/index.vue'
const columnStore = useColumnStore() import ArticleContent from '@/components/common/ArticleContent/index.vue'
const { columnList } = storeToRefs(columnStore) import QuestionCard from '@/components/common/QuestionCard/index.vue'
const interviewStore = useInterviewStore() import { useUserStore } from '@/stores/user'
const { interviewList } = storeToRefs(interviewStore) import { storeToRefs } from 'pinia'
const userStore = useUserStore() import type { ArticleTypeFormExpose } from './types'
const { userInfo } = storeToRefs(userStore) import PostForm from './components/PostForm.vue'
import ColumnForm from './components/ColumnForm.vue'
import InterviewForm from './components/InterviewForm.vue'
import QuestionForm from './components/QuestionForm.vue'
import PracticeForm from './components/PracticeForm.vue'
const loading = ref(false) const loading = ref(false)
const router = useRouter() const router = useRouter()
...@@ -398,169 +178,32 @@ const type = route.params.type as ArticleTypeEnum ...@@ -398,169 +178,32 @@ const type = route.params.type as ArticleTypeEnum
const text = articleTypeListOptions.find((item) => item.value === type)?.label const text = articleTypeListOptions.find((item) => item.value === type)?.label
const canPublish = computed(() => { const canPublish = computed(() => {
return form.value.title && form.value.content return sharedForm.value.title && sharedForm.value.content
}) })
const relateColumnList = computed(() =>
type === ArticleTypeEnum.COLUMN ? columnList.value : interviewList.value,
)
// 抽屉控制 // 抽屉控制
const drawerVisible = ref(false) const drawerVisible = ref(false)
const formRef = useTemplateRef<FormInstance>('formRef') // 公共字段:只有标题、内容——这两个是主编辑区自己的,不属于抽屉表单,其它字段都在各类型组件自己的表单里
const [form, resetForm] = useResetData({ const [sharedForm, resetSharedForm] = useResetData({
id: undefined as number | undefined, id: undefined as number | undefined,
type,
title: '', title: '',
content: '', content: '',
faceUrl: '',
imgUrl: '',
relateColumnId: '' as string | number,
mainTagId: '',
tagList: [] as number[],
isRelateColleague: BooleanFlag.NO,
isRecommend: BooleanFlag.NO,
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
releaseStatus: ReleaseStatusTypeEnum.PUBLISH,
regionHide: BooleanFlag.NO,
isPushAll: BooleanFlag.YES,
// 推送设置
pushType: SendTypeEnum.IMMEDIATE,
pushTime: '',
pushList: [],
summary: '',
// 视频
videoLocation: VideoPositionEnum.TOP,
articleVideoUrl: '',
deptId: '',
}) })
// 时间选择只允许整十分(:00、:10、:20、:30、:40、:50),秒固定为 0 // 每个类型自己的表单组件,字段/校验/提交数据都在对应组件里,这里只认 ArticleTypeFormExpose 这个接口
const disabledSecondsOnlyZero = () => Array.from({ length: 59 }, (_, i) => i + 1) const subFormRef = useTemplateRef<ArticleTypeFormExpose>('subFormRef')
const disabledMinutesOnlyTens = () =>
Array.from({ length: 60 }, (_, i) => i).filter((m) => m % 10 !== 0)
const rules: Record<string, FormItemRule[]> = {
title: [{ required: true, message: '请输入文章标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入文章内容', trigger: 'blur' }],
type: [{ required: true, message: '请选择文章类型', trigger: 'blur' }],
// faceUrl: [{ required: true, message: '请上传封面图', trigger: 'blur' }],
imgUrl: [{ required: true, message: '请上传内容图片', trigger: 'blur' }],
sendType: [{ required: true, message: '请选择发布类型', trigger: 'blur' }],
sendTime: [
{ required: true, message: '请选择发布时间', trigger: 'trigger' },
{
validator: (rule, value, callback) => {
if (form.value.sendType === SendTypeEnum.SCHEDULED && !value) {
callback(new Error('请选择发布时间'))
} else {
callback()
}
},
trigger: 'trigger',
},
],
releaseStatus: [{ required: true, message: '请选择发布状态', trigger: 'blur' }],
mainTagId: [{ required: true, message: '请选择主标签', trigger: 'blur' }],
isRecommend: [{ required: true, message: '是否推荐', trigger: 'trigger' }],
isRelateColleague: [{ required: true, message: '是否同步同事吧', trigger: 'trigger' }],
relateColumnId: [{ required: true, message: '请选择对应的栏目', trigger: 'trigger' }],
regionHide: [{ required: true, message: '请选择是否隐藏IP信息', trigger: 'trigger' }],
// 推送设置
pushType: [{ required: true, message: '请选择推送类型', trigger: 'trigger' }],
pushTime: [{ required: true, message: '请选择推送时间', trigger: 'trigger' }],
pushList: [{ required: true, message: '请选择推送对象', trigger: 'trigger' }],
}
// 选中的部门 // el-drawer 的内容要第一次打开时才会渲染,编辑回显时子表单可能还没挂载(subFormRef 是 null),
const selectedDepts = ref<{ id: string; name: string }[]>([]) // 直接 setEchoData 会被静默跳过。先把详情存着,等子表单挂载出来再回填
// 选中的成员 let pendingEchoData: ArticleItemDto | null = null
const selectedUsers = ref<{ id: string; name: string }[]>([]) watch(subFormRef, (form) => {
const selectedText = computed(() => { if (form && pendingEchoData) {
let text = '' form.setEchoData(pendingEchoData)
if (selectedDepts.value.length > 0) { pendingEchoData = null
text += `已选择${selectedDepts.value.length}个部门`
}
if (selectedUsers.value.length > 0) {
text += `已选择${selectedUsers.value.length}个成员`
} }
return text
}) })
const handleSelectUserAndDept = async () => {
const { departmentList, userList } = await selectDepOrUser({
mode: 'multi',
type: ['user', 'department'],
selectedDepartmentIds: selectedDepts.value.map((item) => item.id),
selectedUserIds: selectedUsers.value.map((item) => item.id),
})
selectedDepts.value = departmentList.map((item) => ({
id: item.id,
name: item.name,
}))
selectedUsers.value = userList.map((item) => ({
id: item.id,
name: item.name,
}))
}
const filterTagsFn = (allTags: any[]) => {
return allTags.filter((tag) => tag.id !== Number(form.value.mainTagId))
}
const transFormData = (releaseStatus: ReleaseStatusTypeEnum) => {
const { tagList, ...data } = form.value as any
data.tagList = [data.mainTagId, ...tagList].map((item, index) => ({
tagId: Number(item),
sort: index,
}))
data.releaseStatus = releaseStatus
if (data.type === ArticleTypeEnum.PRACTICE || data.type === ArticleTypeEnum.QUESTION) {
// 手动设置一下封面图
if (data.imgUrl) {
data.faceUrl = data.imgUrl.split(',')[0] || ''
}
}
if (
(data.type === ArticleTypeEnum.COLUMN ||
data.type === ArticleTypeEnum.INTERVIEW ||
data.type === ArticleTypeEnum.POST ||
data.type === ArticleTypeEnum.QUESTION) &&
userInfo.value.isOfficialAccount
) {
if (data.isPushAll === BooleanFlag.YES) {
data.pushList = [
{
valueId: '1',
valueType: 1,
valueName: '亚声威格',
},
]
} else {
data.pushList = [
...selectedDepts.value.map((item) => ({
valueId: item.id,
valueType: 1,
valueName: item.name,
})),
...selectedUsers.value.map((item) => ({
valueId: item.id,
valueType: 2,
valueName: item.name,
})),
]
}
}
if (data.type === ArticleTypeEnum.PRACTICE && userInfo.value.isOfficialAccount) {
data.deptId = data.deptId || ''
}
return data
}
// 打开抽屉 // 打开抽屉
const openDrawer = () => { const openDrawer = () => {
drawerVisible.value = true drawerVisible.value = true
...@@ -571,28 +214,72 @@ const handleDrawerClose = () => { ...@@ -571,28 +214,72 @@ const handleDrawerClose = () => {
drawerVisible.value = false drawerVisible.value = false
} }
// 预览:用当前编辑器里的标题/内容和自己的头像昵称拼一份假的文章详情,交给 ArticleContent 渲染。
// 标签、浏览量这些要后端数据的部分预览里没有,也不需要
const previewing = ref(false)
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
// 抽屉里也能预览:关掉抽屉进预览,抽屉里已填的内容不会丢(子表单没销毁),预览里点发布会再打开
const handlePreviewFromDrawer = () => {
drawerVisible.value = false
previewing.value = true
}
const previewDetail = computed(() => {
// 实践/问吧的配图在抽屉子表单里,拿得到就带上
const imgUrl = subFormRef.value?.getSubmitData().imgUrl as string | undefined
const isReal = [
ArticleTypeEnum.PRACTICE,
ArticleTypeEnum.INTERVIEW,
ArticleTypeEnum.QUESTION,
].includes(type)
return {
type,
title: sharedForm.value.title,
content: sharedForm.value.content,
imgUrl,
imgUrlList: imgUrl?.split(',').filter(Boolean) ?? [],
createTime: Math.floor(Date.now() / 1000),
viewCount: 0,
createUserName: isReal ? userInfo.value.name : userInfo.value.hiddenName,
createUserAvatar: isReal ? userInfo.value.avatar : userInfo.value.hiddenAvatar,
tagNameList: [],
tagIdList: [],
} as unknown as ArticleItemDto
})
// 取消按钮 // 取消按钮
const handleClosed = () => { const handleClosed = () => {
resetForm() resetSharedForm()
subFormRef.value?.reset()
router.back() router.back()
} }
// 提交表单 // 提交表单
const handleSubmit = async (releaseStatus: ReleaseStatusTypeEnum) => { const handleSubmit = async (releaseStatus: ReleaseStatusTypeEnum) => {
try { try {
const validateRes = await formRef.value?.validate() await subFormRef.value?.validate()
console.log(validateRes, 'validateRes')
loading.value = true loading.value = true
if (form.value.type === ArticleTypeEnum.PRACTICE) {
await addOrUpdatePractice(transFormData(releaseStatus)) const data = {
id: sharedForm.value.id,
type,
title: sharedForm.value.title,
content: sharedForm.value.content,
releaseStatus,
...subFormRef.value?.getSubmitData(),
}
if (type === ArticleTypeEnum.PRACTICE) {
await addOrUpdatePractice(data as any)
} else { } else {
await addOrUpdateArticle(transFormData(releaseStatus)) await addOrUpdateArticle(data as any)
} }
drawerVisible.value = false drawerVisible.value = false
push.success(releaseStatus === ReleaseStatusTypeEnum.PUBLISH ? '发布成功' : '存草稿成功') push.success(releaseStatus === ReleaseStatusTypeEnum.PUBLISH ? '发布成功' : '存草稿成功')
resetForm() resetSharedForm()
subFormRef.value?.reset()
router.back() router.back()
// 发布成功后的逻辑...
} catch (error) { } catch (error) {
console.log(error) console.log(error)
} finally { } finally {
...@@ -600,98 +287,32 @@ const handleSubmit = async (releaseStatus: ReleaseStatusTypeEnum) => { ...@@ -600,98 +287,32 @@ const handleSubmit = async (releaseStatus: ReleaseStatusTypeEnum) => {
} }
} }
const key = ref(0) // WangEditor 回显时偶尔不刷新,靠 key 强制重新渲染一下
const editorKey = ref(0)
const isEdit = computed(() => !!route.query.id) const isEdit = computed(() => !!route.query.id)
onActivated(async () => { onActivated(async () => {
key.value++ previewing.value = false
resetForm() editorKey.value++
selectedDepts.value = [] resetSharedForm()
selectedUsers.value = [] subFormRef.value?.reset()
pendingEchoData = null
await nextTick() await nextTick()
if (isEdit.value) { if (!isEdit.value) return
// 要编辑回显
const { data } = await getArticleDetail(route.query.id as string)
// 1首先回显基础的信息
// 标题 内容 主标签 副标签 所属栏目 是否推荐 是否同步同事吧 发布时间 发布状态
const {
id,
title,
content,
relateColumnId,
isRecommend,
isRelateColleague,
sendType,
sendTime,
tagIdList,
regionHide,
} = data
form.value = {
...form.value,
title,
content,
relateColumnId,
isRecommend,
isRelateColleague,
sendType,
sendTime,
id,
regionHide,
}
// 2回显主副标签
form.value.mainTagId = tagIdList[0] ? String(tagIdList[0]) : ''
form.value.tagList = tagIdList.slice(1) || []
// 3回显封面图内容图片 以及顶部 底部 视频等 // 编辑回显:标题/内容自己填,剩下的(包括发布时间/IP信息)都交给子组件的 setEchoData
const { imgUrl, faceUrl, articleVideoUrl } = data const { data } = await getArticleDetail(route.query.id as string)
sharedForm.value = {
if (type === ArticleTypeEnum.QUESTION || type === ArticleTypeEnum.PRACTICE) { ...sharedForm.value,
form.value.imgUrl = imgUrl id: data.id,
} else { title: data.title,
form.value.faceUrl = faceUrl content: data.content,
}
if (articleVideoUrl) {
form.value.articleVideoUrl = articleVideoUrl
} }
// 4回显推送人员设置 if (subFormRef.value) {
form.value.summary = data.summary subFormRef.value.setEchoData(data)
if (
(type === ArticleTypeEnum.COLUMN ||
type === ArticleTypeEnum.INTERVIEW ||
type === ArticleTypeEnum.POST ||
type === ArticleTypeEnum.QUESTION) &&
userInfo.value.isOfficialAccount
) {
if (data.pushList?.find((item) => item.valueId == '1')) {
form.value.isPushAll = BooleanFlag.YES
selectedDepts.value = []
selectedUsers.value = []
} else { } else {
form.value.isPushAll = BooleanFlag.NO pendingEchoData = data
selectedDepts.value =
data.pushList
?.filter((item) => item.valueType === 1)
.map((item) => ({
id: item.valueId,
name: item.valueName,
})) || []
selectedUsers.value =
data.pushList
?.filter((item) => item.valueType === 2)
.map((item) => ({
id: item.valueId,
name: item.valueName,
})) || []
}
}
if (userInfo.value.isOfficialAccount && form.value.type === ArticleTypeEnum.PRACTICE) {
form.value.deptId = data.deptId === '0' ? '' : data.deptId
}
} }
}) })
</script> </script>
......
import type { ArticleItemDto } from '@/api/article/types'
/**
* 每个文章类型子表单组件(PostForm/ColumnForm/InterviewForm/QuestionForm/PracticeForm)
* 都要通过 defineExpose 暴露这一套方法,外层 publishLongArticle/index.vue 只认这个接口,
* 不关心某个类型内部具体有哪些字段——加/改某个类型的专属字段,只改对应的子组件就行。
*/
export interface ArticleTypeFormExpose {
/** 校验自己负责的字段,跟 el-form.validate() 一样,校验不通过会 reject */
validate: () => Promise<unknown>
/** 提交时调用,返回自己负责的字段,外层会跟公共字段(标题/内容/发布时间/IP信息等)合并后一起提交 */
getSubmitData: () => Record<string, unknown>
/** 编辑回显时调用,传入后端返回的文章详情,自己挑需要的字段回显 */
setEchoData: (data: ArticleItemDto) => void
/** 重置自己负责的字段(取消、或者 keep-alive 场景下再次打开同一类型的发布页时调用) */
reset: () => void
}
...@@ -100,6 +100,28 @@ ...@@ -100,6 +100,28 @@
</el-form-item> </el-form-item>
</div> </div>
<!-- 视频分类:非必选,最多5个,普通用户和管理员都能选,不受 showSpecialInput 限制 -->
<div class="mb-8">
<el-form-item prop="categoryIdList">
<label class="block text-sm font-semibold text-gray-700 mb-3">视频分类</label>
<el-select
v-model="form.categoryIdList"
multiple
:multiple-limit="5"
collapse-tags-tooltip
placeholder="选择视频分类(非必选,最多5个)"
class="w-full"
>
<el-option
v-for="item in categoryOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</div>
<template v-if="showSpecialInput"> <template v-if="showSpecialInput">
<div class="mb-8"> <div class="mb-8">
<el-form-item prop="mainTagId"> <el-form-item prop="mainTagId">
...@@ -156,24 +178,28 @@ ...@@ -156,24 +178,28 @@
<div class="mb-8"> <div class="mb-8">
<el-form-item> <el-form-item>
<label class="block text-sm font-semibold text-gray-700 mb-3"> 推送对象 </label> <label class="block text-sm font-semibold text-gray-700 mb-3"> 推送对象 </label>
<div class="w-full flex items-center gap-2"> <div class="w-full">
<el-radio-group v-model="form.isPushAll"> <el-radio-group v-model="form.isPushAll">
<el-radio :value="BooleanFlag.YES">全员</el-radio> <el-radio :value="BooleanFlag.YES">全员</el-radio>
<el-radio :value="BooleanFlag.NO">指定部门/人员</el-radio> <el-radio :value="BooleanFlag.NO">指定部门/人员</el-radio>
</el-radio-group> </el-radio-group>
<div
v-show="form.isPushAll === BooleanFlag.NO"
class="flex items-center gap-2"
>
<el-button
class="button-new-tag"
size="small"
@click="handleSelectUserAndDept"
>
+ 添加部门
</el-button>
<span class="ml-2 text-sm text-gray-600"> {{ selectedText }}</span>
</div> </div>
<!-- 选人输入框单独放一行,跟帖子/专栏等发布页的推送设置保持一致 -->
<div v-show="form.isPushAll === BooleanFlag.NO" class="w-full mt-2">
<DeptUserPicker
v-model:dept-value="pushDeptIds"
v-model:user-value="pushUserIds"
selection-mode="both"
@confirm="(items) => (pushSelection = items)"
v-slot="{ echoText }"
>
<el-input
readonly
class="w-80 cursor-pointer"
placeholder="请选择部门/人员"
:model-value="echoText"
/>
</DeptUserPicker>
</div> </div>
</el-form-item> </el-form-item>
</div> </div>
...@@ -385,12 +411,19 @@ ...@@ -385,12 +411,19 @@
import UploadVideo from '@/components/common/UploadVideo/index.vue' import UploadVideo from '@/components/common/UploadVideo/index.vue'
import { useResetData } from '@/hooks' import { useResetData } from '@/hooks'
import { ArticleTypeEnum, ReleaseStatusTypeEnum, SendTypeEnum, BooleanFlag } from '@/constants' import { ArticleTypeEnum, ReleaseStatusTypeEnum, SendTypeEnum, BooleanFlag } from '@/constants'
import { addOrUpdateArticle, uploadFile, getArticleDetail,resolveUploadBaseUrl } from '@/api' import {
addOrUpdateArticle,
uploadFile,
getArticleDetail,
resolveUploadBaseUrl,
listVideoCategoryPublic,
} from '@/api'
import SelectTags from '@/components/common/SelectTags/index.vue' import SelectTags from '@/components/common/SelectTags/index.vue'
import type { TagItemDto, AddOrUpdateVideoDto} from '@/api' import type { TagItemDto, AddOrUpdateVideoDto, VideoCategoryItemDto } from '@/api'
import { useVideoStore, useUserStore } from '@/stores' import { useVideoStore, useUserStore } from '@/stores'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { selectDepOrUser } from '@/utils' import DeptUserPicker from '@/components/common/DeptUserPicker/index.vue'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
import BackButton from '@/components/common/BackButton/index.vue' import BackButton from '@/components/common/BackButton/index.vue'
import { push } from 'notivue' import { push } from 'notivue'
...@@ -422,6 +455,7 @@ const [form, resetData] = useResetData({ ...@@ -422,6 +455,7 @@ const [form, resetData] = useResetData({
faceUrl: '', // 封面URL faceUrl: '', // 封面URL
videoDuration: '', videoDuration: '',
relateColumnId: '' as string | number, relateColumnId: '' as string | number,
categoryIdList: [] as number[],
regionHide: BooleanFlag.NO, regionHide: BooleanFlag.NO,
isPushAll: BooleanFlag.YES, isPushAll: BooleanFlag.YES,
// 推送设置 // 推送设置
...@@ -436,33 +470,30 @@ const disabledSecondsOnlyZero = () => Array.from({ length: 59 }, (_, i) => i + 1 ...@@ -436,33 +470,30 @@ const disabledSecondsOnlyZero = () => Array.from({ length: 59 }, (_, i) => i + 1
const disabledMinutesOnlyTens = () => const disabledMinutesOnlyTens = () =>
Array.from({ length: 60 }, (_, i) => i).filter((m) => m % 10 !== 0) Array.from({ length: 60 }, (_, i) => i).filter((m) => m % 10 !== 0)
const selectedDepts = ref<{ id: string; name: string }[]>([]) // “推送给”选中的部门/人员:*Ids 是给 DeptUserPicker 用的 id 数组,
const selectedUsers = ref<{ id: string; name: string }[]>([]) // pushSelection 是 @confirm 拿到的完整详情(带名字),提交时直接用它拼 pushList。
const selectedText = computed(() => { const pushDeptIds = ref<string[]>([])
let text = '' const pushUserIds = ref<string[]>([])
if (selectedDepts.value.length > 0) { const pushSelection = ref<DepartmentUserSelection[]>([])
text += `已选择${selectedDepts.value.length}个部门`
} // 视频分类下拉:不管是普通用户还是管理员,统一只展示公开分类——管理员如果选了不公开的分类,
if (selectedUsers.value.length > 0) { // 这个分类在前台分类栏不展示,用户会一脸懵(产品那边确认过,统一改成只看公开的)。
text += `已选择${selectedUsers.value.length}个成员` // 编辑回显时,如果这个视频本身挂着已被设为不公开的分类,需要把它们也并进选项里,
} // 不然 el-select 只会显示 id 而不是名字
return text const categoryOptions = ref<VideoCategoryItemDto[]>([])
onMounted(async () => {
const { data } = await listVideoCategoryPublic()
categoryOptions.value = data
}) })
const handleSelectUserAndDept = async () => { const mergeHiddenCategoryOptions = (categoryIdList?: number[], categoryNameList?: string[]) => {
const { departmentList, userList } = await selectDepOrUser({ const hidden = (categoryIdList || [])
mode: 'multi', .map((id, index) => ({ id, name: categoryNameList?.[index] }))
type: ['user', 'department'], .filter(
selectedDepartmentIds: selectedDepts.value.map((item) => item.id), (item): item is { id: number; name: string } =>
selectedUserIds: selectedUsers.value.map((item) => item.id), !!item.name && !categoryOptions.value.some((option) => option.id === item.id),
}) )
selectedDepts.value = departmentList.map((item) => ({ .map((item) => ({ id: item.id, name: item.name, sort: 0, isPublic: 0 }) as VideoCategoryItemDto)
id: item.id, if (hidden.length) categoryOptions.value = [...categoryOptions.value, ...hidden]
name: item.name,
}))
selectedUsers.value = userList.map((item) => ({
id: item.id,
name: item.name,
}))
} }
// 封面选择相关 // 封面选择相关
...@@ -588,18 +619,11 @@ const tansformData = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdateVideoDto ...@@ -588,18 +619,11 @@ const tansformData = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdateVideoDto
}, },
] ]
} else { } else {
data.pushList = [ data.pushList = pushSelection.value.map((item) => ({
...selectedDepts.value.map((item) => ({ valueId: item.valueId,
valueId: item.id, valueType: item.valueType,
valueType: 1, valueName: item.valueName,
valueName: item.name, }))
})),
...selectedUsers.value.map((item) => ({
valueId: item.id,
valueType: 2,
valueName: item.name,
})),
]
} }
} }
return data as AddOrUpdateVideoDto return data as AddOrUpdateVideoDto
...@@ -607,8 +631,9 @@ const tansformData = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdateVideoDto ...@@ -607,8 +631,9 @@ const tansformData = (releaseStatus: ReleaseStatusTypeEnum): AddOrUpdateVideoDto
const loading = ref(false) const loading = ref(false)
const resetPageData = () => { const resetPageData = () => {
selectedDepts.value = [] pushDeptIds.value = []
selectedUsers.value = [] pushUserIds.value = []
pushSelection.value = []
resetData() resetData()
locationVideoBlolUrl.value = '' locationVideoBlolUrl.value = ''
} }
...@@ -672,28 +697,25 @@ onActivated(async () => { ...@@ -672,28 +697,25 @@ onActivated(async () => {
...data, ...data,
mainTagId: data.tagIdList?.[0] ? String(data.tagIdList[0]) : '', mainTagId: data.tagIdList?.[0] ? String(data.tagIdList[0]) : '',
tagList: data.tagIdList?.slice(1) || [], tagList: data.tagIdList?.slice(1) || [],
categoryIdList: data.categoryIdList || [],
} }
mergeHiddenCategoryOptions(data.categoryIdList, data.categoryNameList)
// 回显推送的部门 这个后端没有返回相关数据 需要返回 // 回显推送的部门 这个后端没有返回相关数据 需要返回
if (data.pushList?.find((item) => item.valueId == '1')) { if (data.pushList?.find((item) => item.valueId == '1')) {
form.value.isPushAll = BooleanFlag.YES form.value.isPushAll = BooleanFlag.YES
selectedDepts.value = [] pushDeptIds.value = []
selectedUsers.value = [] pushUserIds.value = []
pushSelection.value = []
} else { } else {
form.value.isPushAll = BooleanFlag.NO form.value.isPushAll = BooleanFlag.NO
selectedDepts.value = const items: DepartmentUserSelection[] = (data.pushList || []).map((item) => ({
data.pushList valueType: item.valueType as 1 | 2,
?.filter((item) => item.valueType === 1) valueId: String(item.valueId),
.map((item) => ({ valueName: item.valueName,
id: item.valueId, }))
name: item.valueName, pushDeptIds.value = items.filter((item) => item.valueType === 1).map((item) => item.valueId)
})) || [] pushUserIds.value = items.filter((item) => item.valueType === 2).map((item) => item.valueId)
selectedUsers.value = pushSelection.value = items
data.pushList
?.filter((item) => item.valueType === 2)
.map((item) => ({
id: item.valueId,
name: item.valueName,
})) || []
} }
} }
}) })
......
...@@ -2,244 +2,21 @@ ...@@ -2,244 +2,21 @@
<div class="min-h-screen pb-10 text-slate-800 flex justify-center sm:px-20"> <div class="min-h-screen pb-10 text-slate-800 flex justify-center sm:px-20">
<div class="w-full space-y-4"> <div class="w-full space-y-4">
<!-- 1. 问题卡片 --> <!-- 1. 问题卡片 -->
<div <QuestionCard
class="bg-white rounded-lg p-6 shadow-[0_1px_3px_rgba(0,0,0,0.02)] border border-slate-100 relative" :questionDetail="questionDetail"
:isAuthor="isAuthor"
@write="openCommentDialog"
@like="handleLikeArticle"
@collect="handleCollectArticle"
@share="handleShareQuestion"
@message="openSendMessage"
> >
<BackButton /> <BackButton />
<el-skeleton </QuestionCard>
:rows="5"
animated
:loading="loading"
:throttle="{ leading: 0, trailing: 300 }"
>
<template #template>
<!-- 顶部标签行 -->
<div class="flex flex-wrap gap-2 mb-4 justify-between">
<!-- 标签 -->
<div class="flex gap-2">
<el-skeleton-item
v-for="i in 3"
:key="i"
variant="text"
style="width: 60px; height: 18px; border-radius: 9999px"
/>
</div>
<!-- 编辑按钮 -->
<el-skeleton-item variant="text" style="width: 40px; height: 18px" />
</div>
<!-- 发布人信息 -->
<div class="flex items-center gap-3 pb-3 border-b border-slate-100">
<!-- 头像 -->
<el-skeleton-item variant="circle" style="width: 40px; height: 40px" />
<!-- 用户名 + 时间 -->
<div class="flex flex-col gap-2">
<el-skeleton-item variant="text" style="width: 100px; height: 14px" />
<el-skeleton-item variant="text" style="width: 180px; height: 12px" />
</div>
</div>
<!-- 标题 -->
<div class="mt-3 mb-3">
<el-skeleton-item variant="text" style="width: 90%; height: 28px" />
</div>
<!-- 内容描述 -->
<div class="space-y-2">
<el-skeleton-item variant="text" style="width: 100%" />
<el-skeleton-item variant="text" style="width: 95%" />
<el-skeleton-item variant="text" style="width: 85%" />
</div>
<!-- 图片列表 -->
<!-- <div class="mt-3 flex gap-2 flex-wrap">
<el-skeleton-item
v-for="i in 3"
:key="i"
variant="image"
style="width: 96px; height: 96px; border-radius: 8px"
/>
</div> -->
<!-- 底部操作栏 -->
<div class="flex items-center justify-between mt-4">
<!-- 写回答按钮 -->
<el-skeleton-item variant="button" style="width: 88px; height: 32px" />
<!-- 右侧点赞 / 收藏 -->
<div class="flex items-center gap-6">
<el-skeleton-item variant="text" style="width: 48px; height: 16px" />
<el-skeleton-item variant="text" style="width: 48px; height: 16px" />
</div>
</div>
</template>
<template #default>
<!-- 顶部标签行 -->
<div class="flex flex-wrap gap-2 mb-4 justify-between">
<div>
<span
v-for="tag in questionDetail.tagNameList"
:key="tag"
class="px-2.5 py-0.5 rounded-full bg-blue-50 text-blue-500 text-xs font-semibold hover:bg-blue-100 transition-colors cursor-pointer mr-2"
>
#{{ tag }}
</span>
</div>
<!-- 后面加一个编辑 -->
<el-link
v-if="isAuthor"
type="primary"
:underline="false"
@click="
router.push(`/publishLongArticle/${questionDetail.type}?id=${questionDetail.id}`)
"
class="text-sm"
>
编辑
</el-link>
</div>
<!-- 👇 新增:发布人信息 -->
<div class="flex items-center gap-3 pb-3 border-b border-slate-100">
<el-avatar
:src="questionDetail.createUserAvatar"
:size="40"
class="flex-shrink-0 cursor-pointer"
@click="
jumpToUserHomePage({
userId: questionDetail.createUserId,
type: ArticleTypeEnum.QUESTION,
})
"
>
{{ questionDetail.createUserName }}
</el-avatar>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-sm text-slate-900">
{{ questionDetail.createUserName }}
</span>
<span class="text-sm text-slate-500 flex items-center gap-1">
{{ dayjs(questionDetail.createTime * 1000).format('YYYY-MM-DD HH:mm') }} ·
<span
v-if="questionDetail?.region"
class="text-sm text-slate-500 flex items-center gap-1"
>
{{ questionDetail.region }}
·
</span>
{{ questionDetail.viewCount }}阅读
</span>
</div>
<button
v-if="!isAuthor && questionDetail.createUserId"
type="button"
class="shrink-0 inline-flex items-center px-3 py-1.5 text-xs font-semibold text-blue-600 bg-blue-50 rounded-lg border border-blue-100 hover:bg-blue-100 hover:border-blue-200/80 active:scale-[0.98] transition-all cursor-pointer"
@click="
sendMessageDialogRef?.open({
receiverId: String(questionDetail.createUserId),
isReal: BooleanFlag.YES,
})
"
>
私信
</button>
</div>
<!-- 标题:主要信息,黑重粗 -->
<h1 class="text-2xl font-bold text-slate-900 mb-3 leading-snug tracking-tight">
{{ questionDetail.title }}
</h1>
<!-- 描述:次要信息,深灰 -->
<div class="text-gray-600 text-base leading-relaxed transition-all duration-300">
<div :class="{ 'line-clamp-3': !isExpand }" ref="questionContentRef">
{{ questionDetail.content }}
</div>
<button
v-if="isOverThreeLine"
class="text-blue-500 text-sm font-medium hover:text-blue-600 mt-1 flex items-center gap-0.5 cursor-pointer"
@click="isExpand = !isExpand"
>
{{ isExpand ? '收起' : '显示全部' }}
<el-icon :class="{ 'rotate-180': isExpand }" class="transition-transform"
><IEpCaretBottom
/></el-icon>
</button>
</div>
<div
v-if="questionDetail.imgUrlList?.length"
class="mt-3 flex gap-2 flex-wrap items-center justify-start"
>
<el-image
v-for="(item, i) in questionDetail.imgUrlList"
:key="item"
:src="item"
fit="cover"
class="rounded-lg w-24 h-24 hover:scale-105 transition-transform cursor-pointer"
:preview-src-list="questionDetail.imgUrlList"
:initial-index="i"
:preview-teleported="true"
/>
</div>
<!-- 底部操作栏 -->
<div class="flex items-center justify-between mt-4">
<div class="flex gap-3">
<button
class="px-5 py-1.5 border !bg-blue-500 !text-white rounded-[4px] text-sm font-medium transition-colors flex items-center gap-1 cursor-pointer"
@click="openCommentDialog"
>
<el-icon><IEpEditPen /></el-icon>
写回答
</button>
</div>
<!-- 右侧数据 -->
<div class="flex items-center gap-1 text-slate-500 text-base select-none">
<el-button
text
class="!ml-0 flex items-center gap-1 transition-colors hover:text-blue-500"
:class="{ '!text-blue-500': questionDetail?.hasPraised }"
@click="handleLikeArticle"
>
<el-icon size="17">
<svg-icon
:name="questionDetail?.hasPraised ? 'praise_fill' : 'praise'"
></svg-icon>
</el-icon>
<span class="text-15px">{{ questionDetail?.praiseCount || 0 }}</span>
</el-button>
<el-button
text
class="!ml-0 flex items-center gap-1 transition-colors hover:text-blue-500"
:class="{ '!text-blue-500': questionDetail?.hasCollect }"
@click="handleCollectArticle"
>
<el-icon size="17">
<svg-icon
:name="questionDetail?.hasCollect ? 'collection_fill' : 'collection'"
></svg-icon>
</el-icon>
<span class="text-15px">{{ questionDetail?.collectionCount || 0 }}</span>
</el-button>
<el-button
text
class="!ml-0 flex items-center gap-2 transition-colors hover:text-blue-500"
@click="handleShareQuestion"
>
<el-icon size="17">
<svg-icon name="share" size="15"></svg-icon>
</el-icon>
<span class="text-15px">分享</span>
</el-button>
</div>
</div>
</template>
</el-skeleton>
</div>
<!-- 2. 列表控制栏 --> <!-- 2. 列表控制栏 -->
<div <div
ref="answerListRef"
class="bg-white rounded-lg border-b border-slate-100 p-6 flex justify-between items-center shadow-sm" class="bg-white rounded-lg border-b border-slate-100 p-6 flex justify-between items-center shadow-sm"
> >
<h3 class="font-bold text-slate-800 text-base">{{ total }} 个回答</h3> <h3 class="font-bold text-slate-800 text-base">{{ total }} 个回答</h3>
...@@ -421,7 +198,7 @@ ...@@ -421,7 +198,7 @@
:ref="(e) => (commentRefList[index] = e as InstanceType<typeof Comment>)" :ref="(e) => (commentRefList[index] = e as InstanceType<typeof Comment>)"
:id="questionId" :id="questionId"
:total="answer.childrenNum" :total="answer.childrenNum"
:defaultSize="5" :defaultSize="ANSWER_COMMENT_PAGE_SIZE"
:type="ArticleTypeEnum.QUESTION" :type="ArticleTypeEnum.QUESTION"
:immediate="false" :immediate="false"
:isQuestion="true" :isQuestion="true"
...@@ -440,7 +217,7 @@ ...@@ -440,7 +217,7 @@
:total="total" :total="total"
layout="prev, pager, next,total" layout="prev, pager, next,total"
small small
@current-change="goToPage" @current-change="handlePageChange"
/> />
</div> </div>
</div> </div>
...@@ -457,18 +234,20 @@ import { ...@@ -457,18 +234,20 @@ import {
addOrCancelCommentLike, addOrCancelCommentLike,
topOrCancelTopComment, topOrCancelTopComment,
deleteComment, deleteComment,
locateComment as locateCommentApi,
} from '@/api' } from '@/api'
import type { ArticleItemDto, CommentItemDto } from '@/api' import type { ArticleItemDto, CommentItemDto, CommentLocationDto } from '@/api'
import { usePageSearch } from '@/hooks' import { usePageSearch, scrollToLocate } from '@/hooks'
import { CHILD_PAGE_SIZE } from '@/components/common/Comment/constants'
import Comment from '@/components/common/Comment/index.vue' import Comment from '@/components/common/Comment/index.vue'
import CommentDialog from '@/components/common/CommentDialog/index.vue' import CommentDialog from '@/components/common/CommentDialog/index.vue'
import SendMessageDialog from '@/components/common/SendMessageDialog/index.vue' import SendMessageDialog from '@/components/common/SendMessageDialog/index.vue'
import BackButton from '@/components/common/BackButton/index.vue' import BackButton from '@/components/common/BackButton/index.vue'
import QuestionCard from '@/components/common/QuestionCard/index.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useNavigation, useScrollTop } from '@/hooks' import { useMessageBox, useNavigation, useScrollTop } from '@/hooks'
import { useMessageBox } from '@/hooks'
import { parseEmoji } from '@/utils/emoji' import { parseEmoji } from '@/utils/emoji'
import { isWxShareCancel, wxShare } from '@/utils/wxUtil' import { isWxShareCancel, wxShare } from '@/utils/wxUtil'
import { ArticleTypeEnum, BooleanFlag } from '@/constants' import { ArticleTypeEnum, BooleanFlag } from '@/constants'
...@@ -480,11 +259,11 @@ const { userInfo } = storeToRefs(userStore) ...@@ -480,11 +259,11 @@ const { userInfo } = storeToRefs(userStore)
const { jumpToUserHomePage } = useNavigation() const { jumpToUserHomePage } = useNavigation()
const { confirm } = useMessageBox() const { confirm } = useMessageBox()
const route = useRoute() const route = useRoute()
const router = useRouter()
const questionId = Number(route.params.id) const questionId = Number(route.params.id)
const commentRefList = ref<InstanceType<typeof Comment>[]>([]) const commentRefList = ref<InstanceType<typeof Comment>[]>([])
const answerRefList = ref<HTMLElement[]>([]) const answerRefList = ref<HTMLElement[]>([])
const answerListRef = useTemplateRef<HTMLElement>('answerListRef')
const questionDetail = ref<ArticleItemDto>({} as ArticleItemDto) const questionDetail = ref<ArticleItemDto>({} as ArticleItemDto)
const commentDialogRef = useTemplateRef<typeof CommentDialog>('commentDialogRef') const commentDialogRef = useTemplateRef<typeof CommentDialog>('commentDialogRef')
const sendMessageDialogRef = useTemplateRef<InstanceType<typeof SendMessageDialog> | null>( const sendMessageDialogRef = useTemplateRef<InstanceType<typeof SendMessageDialog> | null>(
...@@ -493,25 +272,14 @@ const sendMessageDialogRef = useTemplateRef<InstanceType<typeof SendMessageDialo ...@@ -493,25 +272,14 @@ const sendMessageDialogRef = useTemplateRef<InstanceType<typeof SendMessageDialo
// 回滚到子评论框 // 回滚到子评论框
const { handleBackTop: handleBackTopChildren } = useScrollTop(answerRefList) const { handleBackTop: handleBackTopChildren } = useScrollTop(answerRefList)
const loading = computed(() => !questionDetail.value.title) // 翻页时滚回到回答列表顶部
const { handleBackTop: handleBackTopAnswerList } = useScrollTop(answerListRef)
const isSelfAnswer = (answer: CommentItemDto) => const isSelfAnswer = (answer: CommentItemDto) =>
String(answer.userId || '') === String(userInfo.value.userId || '') String(answer.userId || '') === String(userInfo.value.userId || '')
const isAuthor = computed(() => { const isAuthor = computed(() => {
return questionDetail.value.createUserId === userInfo.value.userId return questionDetail.value.createUserId === userInfo.value.userId
}) })
const questionContentRef = useTemplateRef<HTMLElement>('questionContentRef')
const isExpand = ref(false)
// 检测当前是否超过三行 要用到具体的dom
const isOverThreeLine = computed(() => {
if (!questionContentRef.value || !questionDetail.value.content) return false
const lineHeight = parseFloat(getComputedStyle(questionContentRef.value).lineHeight)
const height = questionContentRef.value!.scrollHeight
const maxHeight = lineHeight * 3
return height > maxHeight
})
const getQuestionDetail = async () => { const getQuestionDetail = async () => {
const { data } = await getArticleDetail(questionId) const { data } = await getArticleDetail(questionId)
questionDetail.value = { questionDetail.value = {
...@@ -520,15 +288,23 @@ const getQuestionDetail = async () => { ...@@ -520,15 +288,23 @@ const getQuestionDetail = async () => {
} }
} }
// 每个回答下面内嵌评论区的每页条数,评论定位算页码时也要用同一个值
const ANSWER_COMMENT_PAGE_SIZE = 5
// 消息通知链接带的评论 id。点通知都是新开页面重新加载,所以挂载时读一次就够了;
// 有的话回答列表不先加载第一页,直接由定位去加载目标所在页
const initialCommentId = Number(route.query.commentId) || 0
const { const {
list, list,
total, total,
searchParams, searchParams,
goToPage, goToPage,
refresh, refresh,
search,
loading: commentLoading, loading: commentLoading,
} = usePageSearch(getCommentList, { } = usePageSearch(getCommentList, {
// immediate: false, immediate: !initialCommentId,
defaultParams: { defaultParams: {
articleId: questionId, articleId: questionId,
sortType: CommentSortTypeEnum.MOST_LIKE, sortType: CommentSortTypeEnum.MOST_LIKE,
...@@ -558,6 +334,11 @@ const changeSortType = (type: CommentSortTypeEnum) => { ...@@ -558,6 +334,11 @@ const changeSortType = (type: CommentSortTypeEnum) => {
refresh() refresh()
} }
const handlePageChange = async (page: number) => {
await goToPage(page)
handleBackTopAnswerList()
}
const handleLikeArticle = async () => { const handleLikeArticle = async () => {
await addOrCanceArticlelLike(questionId) await addOrCanceArticlelLike(questionId)
questionDetail.value.hasPraised = !questionDetail.value.hasPraised questionDetail.value.hasPraised = !questionDetail.value.hasPraised
...@@ -656,8 +437,93 @@ const openCommentDialog = () => { ...@@ -656,8 +437,93 @@ const openCommentDialog = () => {
commentDialogRef.value?.open(questionDetail.value.id) commentDialogRef.value?.open(questionDetail.value.id)
} }
const openSendMessage = () => {
sendMessageDialogRef.value?.open({
receiverId: String(questionDetail.value.createUserId),
isReal: BooleanFlag.YES,
})
}
// ==================== 评论定位(消息通知带 commentId 跳进来) ====================
// 问吧是三层:回答 → 回答下的评论 → 评论下的回复。回答这层由页面自己定位,
// 评论/回复那两层交给该回答内嵌的 Comment 组件(focusTarget),滚动只发生一次
const flashAnswer = async (answerId: number) => {
highlightCommentId.value = null
await nextTick()
highlightCommentId.value = answerId
setTimeout(() => {
if (highlightCommentId.value === answerId) {
highlightCommentId.value = null
}
}, 2400)
}
const locateAnswerComment = async (commentId: number) => {
let result: CommentLocationDto | undefined
try {
const { data } = await locateCommentApi({
commentId,
articleId: questionId,
sortType: searchParams.value.sortType,
size: searchParams.value.size,
// 内嵌评论区每次都按默认的「最高点赞」排,focusTarget 也会把它切回这个排序,页码才对得上
secondSortType: CommentSortTypeEnum.MOST_LIKE,
secondSize: ANSWER_COMMENT_PAGE_SIZE,
childSize: CHILD_PAGE_SIZE,
})
result = data
} catch (error) {
// 接口报错时全局拦截器已经弹过提示了
console.error(error)
}
// 定位不到也要把回答列表正常加载出来,不然进来看着像 0 个回答
if (!result?.found) {
if (result) push.warning(result.message || '没有找到这条评论')
await refresh()
return
}
const location = result
searchParams.value.current = location.page
await search()
const index = list.value.findIndex((answer: CommentItemDto) => answer.id === location.rootCommentId)
const answer = list.value[index]
if (!answer) {
push.warning('评论位置有变化,没有定位到这条评论')
return
}
// 目标就是回答本身
if (!location.secondCommentId) {
await nextTick()
const answerEl = answerRefList.value[index]
if (!answerEl) return
await scrollToLocate(answerEl)
flashAnswer(answer.id)
return
}
// 目标在回答下面:展开该回答的评论区,剩下的交给内嵌评论区
answer.showComment = true
await nextTick()
await commentRefList.value[index]?.focusTarget({
floorId: location.secondCommentId,
page: location.secondPage ?? 1,
sortType: CommentSortTypeEnum.MOST_LIKE,
targetId: location.targetCommentId,
childInPreview: location.childInPreview,
childPage: location.childPage,
})
}
onMounted(() => { onMounted(() => {
getQuestionDetail() getQuestionDetail()
if (initialCommentId) {
locateAnswerComment(initialCommentId)
}
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
......
...@@ -94,7 +94,7 @@ ...@@ -94,7 +94,7 @@
> >
<img :src="item.faceUrl" class="w-full h-full object-cover" /> <img :src="item.faceUrl" class="w-full h-full object-cover" />
<div <div
v-if="item.type === ArticleTypeEnum.VIDEO" v-if="item.type === ArticleTypeEnum.VIDEO && item.videoDuration"
class="absolute bottom-2 right-2 bg-black/70 text-white text-12px px-2 py-0.5 rounded" class="absolute bottom-2 right-2 bg-black/70 text-white text-12px px-2 py-0.5 rounded"
> >
{{ item.videoDuration }} {{ item.videoDuration }}
...@@ -114,7 +114,8 @@ ...@@ -114,7 +114,8 @@
v-if=" v-if="
item.type !== ArticleTypeEnum.POST && item.type !== ArticleTypeEnum.POST &&
item.type !== ArticleTypeEnum.INTERVIEW && item.type !== ArticleTypeEnum.INTERVIEW &&
item.type !== ArticleTypeEnum.COLUMN item.type !== ArticleTypeEnum.COLUMN &&
item.type !== ArticleTypeEnum.QUESTION
" "
class="text-14px text-gray-600 mb-3 line-clamp-2" class="text-14px text-gray-600 mb-3 line-clamp-2"
> >
......
...@@ -23,9 +23,17 @@ ...@@ -23,9 +23,17 @@
<div <div
v-for="item in list" v-for="item in list"
:key="item.id" :key="item.id"
class="flex items-center p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer" class="flex items-center p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<div
class="flex-1 min-w-0 cursor-pointer"
@click="
jumpToArticleDetailPage({
type: ArticleTypeEnum.QUESTION,
id: item.id,
})
"
> >
<div class="flex-1 min-w-0">
<div class="text-gray-900 font-medium truncate line-clamp-1"> <div class="text-gray-900 font-medium truncate line-clamp-1">
{{ item.title }} {{ item.title }}
</div> </div>
......
...@@ -31,9 +31,12 @@ ...@@ -31,9 +31,12 @@
<div <div
v-for="item in list" v-for="item in list"
:key="item.id" :key="item.id"
class="flex items-center p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer" class="flex items-center p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<div
class="flex-1 min-w-0 cursor-pointer"
@click="jumpToArticleDetailPage({ type: item.type, id: item.id })"
> >
<div class="flex-1 min-w-0">
<div class="text-gray-900 font-medium truncate line-clamp-1">{{ item.title }}</div> <div class="text-gray-900 font-medium truncate line-clamp-1">{{ item.title }}</div>
<div class="text-gray-500 text-sm mt-1 truncate"> <div class="text-gray-500 text-sm mt-1 truncate">
<span class="mr-2"> <span class="mr-2">
......
...@@ -46,10 +46,13 @@ ...@@ -46,10 +46,13 @@
<div <div
v-for="item in list" v-for="item in list"
:key="item.id" :key="item.id"
class="flex items-center p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer" class="flex items-center p-2 rounded-lg hover:bg-gray-100 transition-colors"
> >
<!-- Content --> <!-- Content -->
<div class="flex-1 min-w-0 space-y-1.5"> <div
class="flex-1 min-w-0 space-y-1.5 cursor-pointer"
@click="jumpToArticleDetailPage({ type: item.type, id: item.articleId })"
>
<div class="text-gray-900 font-medium text-base line-clamp-1"> <div class="text-gray-900 font-medium text-base line-clamp-1">
{{ item.title }} {{ item.title }}
</div> </div>
......
...@@ -30,9 +30,12 @@ ...@@ -30,9 +30,12 @@
<div <div
v-for="item in list" v-for="item in list"
:key="item.id" :key="item.id"
class="flex items-center p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer" class="flex items-center p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<div
class="flex-1 min-w-0 cursor-pointer"
@click="jumpToArticleDetailPage({ type: item.type, id: item.id })"
> >
<div class="flex-1 min-w-0">
<div class="text-gray-900 font-medium truncate line-clamp-1">{{ item.title }}</div> <div class="text-gray-900 font-medium truncate line-clamp-1">{{ item.title }}</div>
<div class="text-gray-500 text-sm mt-1 truncate"> <div class="text-gray-500 text-sm mt-1 truncate">
<span class="mr-2"> <span class="mr-2">
......
...@@ -31,9 +31,12 @@ ...@@ -31,9 +31,12 @@
<div <div
v-for="item in list" v-for="item in list"
:key="item.id" :key="item.id"
class="flex items-center p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer" class="flex items-center p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<div
class="flex-1 min-w-0 cursor-pointer"
@click="jumpToArticleDetailPage({ type: item.type, id: item.id })"
> >
<div class="flex-1 min-w-0">
<div class="text-gray-900 font-medium truncate line-clamp-1">{{ item.title }}</div> <div class="text-gray-900 font-medium truncate line-clamp-1">{{ item.title }}</div>
<div class="text-gray-500 text-sm mt-1 truncate"> <div class="text-gray-500 text-sm mt-1 truncate">
<span class="mr-2"> <span class="mr-2">
...@@ -57,7 +60,7 @@ ...@@ -57,7 +60,7 @@
<el-button type="primary" link @click="jumpToEditPage({ type: item.type, id: item.id })" <el-button type="primary" link @click="jumpToEditPage({ type: item.type, id: item.id })"
>编辑</el-button >编辑</el-button
> >
<el-button type="danger" link @click="handleDelete(item.id)">删除</el-button> <el-button type="danger" link @click="handleDelete(item)">删除</el-button>
</div> </div>
</div> </div>
</div> </div>
...@@ -87,6 +90,7 @@ ...@@ -87,6 +90,7 @@
import { getSelfPublishList, deleteArticle } from '@/api' import { getSelfPublishList, deleteArticle } from '@/api'
import { usePageSearch } from '@/hooks' import { usePageSearch } from '@/hooks'
import { import {
articleTypeListOptions,
articleTypeListOptionsForReal, articleTypeListOptionsForReal,
articleTypeListOptionsForNotReal, articleTypeListOptionsForNotReal,
} from '@/constants/options' } from '@/constants/options'
...@@ -129,14 +133,16 @@ onActivated(() => { ...@@ -129,14 +133,16 @@ onActivated(() => {
refresh() refresh()
}) })
const handleDelete = async (articleId: number) => { const handleDelete = async (item: { id: number; type: ArticleTypeEnum; title: string }) => {
const typeLabel = articleTypeListOptions.find((option) => option.value === item.type)?.label
await confirm({ await confirm({
title: '提示', title: '删除确认',
message: '确定删除该吗?', message: `确定要删除${typeLabel}《${item.title}》吗?删除后将无法恢复。`,
confirmText: '确认删除',
type: 'danger', type: 'danger',
}) })
await deleteArticle(articleId) await deleteArticle(item.id)
refresh() refresh()
push.success('删除成功') push.success('删除成功')
} }
......
...@@ -329,6 +329,7 @@ ...@@ -329,6 +329,7 @@
:id="videoId" :id="videoId"
v-model:total="videoDetail.replyCount" v-model:total="videoDetail.replyCount"
:type="ArticleTypeEnum.VIDEO" :type="ArticleTypeEnum.VIDEO"
:immediate="!initialCommentId"
class="mt-5" class="mt-5"
/> />
<RewardDialog ref="rewardDialogRef" v-model:rewardNum="videoDetail.rewardNum" /> <RewardDialog ref="rewardDialogRef" v-model:rewardNum="videoDetail.rewardNum" />
...@@ -380,6 +381,9 @@ const rewardFullSetToastRef = useTemplateRef<InstanceType<typeof RewardFullSetTo ...@@ -380,6 +381,9 @@ const rewardFullSetToastRef = useTemplateRef<InstanceType<typeof RewardFullSetTo
'rewardFullSetToastRef', 'rewardFullSetToastRef',
) )
const commentRef = useTemplateRef<InstanceType<typeof Comment> | null>('commentRef') const commentRef = useTemplateRef<InstanceType<typeof Comment> | null>('commentRef')
// 消息通知链接带的评论 id。点通知都是新开页面重新加载,所以挂载时读一次就够了;
// 有的话评论区不先加载第一页,直接由定位去加载目标所在页
const initialCommentId = Number(route.query.commentId) || 0
// 关于视频跳出奖励相关的逻辑 // 加入相关的userId作为区别 不然切换账号的时候 本地存储的一直没变 // 关于视频跳出奖励相关的逻辑 // 加入相关的userId作为区别 不然切换账号的时候 本地存储的一直没变
const userStore = useUserStore() const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore) const { userInfo } = storeToRefs(userStore)
...@@ -552,6 +556,11 @@ const handleCollect = async (item: ArticleItemDto) => { ...@@ -552,6 +556,11 @@ const handleCollect = async (item: ArticleItemDto) => {
// 打赏 // 打赏
const handleReward = () => { const handleReward = () => {
// 自己发布的内容只提示,不进入打赏流程。
if (isVideoAuthor.value) {
return push.warning('不能给自己的视频打赏')
}
rewardDialogRef.value?.open(videoDetail.value.id) rewardDialogRef.value?.open(videoDetail.value.id)
} }
...@@ -575,6 +584,9 @@ const handleShareVideo = async () => { ...@@ -575,6 +584,9 @@ const handleShareVideo = async () => {
} }
onMounted(async () => { onMounted(async () => {
if (initialCommentId) {
commentRef.value?.locateComment(initialCommentId)
}
const { data } = await getArticleDetail(videoId) const { data } = await getArticleDetail(videoId)
videoDetail.value = data videoDetail.value = data
}) })
......
...@@ -81,6 +81,7 @@ ...@@ -81,6 +81,7 @@
> >
<img :src="item.faceUrl" class="w-full h-full object-cover" /> <img :src="item.faceUrl" class="w-full h-full object-cover" />
<div <div
v-if="item.videoDuration"
class="absolute bottom-2 right-2 bg-black/70 text-white text-12px px-2 py-0.5 rounded" class="absolute bottom-2 right-2 bg-black/70 text-white text-12px px-2 py-0.5 rounded"
> >
{{ item.videoDuration }} {{ item.videoDuration }}
......
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