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
import type { CommentSortTypeEnum } from '@/constants'
/**
* 评论区自己这一层要定位到哪。
* 帖子/视频的评论区,列表是楼层;问吧回答下内嵌的评论区,列表是回答下面的评论。
*/
export interface CommentLocateTarget {
/** 本评论区列表里要找的那一条 */
floorId: number
/** floorId 在本评论区列表里的页码 */
page: number
/** 算页码时用的排序,定位时会把列表切到这个排序,保证页码对得上 */
sortType: CommentSortTypeEnum
/** 真正要滚动到、高亮的评论:可能是 floorId 本身,也可能是它下面的某条回复 */
targetId: number
/** 目标是回复时:是否在默认展示的前几条里(不用点展开) */
childInPreview?: boolean
/** 目标是回复且不在默认展示里时:在展开后的第几页 */
childPage?: number
}
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> <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"> <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="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>
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,
})) || []
} }
} }
}) })
......
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