feat: 素材中心成品库-客户端处理

This commit is contained in:
rd
2025-08-23 13:55:25 +08:00
parent 75874d88dc
commit 786d36ab0d
64 changed files with 7187 additions and 313 deletions

View File

@ -36,9 +36,9 @@ const showInOnePage = computed(() => {
const layoutPageClass = computed(() => {
let result = showInOnePage.value ? 'overflow-hidden' : '';
if (isHomeRoute.value) {
result += ' pb-8px pr-8px';
result += 'pb-8px pr-8px';
} else {
result += ' pb-24px pr-24px';
result += 'pb-24px pr-24px';
}
return result;
});

View File

@ -77,7 +77,14 @@ export const MENU_LIST = <Record<string, typeMenuItem[]>>{
label: '成品库',
routeName: 'MaterialCenterFinishedProducts',
requireLogin: true,
activeMatch: ['MaterialCenterFinishedProducts'],
activeMatch: [
'MaterialCenterFinishedProducts',
'ManuscriptUpload',
'ManuscriptEdit',
'ManuscriptDetail',
'ManuscriptCheckListDetail',
'ManuscriptCheck',
],
},
{
key: 'ModMaterialCenterRawMaterialStorage',

View File

@ -1,142 +1,142 @@
import type { AppRouteRecordRaw } from '../types';
import { MENU_GROUP_IDS } from '@/router/constants';
// import type { AppRouteRecordRaw } from '../types';
// import { MENU_GROUP_IDS } from '@/router/constants';
import IconContentManuscript from '@/assets/svg/svg-contentManuscript.svg';
// import IconContentManuscript from '@/assets/svg/svg-contentManuscript.svg';
const COMPONENTS: AppRouteRecordRaw[] = [
{
path: '/manuscript',
name: 'Manuscript',
redirect: 'manuscript/list',
meta: {
locale: '内容稿件',
icon: IconContentManuscript,
requiresAuth: true,
requireLogin: true,
roles: ['*'],
id: MENU_GROUP_IDS.CREATIVE_GENERATION_WORKSHOP_ID,
},
children: [
{
path: 'list',
name: 'ManuscriptList',
meta: {
locale: '内容稿件列表',
requiresAuth: true,
requireLogin: true,
roles: ['*'],
},
component: () => import('@/views/creative-generation-workshop/manuscript/list/index.vue'),
},
{
path: 'upload',
name: 'ManuscriptUpload',
meta: {
locale: '稿件上传',
requiresAuth: true,
requireLogin: true,
hideFooter: true,
roles: ['*'],
hideInMenu: true,
activeMenu: 'ManuscriptList',
},
component: () => import('@/views/creative-generation-workshop/manuscript/upload/index.vue'),
},
{
path: 'edit/:id',
name: 'ManuscriptEdit',
meta: {
locale: '账号详情',
requiresAuth: true,
requireLogin: true,
hideFooter: true,
roles: ['*'],
hideInMenu: true,
activeMenu: 'ManuscriptList',
},
component: () => import('@/views/creative-generation-workshop/manuscript/edit/index.vue'),
},
{
path: 'detail/:id',
name: 'ManuscriptDetail',
meta: {
locale: '稿件详情',
requiresAuth: true,
requireLogin: true,
hideFooter: true,
roles: ['*'],
hideInMenu: true,
activeMenu: 'ManuscriptList',
},
component: () => import('@/views/creative-generation-workshop/manuscript/detail/index.vue'),
},
{
path: 'check-list',
name: 'ManuscriptCheckList',
meta: {
locale: '内容稿件审核',
requiresAuth: true,
requireLogin: true,
roles: ['*'],
},
component: () => import('@/views/creative-generation-workshop/manuscript/check-list/index.vue'),
},
{
path: 'check-list/detail/:id',
name: 'ManuscriptCheckListDetail',
meta: {
locale: '内容稿件审核详情',
requiresAuth: true,
requireLogin: true,
hideFooter: true,
hideInMenu: true,
roles: ['*'],
activeMenu: 'ManuscriptCheckList',
},
component: () => import('@/views/creative-generation-workshop/manuscript/detail/index.vue'),
},
{
path: 'check',
name: 'ManuscriptCheck',
meta: {
locale: '稿件审核',
requiresAuth: true,
requireLogin: true,
hideFooter: true,
roles: ['*'],
hideInMenu: true,
activeMenu: 'ManuscriptCheckList',
},
component: () => import('@/views/creative-generation-workshop/manuscript/check/index.vue'),
},
],
},
{
path: '/explore/list/:shareCode',
name: 'ExploreList',
meta: {
locale: '分享链接列表',
requiresAuth: false,
requireLogin: false,
hideFooter: true,
hideSidebar: true,
roles: ['*'],
},
component: () => import('@/views/creative-generation-workshop/explore/list/index.vue'),
},
{
path: '/explore/detail/:shareCode/:id',
name: 'ExploreDetail',
meta: {
locale: '分享链接详情',
requiresAuth: false,
requireLogin: false,
hideFooter: true,
hideSidebar: true,
roles: ['*'],
},
component: () => import('@/views/creative-generation-workshop/explore/detail/index.vue'),
},
];
// const COMPONENTS: AppRouteRecordRaw[] = [
// {
// path: '/manuscript',
// name: 'Manuscript',
// redirect: 'manuscript/list',
// meta: {
// locale: '内容稿件',
// icon: IconContentManuscript,
// requiresAuth: true,
// requireLogin: true,
// roles: ['*'],
// id: MENU_GROUP_IDS.CREATIVE_GENERATION_WORKSHOP_ID,
// },
// children: [
// // {
// // path: 'list',
// // name: 'ManuscriptList',
// // meta: {
// // locale: '内容稿件列表',
// // requiresAuth: true,
// // requireLogin: true,
// // roles: ['*'],
// // },
// // component: () => import('@/views/material-center/components/finished-products/manuscript/list/index.vue'),
// // },
// {
// path: 'upload',
// name: 'ManuscriptUpload',
// meta: {
// locale: '稿件上传',
// requiresAuth: true,
// requireLogin: true,
// hideFooter: true,
// roles: ['*'],
// hideInMenu: true,
// activeMenu: 'ManuscriptList',
// },
// component: () => import('@/views/material-center/components/finished-products/manuscript/upload/index.vue'),
// },
// {
// path: 'edit/:id',
// name: 'ManuscriptEdit',
// meta: {
// locale: '账号详情',
// requiresAuth: true,
// requireLogin: true,
// hideFooter: true,
// roles: ['*'],
// hideInMenu: true,
// activeMenu: 'ManuscriptList',
// },
// component: () => import('@/views/material-center/components/finished-products/manuscript/edit/index.vue'),
// },
// {
// path: 'detail/:id',
// name: 'ManuscriptDetail',
// meta: {
// locale: '稿件详情',
// requiresAuth: true,
// requireLogin: true,
// hideFooter: true,
// roles: ['*'],
// hideInMenu: true,
// activeMenu: 'ManuscriptList',
// },
// component: () => import('@/views/material-center/components/finished-products/manuscript/detail/index.vue'),
// },
// // {
// // path: 'check-list',
// // name: 'ManuscriptCheckList',
// // meta: {
// // locale: '内容稿件审核',
// // requiresAuth: true,
// // requireLogin: true,
// // roles: ['*'],
// // },
// // component: () => import('@/views/material-center/components/finished-products/manuscript/check-list/index.vue'),
// // },
// {
// path: 'check-list/detail/:id',
// name: 'ManuscriptCheckListDetail',
// meta: {
// locale: '内容稿件审核详情',
// requiresAuth: true,
// requireLogin: true,
// hideFooter: true,
// hideInMenu: true,
// roles: ['*'],
// activeMenu: 'ManuscriptCheckList',
// },
// component: () => import('@/views/material-center/components/finished-products/manuscript/detail/index.vue'),
// },
// {
// path: 'check',
// name: 'ManuscriptCheck',
// meta: {
// locale: '稿件审核',
// requiresAuth: true,
// requireLogin: true,
// hideFooter: true,
// roles: ['*'],
// hideInMenu: true,
// activeMenu: 'ManuscriptCheckList',
// },
// component: () => import('@/views/material-center/components/finished-products/manuscript/check/index.vue'),
// },
// ],
// },
// {
// path: '/explore/list/:shareCode',
// name: 'ExploreList',
// meta: {
// locale: '分享链接列表',
// requiresAuth: false,
// requireLogin: false,
// hideFooter: true,
// hideSidebar: true,
// roles: ['*'],
// },
// component: () => import('@/views/creative-generation-workshop/explore/list/index.vue'),
// },
// {
// path: '/explore/detail/:shareCode/:id',
// name: 'ExploreDetail',
// meta: {
// locale: '分享链接详情',
// requiresAuth: false,
// requireLogin: false,
// hideFooter: true,
// hideSidebar: true,
// roles: ['*'],
// },
// component: () => import('@/views/creative-generation-workshop/explore/detail/index.vue'),
// },
// ];
export default COMPONENTS;
// export default COMPONENTS;

View File

@ -39,126 +39,104 @@ const COMPONENTS: AppRouteRecordRaw[] = [
},
component: () => import('@/views/material-center/index.vue'),
},
// {
// path: 'list',
// name: 'ManuscriptList',
// meta: {
// locale: '内容稿件列表',
// requiresAuth: true,
// requireLogin: true,
// roles: ['*'],
// },
// component: () => import('@/views/creative-generation-workshop/manuscript/list/index.vue'),
// },
// {
// path: 'upload',
// name: 'ManuscriptUpload',
// meta: {
// locale: '稿件上传',
// requiresAuth: true,
// requireLogin: true,
// hideFooter: true,
// roles: ['*'],
// hideInMenu: true,
// activeMenu: 'ManuscriptList',
// },
// component: () => import('@/views/creative-generation-workshop/manuscript/upload/index.vue'),
// },
// {
// path: 'edit/:id',
// name: 'ManuscriptEdit',
// meta: {
// locale: '账号详情',
// requiresAuth: true,
// requireLogin: true,
// hideFooter: true,
// roles: ['*'],
// hideInMenu: true,
// activeMenu: 'ManuscriptList',
// },
// component: () => import('@/views/creative-generation-workshop/manuscript/edit/index.vue'),
// },
// {
// path: 'detail/:id',
// name: 'ManuscriptDetail',
// meta: {
// locale: '稿件详情',
// requiresAuth: true,
// requireLogin: true,
// hideFooter: true,
// roles: ['*'],
// hideInMenu: true,
// activeMenu: 'ManuscriptList',
// },
// component: () => import('@/views/creative-generation-workshop/manuscript/detail/index.vue'),
// },
// {
// path: 'check-list',
// name: 'ManuscriptCheckList',
// meta: {
// locale: '内容稿件审核',
// requiresAuth: true,
// requireLogin: true,
// roles: ['*'],
// },
// component: () => import('@/views/creative-generation-workshop/manuscript/check-list/index.vue'),
// },
// {
// path: 'check-list/detail/:id',
// name: 'ManuscriptCheckListDetail',
// meta: {
// locale: '内容稿件审核详情',
// requiresAuth: true,
// requireLogin: true,
// hideFooter: true,
// hideInMenu: true,
// roles: ['*'],
// activeMenu: 'ManuscriptCheckList',
// },
// component: () => import('@/views/creative-generation-workshop/manuscript/detail/index.vue'),
// },
// {
// path: 'check',
// name: 'ManuscriptCheck',
// meta: {
// locale: '稿件审核',
// requiresAuth: true,
// requireLogin: true,
// hideFooter: true,
// roles: ['*'],
// hideInMenu: true,
// activeMenu: 'ManuscriptCheckList',
// },
// component: () => import('@/views/creative-generation-workshop/manuscript/check/index.vue'),
// },
{
path: 'upload',
name: 'ManuscriptUpload',
meta: {
locale: '稿件上传',
requiresAuth: true,
requireLogin: true,
hideFooter: true,
roles: ['*'],
hideInMenu: true,
activeMenu: 'MaterialCenterFinishedProducts',
},
component: () => import('@/views/material-center/components/finished-products/manuscript/upload/index.vue'),
},
{
path: 'edit/:id',
name: 'ManuscriptEdit',
meta: {
locale: '账号详情',
requiresAuth: true,
requireLogin: true,
hideFooter: true,
roles: ['*'],
hideInMenu: true,
activeMenu: 'MaterialCenterFinishedProducts',
},
component: () => import('@/views/material-center/components/finished-products/manuscript/edit/index.vue'),
},
{
path: 'detail/:id',
name: 'ManuscriptDetail',
meta: {
locale: '稿件详情',
requiresAuth: true,
requireLogin: true,
hideFooter: true,
roles: ['*'],
hideInMenu: true,
activeMenu: 'MaterialCenterFinishedProducts',
},
component: () => import('@/views/material-center/components/finished-products/manuscript/detail/index.vue'),
},
{
path: 'check-list/detail/:id',
name: 'ManuscriptCheckListDetail',
meta: {
locale: '内容稿件审核详情',
requiresAuth: true,
requireLogin: true,
hideFooter: true,
hideInMenu: true,
roles: ['*'],
activeMenu: 'MaterialCenterFinishedProducts',
},
component: () => import('@/views/material-center/components/finished-products/manuscript/detail/index.vue'),
},
{
path: 'check',
name: 'ManuscriptCheck',
meta: {
locale: '稿件审核',
requiresAuth: true,
requireLogin: true,
hideFooter: true,
roles: ['*'],
hideInMenu: true,
activeMenu: 'MaterialCenterFinishedProducts',
},
component: () => import('@/views/material-center/components/finished-products/manuscript/check/index.vue'),
},
],
},
// {
// path: '/explore/list/:shareCode',
// name: 'ExploreList',
// meta: {
// locale: '分享链接列表',
// requiresAuth: false,
// requireLogin: false,
// hideFooter: true,
// hideSidebar: true,
// roles: ['*'],
// },
// component: () => import('@/views/creative-generation-workshop/explore/list/index.vue'),
// },
// {
// path: '/explore/detail/:shareCode/:id',
// name: 'ExploreDetail',
// meta: {
// locale: '分享链接详情',
// requiresAuth: false,
// requireLogin: false,
// hideFooter: true,
// hideSidebar: true,
// roles: ['*'],
// },
// component: () => import('@/views/creative-generation-workshop/explore/detail/index.vue'),
// },
{
path: '/explore/list/:shareCode',
name: 'ExploreList',
meta: {
locale: '分享链接列表',
requiresAuth: false,
requireLogin: false,
hideFooter: true,
hideSidebar: true,
roles: ['*'],
},
component: () => import('@/views/material-center/components/finished-products/explore/list/index.vue'),
},
{
path: '/explore/detail/:shareCode/:id',
name: 'ExploreDetail',
meta: {
locale: '分享链接详情',
requiresAuth: false,
requireLogin: false,
hideFooter: true,
hideSidebar: true,
roles: ['*'],
},
component: () => import('@/views/material-center/components/finished-products/explore/detail/index.vue'),
},
];
export default COMPONENTS;

View File

@ -1,7 +1,8 @@
$navbar-height: 52px; // 头部高度
$sidebar-width: 138px; // 侧边栏菜单宽度
$sidebar-width-collapse: 74px; // 折叠侧边栏菜单宽度
$layout-padding-bottom: 24px;
$layout-padding-right: 24px;
// 汉字字体
$font-family-regular: 'PingFangSC-Regular', 'Microsoft Yahei', Arial, sans-serif;
@ -15,45 +16,44 @@ $font-family-manrope-medium: 'Manrope-Medium';
$font-family-manrope-bold: 'Manrope-Bold';
$font-family-manrope-semiBold: 'Manrope-SemiBold';
$color-primary: #6d4cfe; // 常规
$color-primary-5: #8a70fe; // hover
$color-primary-7: #573dcb; // click
$color-primary-3: #a794fe; // disabled
$color-primary-2: #c5b7ff; // text disabled
$color-primary-1: #f0edff; // 浅色
$color-primary: #6d4cfe; // 常规
$color-primary-5: #8A70FE; // hover
$color-primary-7: #573DCB; // click
$color-primary-3: #A794FE; // disabled
$color-primary-2: #C5B7FF; // text disabled
$color-primary-1: #F0EDFF; // 浅色
$color-success: #25c883;
$color-success-5: #57cf9c;
$color-success-7: #1bae71;
$color-success-3: #81dbb5;
$color-success-2: #abe7ce;
$color-success-1: #ebf7f2;
$color-success: #25C883;
$color-success-5: #57CF9C;
$color-success-7: #1BAE71;
$color-success-3: #81DBB5;
$color-success-2: #ABE7CE;
$color-success-1: #EBF7F2;
$color-warning: #ffae00;
$color-warning-5: #ffbe33;
$color-warning-7: #cc8b00;
$color-warning-3: #ffcf66;
$color-warning-2: #ffdf99;
$color-warning-1: #fff7e5;
$color-warning: #FFAE00;
$color-warning-5: #FFBE33;
$color-warning-7: #CC8B00;
$color-warning-3: #FFCF66;
$color-warning-2: #FFDF99;
$color-warning-1: #FFF7E5;
$color-error: #f64b31;
$color-error-5: #f86f5a;
$color-error-7: #c53c27;
$color-error-3: #fa9383;
$color-error-2: #fbb7ad;
$color-error-1: #ffe9e7;
$color-error: #F64B31;
$color-error-5: #F86F5A;
$color-error-7: #C53C27;
$color-error-3: #FA9383;
$color-error-2: #FBB7AD;
$color-error-1: #FFE9E7;
$color-blue: #2a59f3;
$color-blue-5: #557af6;
$color-blue-7: #2247c2;
$color-blue-3: #7f9cf8;
$color-blue-2: #aabdfa;
$color-blue-1: #e5ecff;
$color-blue: #2A59F3;
$color-blue-5: #557AF6;
$color-blue-7: #2247C2;
$color-blue-3: #7F9CF8;
$color-blue-2: #AABDFA;
$color-blue-1: #E5ECFF;
$color-teal: #39C6E9;
$color-teal-5: #60D2ED;
$color-teal-7: #2E9EBA;
$color-teal-3: #88DDF2;
$color-teal-2: #B0E8F6;
$color-teal-1: #E1F9FF;
$color-teal: #39c6e9;
$color-teal-5: #60d2ed;
$color-teal-7: #2e9eba;
$color-teal-3: #88ddf2;
$color-teal-2: #b0e8f6;
$color-teal-1: #e1f9ff;

View File

@ -1,7 +1,7 @@
$footer-height: 68px;
.manuscript-check-wrap {
width: 100%;
height: calc(100% - 72px);
height: calc(100% - ($footer-height - $layout-padding-bottom));
.cts {
color: #939499;
font-family: $font-family-regular;

View File

@ -1,8 +1,7 @@
$footer-height: 68px;
.manuscript-detail-wrap {
width: 100%;
height: calc(100% - 72px);
margin-bottom: 72px;
height: calc(100% - ($footer-height - $layout-padding-bottom));
.cts {
color: #939499;
font-family: $font-family-regular;

View File

@ -1,6 +1,6 @@
$footer-height: 68px;
.manuscript-edit-wrap {
height: calc(100% - 72px);
height: calc(100% - ($footer-height - $layout-padding-bottom));
display: flex;
flex-direction: column;
.cts {

View File

@ -1,6 +1,6 @@
$footer-height: 68px;
.manuscript-upload-wrap {
height: calc(100% - 72px);
height: calc(100% - ($footer-height - $layout-padding-bottom));
.cts,
:deep(.overflow-text) {
color: #939499;

View File

@ -26,6 +26,7 @@ export default {
};
onMounted(() => {
console.log('getAgentData')
getAgentData();
});

View File

@ -0,0 +1,39 @@
<template>
<a-modal v-model:visible="visible" title="确定删除评论?" width="400px" @close="onClose">
<div class="flex items-center">
<img :src="icon1" width="20" height="20" class="mr-12px" />
<span>删除的评论将从对话中消失但仍在被引用的评论中可见</span>
</div>
<template #footer>
<a-button size="large" @click="onClose">取消</a-button>
<a-button type="primary" class="ml-16px !border-none" size="large" @click="onDelete">删除</a-button>
</template>
</a-modal>
</template>
<script setup>
import { ref } from 'vue';
import icon1 from '@/assets/img/media-account/icon-warn-1.png';
const emits = defineEmits(['delete', 'close']);
const visible = ref(false);
const commentId = ref('');
const onClose = () => {
commentId.value = ''
visible.value = false;
};
const open = (id) => {
commentId.value = id;
visible.value = true;
};
const onDelete = () => {
emits('delete', commentId.value);
onClose();
};
defineExpose({ open });
</script>

View File

@ -0,0 +1,320 @@
<script lang="jsx">
import { Image, Spin, Button, Input, Textarea, Affix } from '@arco-design/web-vue';
import TextOverTips from '@/components/text-over-tips';
import SvgIcon from '@/components/svg-icon/index.vue';
import DeleteCommentModal from './delete-comment-modal.vue';
import { RESULT_LIST } from '@/views/material-center/components/finished-products/manuscript/check/components/content-card/constants.ts';
import { ENUM_OPINION, formatRelativeTime } from '../../constants';
import { postShareWorksComments, deleteShareWorksComments } from '@/api/all/generationWorkshop.ts';
import { exactFormatTime } from '@/utils/tools.ts';
import { useUserStore } from '@/stores';
import icon1 from '@/assets/img/creative-generation-workshop/icon-line.png';
import icon2 from '@/assets/img/creative-generation-workshop/icon-avatar-default.png';
import icon3 from '@/assets/img/error-img.png';
import icon4 from '@/assets/img/creative-generation-workshop/icon-avatar-default-v2.png';
const _iconMap = new Map([
// [3, { icon: <icon-check-circle-fill size={16} class="color-#25C883 flex-shrink-0" /> }],
[2, { icon: <icon-exclamation-circle-fill size={16} class="color-#F64B31 flex-shrink-0" /> }],
[1, { icon: <icon-exclamation-circle-fill size={16} class="color-#FFAE00 flex-shrink-0" /> }],
[0, { icon: <icon-check-circle-fill size={16} class="color-#25C883 flex-shrink-0" /> }],
]);
export default {
props: {
isExpand: {
type: Boolean,
default: true,
},
dataSource: {
type: Object,
default: () => {},
},
},
emits: ['toggle', 'updateComment', 'deleteComment'],
setup(props, { emit, expose }) {
const route = useRoute();
const userStore = useUserStore();
const isCollapse = ref(false);
const comment = ref('');
const isReplay = ref(false);
const replayTarget = ref({});
const deleteCommentModalRef = ref(null);
const textAreaRef = ref(null);
const aiReview = computed(() => props.dataSource.ai_review);
const inspectionItems = computed(() => props.dataSource?.ai_review?.inspection_items ?? []);
const closeReplay = () => {
isReplay.value = false;
replayTarget.value = {};
};
const onReplay = (item) => {
isReplay.value = true;
replayTarget.value = item;
textAreaRef.value.focus();
};
const onComment = async () => {
const { code, data } = await postShareWorksComments(props.dataSource.id, route.params.shareCode, {
content: comment.value,
comment_id: replayTarget.value.id,
});
if (code === 200) {
emit('updateComment');
onClearComment();
textAreaRef.value.focus();
}
};
const onClearComment = () => {
isReplay.value = false;
replayTarget.value = {};
comment.value = '';
};
const renderDeleteBtn = (item) => {
const { userInfo, isLogin } = userStore;
const { commenter_id, id, commenter } = item;
let showBtn = true;
if (isLogin) {
showBtn = commenter?.id === userInfo.id;
} else {
showBtn = commenter_id === 1 ? false : !commenter;
}
if (!showBtn) {
return null;
}
return (
<icon-delete
class="ml-12px cursor-pointer color-#55585F hover:color-#6D4CFE"
size={16}
onClick={() => deleteCommentModalRef.value?.open(id)}
/>
);
};
const deleteComment = async (comment_id) => {
emit('deleteComment', comment_id);
deleteShareWorksComments(props.dataSource.id, comment_id, route.params.shareCode);
};
const renderTextareaBox = () => {
return (
<div class="sticky bottom-0 left-0 w-full z-22 px-24px">
<div class="relative">
{isReplay.value && (
<div class="px-8px pt-8px absolute top-0 left-0 z-2 mb-8px w-full">
<div class="rounded-4px bg-#F2F3F5 h-30px px-8px flex justify-between items-center ">
<div class="flex items-center mr-12px flex-1 overflow-hidden">
<span class="mr-4px cts !color-#737478 flex-shrink-0">回复</span>
<TextOverTips
context={`${getCommentName(replayTarget.value)}${replayTarget.value.content}`}
class="cts !color-#737478"
/>
</div>
<icon-close size={16} class="color-#737478 cursor-pointer flex-shrink-0" onClick={closeReplay} />
</div>
</div>
)}
<Textarea
ref={textAreaRef}
auto-size
class={`max-h-220px overflow-y-auto ${isReplay.value ? 'pt-38px' : ''}`}
size="large"
placeholder="输入评论"
v-model={comment.value}
onPressEnter={onComment}
/>
</div>
{comment.value && (
<div class="flex justify-end mt-12px">
<Button type="outline" class="mr-12px rounded-8px" size="medium" onClick={onClearComment}>
取消
</Button>
<Button type="primary" class="rounded-8px" size="medium" onClick={onComment}>
发送
</Button>
</div>
)}
</div>
);
};
const renderCommentBox = () => {
return (
<div class="comment-box">
<p class="mb-16px">
<span class="cts bold cm !text-16px !lh-24px mr-8px">评论</span>
{props.dataSource.comments?.length > 0 && (
<span class="cts !text-16px !lh-24px bold">{props.dataSource.comments?.length}</span>
)}
</p>
{props.dataSource.comments?.length > 0 && (
<div class="comment-list flex flex-col my-16px rounded-8px">
{props.dataSource.comments?.map((item) => (
<div class="comment-item flex px-12px py-8px group" key={item.id}>
<Image
src={item.commenter_id === 0 ? icon2 : item.commenter?.head_image || icon4}
width={40}
height={40}
preview={false}
fit="cover"
class="rounded-50% mr-13px"
v-slots={{
error: () => <img src={icon3} class="w-40 h-40 rounded-50%" />,
}}
/>
<div class="flex flex-col flex-1 ">
<div class="flex justify-between">
<p class="mb-4px">
<span class="cts !color-#211F24 mr-8px">{getCommentName(item)}</span>
<span class="cts !color-#939499">{formatRelativeTime(item.created_at)}</span>
</p>
<div class="items-center hidden group-hover:flex">
<SvgIcon
onClick={() => onReplay(item)}
name="svg-comment"
size={16}
class="color-#55585F cursor-pointer hover:color-#6D4CFE"
/>
{renderDeleteBtn(item)}
</div>
</div>
{item.reply_comment && (
<div class="flex items-center">
<div class="w-2px h-12px bg-#B1B2B5"></div>
<span class="mx-4px cts !color-#939499 flex-shrink-0">回复</span>
<TextOverTips
context={`${getCommentName(item.reply_comment)}${item.reply_comment.content}`}
class="cts !color-#939499"
/>
</div>
)}
<p class="cts !color-#211F24 break-all">{item.content}</p>
</div>
</div>
))}
</div>
)}
</div>
);
};
const renderAiSuggest = () => {
if (isEmpty(aiReview.value)) return null;
const hasInspectionItems = inspectionItems.value.length > 0;
return (
<>
<div class="result-box p-16px rounded-8px">
<div class="flex items-center justify-between mb-16px">
<p class="cts bold !color-#000 !text-16px">审核结果</p>
{hasInspectionItems && (
<Button
type="text"
class="!color-#6D4CFE hover:!color-#8A70FE"
onClick={() => (isCollapse.value = !isCollapse.value)}
>
{isCollapse.value ? '展开详情' : '收起详情'}
</Button>
)}
</div>
<div class="flex items-center">
{RESULT_LIST.map((item, index) => (
<div class="flex flex-col justify-center items-center flex-1 result-item" key={index}>
<span class="s1" style={{ color: item.color }}>{`${aiReview.value?.[item.value]}${
item.suffix || ''
}`}</span>{' '}
<span class="cts mt-4px !lh-20px !text-12px !color-#737478">{item.label}</span>
</div>
))}
</div>
</div>
<div class={`collapse-box mb-16px overflow-hidden ${isCollapse.value ? 'h-0 ' : 'h-auto'}`}>
{hasInspectionItems &&
inspectionItems.value.map((parentItem, parentIndex) => (
<div class="result-box p-16px rounded-8px mt-16px" key={parentIndex}>
<p class="cts bold !color-#000 !text-16px mb-16px">{parentItem.name}</p>
<div class="grid grid-cols-3 gap-x-24px gap-y-8px">
{parentItem.items.map((item, index) => (
<div class="audit-item" key={index}>
<div class="flex items-center h-20px">
{_iconMap.get(item.level)?.icon}
<TextOverTips context={item.name} class="cts ml-4px !color-#000" />
</div>
</div>
))}
</div>
</div>
))}
</div>
</>
);
};
const getCommentName = (item) => {
// 姓名脱敏保留首尾字符中间拼接6个****
const maskName = (name) => {
if (!name || name.length <= 1) return name; // 单字符不脱敏
return name[0] + '****' + name[name.length - 1];
};
// 手机号脱敏保留前3位和后4位中间4位替换为****
const maskMobile = (mobile) => mobile?.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
if (item.commenter_id === 0) {
return item.commenter?.name ? maskName(item.commenter?.name) : '佚名';
}
const maskedName = maskName(item.commenter?.name);
const maskedMobile = maskMobile(item.commenter?.mobile);
return maskedName || maskedMobile;
};
return () => (
<section class="ai-suggest-wrap py-16px fixed z-3 right-16px w-440px h-full overflow-hidden">
<div class="ai-suggest-box relative py-24px flex flex-col">
{!isEmpty(aiReview.value) && (
<div class="relative w-fit ml-24px mb-16px">
<span class="ai-text relative z-2">AI 智能审核</span>
<img src={icon1} class="w-80px h-10.8px absolute bottom-1px left--9px" />
</div>
)}
<icon-menu-unfold
size={20}
class="color-#55585F cursor-pointer hover:color-#6D4CFE absolute top-24px right-24px"
onClick={() => emit('toggle', false)}
/>
{/**主体 */}
<div class="flex-1 overflow-y-auto px-24px main-box">
{/* AI审核结果 */}
{renderAiSuggest()}
{/* 评论与回复 */}
{renderCommentBox()}
</div>
{renderTextareaBox()}
</div>
<DeleteCommentModal ref={deleteCommentModalRef} onDelete={deleteComment} />
</section>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,121 @@
.ai-suggest-wrap {
top: $navbar-height;
height: calc(100% - ($navbar-height + 12px));
.ai-suggest-box {
width: 440px;
height: fit-content;
max-height: 100%;
border-radius: 16px;
background: linear-gradient(126deg, #eef2fd 8.36%, #f5ebfe 49.44%, #fdebf3 90.52%);
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.1);
.cts {
color: #939499;
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&.bold {
font-family: $font-family-medium;
}
}
.ai-text {
font-family: $font-family-medium;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 24px;
background: linear-gradient(85deg, #7d419d 4.56%, #31353d 94.75%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
:deep(.arco-textarea-wrapper) {
min-height: 38px;
display: flex;
border-color: transparent !important;
align-items: center;
border-radius: 8px !important;
background-color: #fff;
color: #211f24 !important;
transition: all 0.3s;
.arco-textarea-mirror,
.arco-textarea {
padding: 8px 16px !important;
}
&:hover {
border-color: #6d4cfe !important;
}
&.arco-textarea-focus {
border-color: #6d4cfe !important;
}
}
.result-box {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(4px);
.result-item {
.s1 {
color: var(--Brand-6, #6d4cfe);
font-family: $font-family-manrope-medium;
font-size: 24px;
font-style: normal;
font-weight: 700;
line-height: 32px;
}
&:first-child {
position: relative;
&::after {
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
right: 0;
width: 1px;
height: 32px;
background: var(--Border-1, #d7d7d9);
}
}
}
}
.collapse-box {
transition: all 0.3s;
}
.comment-box {
.cm {
background: linear-gradient(85deg, #7d419d 4.56%, #31353d 94.75%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.comment-list {
backdrop-filter: blur(4px);
.comment-item {
&:not(:last-child) {
margin-bottom: 8px;
}
&:hover {
border-radius: 8px;
background: rgba(255, 255, 255, 0.8);
}
}
}
}
.main-box {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
border: none;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
}
}

View File

@ -0,0 +1,44 @@
export const ENUM_OPINION = {
wait: 0, // 待确认
confirm: 1, // 已确认
};
export const formatRelativeTime = (date: number): string => {
const target = dayjs(date * 1000);
if (!target.isValid()) return '';
const now = dayjs();
// 处理未来时间
if (target.isAfter(now)) return '刚刚';
const diffInMinutes = now.diff(target, 'minute');
const diffInHours = now.diff(target, 'hour');
const diffInDays = now.diff(target, 'day');
const diffInYears = now.diff(target, 'year');
// 1分钟内
if (diffInMinutes < 1) {
return '刚刚';
}
// 1分钟 ~ 1小时
else if (diffInMinutes < 60) {
return `${diffInMinutes}分钟前`;
}
// 1小时 ~ 24小时
else if (diffInHours < 24) {
return `${diffInHours}小时前`;
}
// 1天 ~ 30天
else if (diffInDays < 30) {
return `${diffInDays}天前`;
}
// 超过30天但不到1年
else if (diffInYears < 1) {
return target.format('MM-DD HH:mm');
}
// 超过1年
else {
return target.format('YYYY-MM-DD HH:mm');
}
};

View File

@ -0,0 +1,275 @@
<script lang="jsx">
import { Image, Spin, Button } from '@arco-design/web-vue';
import AiSuggest from './components/ai-suggest/';
import { getShareWorksList, getShareWorksDetail, patchShareWorksConfirm } from '@/api/all/generationWorkshop.ts';
import { convertVideoUrlToCoverUrl, exactFormatTime } from '@/utils/tools.ts';
import { EnumManuscriptType } from '@/views/material-center/components/finished-products/manuscript/list/constants.ts';
import { ENUM_OPINION } from './constants';
import { handleUserHome } from '@/utils/user.ts';
import { useUserStore } from '@/stores';
import icon1 from '@/assets/img/icon-logo.png';
import icon2 from '@/assets/img/creative-generation-workshop/icon-confirm.png';
import icon3 from '@/assets/img/creative-generation-workshop/icon-line.png';
export default {
setup(props, { emit, expose }) {
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const dataSource = ref({});
const shareWorks = ref({});
const isExpand = ref(true);
const loading = ref(false);
const isPlaying = ref(false);
const videoRef = ref(null);
const videoUrl = ref('');
const coverImageUrl = ref('');
const isVideoLoaded = ref(false);
const images = ref([]);
const isVideo = computed(() => dataSource.value.type === EnumManuscriptType.Video);
const isMultiWork = computed(() => shareWorks.value.works?.length > 1);
const shareCode = computed(() => route.params.shareCode);
const hasPrevBtn = computed(() => dataSource.value.prev);
const hasNextBtn = computed(() => dataSource.value.next);
const initData = () => {
videoUrl.value = '';
coverImageUrl.value = '';
images.value = [];
if (!dataSource.value.files.length) return;
const [fileOne, ...fileOthers] = dataSource.value.files ?? [];
if (isVideo.value) {
videoUrl.value = fileOne.url;
coverImageUrl.value = convertVideoUrlToCoverUrl(fileOne.url);
} else {
coverImageUrl.value = fileOne.url;
images.value = fileOthers;
}
};
const getDetail = async () => {
try {
dataSource.value = {};
loading.value = true;
const { id } = route.params;
const { code, data } = await getShareWorksDetail(id, shareCode.value);
if (code === 200) {
dataSource.value = data;
initData();
}
} finally {
loading.value = false;
}
};
const updateCommentList = async () => {
const { id } = route.params;
const { code, data } = await getShareWorksDetail(id, shareCode.value);
if (code === 200) {
dataSource.value.comments = data.comments;
}
};
const getShareWorks = async () => {
const { code, data } = await getShareWorksList(shareCode.value);
if (code === 200) {
shareWorks.value = data;
}
};
const renderMainImg = () => {
if (!coverImageUrl.value) return null;
if (isVideo.value) {
return (
<div class="main-video-box mb-16px relative overflow-hidden cursor-pointer" onClick={togglePlay}>
<video ref={videoRef} class="w-100% h-100% object-contain" onEnded={onVideoEnded}></video>
{!isPlaying.value && (
<>
<img src={coverImageUrl.value} class="w-100% h-100% object-contain absolute z-0 top-0 left-0" />
<div v-show={!isPlaying.value} class="play-icon"></div>
</>
)}
</div>
);
} else {
return (
<div class="main-img-box mb-16px relative overflow-hidden cursor-pointer">
<img src={coverImageUrl.value} class="w-100% h-100% object-contain" />
</div>
);
}
};
const togglePlay = () => {
if (!videoRef.value) return;
if (isPlaying.value) {
videoRef.value.pause();
} else {
if (!isVideoLoaded.value) {
videoRef.value.src = videoUrl.value;
isVideoLoaded.value = true;
}
videoRef.value.play();
}
isPlaying.value = !isPlaying.value;
};
const onVideoEnded = () => {
isPlaying.value = false;
};
const onBackList = () => {
router.push({
path: `/explore/list/${shareCode.value}`,
});
};
const onPrevWork = async () => {
await router.push({
path: `/explore/detail/${shareCode.value}/${dataSource.value.prev.id}`,
});
getDetail();
};
const onNextWork = async () => {
await router.push({
path: `/explore/detail/${shareCode.value}/${dataSource.value.next.id}`,
});
getDetail();
};
const handleConfirm = async () => {
const { code, data } = await patchShareWorksConfirm(dataSource.value.id, shareCode.value);
if (code === 200) {
dataSource.value.customer_opinion = ENUM_OPINION.confirm;
}
};
const onUpdateComment = () => {
updateCommentList();
};
const onDeleteComment = (id) => {
const index = dataSource.value.comments.findIndex((item) => item.id === id);
if (index === -1) return;
console.log({ index });
dataSource.value.comments.splice(index, 1);
};
const renderConfirmBtn = () => {
if (userStore.isLogin) return null;
if (dataSource.value.customer_opinion === ENUM_OPINION.confirm) return null;
return (
<Button type="primary" size="large" onClick={handleConfirm}>
确认内容稿件
</Button>
);
};
const renderActionRow = () => {
if (loading.value) return null;
if (isMultiWork.value) {
return (
<>
{hasPrevBtn.value && (
<Button type="text" size="large" class="mr-12px" onClick={onPrevWork}>
上一条
</Button>
)}
{hasNextBtn.value && (
<Button type="text" size="large" class="mr-12px" onClick={onNextWork}>
下一条
</Button>
)}
<Button type="outline" size="large" class="mr-12px" onClick={onBackList}>
返回列表
</Button>
{renderConfirmBtn()}
</>
);
}
return renderConfirmBtn();
};
onMounted(() => {
getDetail();
getShareWorks();
});
onBeforeUnmount(() => {
if (videoRef.value) {
videoRef.value.pause();
videoRef.value = null;
}
});
return () => {
return (
<div class='explore-page'>
<header class="page-header">
<div class="content w-full px-24px flex items-center justify-between">
<div class="h-full flex items-center">
<img src={icon1} alt="" width="96" height="24"/>
</div>
<div class="flex items-center">{renderActionRow()}</div>
</div>
</header>
{loading.value ? (
<Spin spinning={loading.value} class="flex-1 w-full flex justify-center items-center" size={60} />
) : (
<section class={`page-wrap relative ${isExpand.value ? 'expand' : ''}`}>
<div class="fold-box cursor-pointer" onClick={() => (isExpand.value = true)}>
<icon-menu-fold size={20} class="color-#55585F hover:color-#6D4CFE" />
</div>
<section class="explore-detail-wrap pt-32px pb-52px flex flex-col items-center">
<div class="flex justify-start flex-col w-full relative">
<p class="title mb-8px">{dataSource.value.title}</p>
<p class="cts color-#737478 mb-32px">
{exactFormatTime(dataSource.value.last_modified_at, 'YYYY年MM月DD日')}修改
</p>
{dataSource.value.customer_opinion === ENUM_OPINION.confirm && (
<img src={icon2} width={92} height={92} class="absolute right-0 bottom-0" />
)}
</div>
{renderMainImg()}
<div class="w-full">
<p class="cts !color-#211F24 whitespace-pre-line">{dataSource.value.content}</p>
</div>
{/* 仅图片类型显示图片列表 */}
{!isVideo.value && (
<div class="desc-img-wrap mt-40px">
{images.value.map((item, index) => (
<div class="desc-img-box" key={index}>
<img src={item.url} class="w-100% h-100% object-cover" />
</div>
))}
</div>
)}
</section>
{isExpand.value && (
<AiSuggest
isExpand={isExpand.value}
dataSource={dataSource.value}
onToggle={(expand) => (isExpand.value = expand)}
onUpdateComment={onUpdateComment}
onDeleteComment={onDeleteComment}
/>
)}
</section>
)}
</div>
);
};
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,135 @@
.explore-page {
position: relative;
min-width: 1200px;
min-height: 100vh;
// background: #fff;
display: flex;
flex-direction: column;
// &::before {
// content: '';
// position: absolute;
// top: 0;
// left: 0;
// width: 100%;
// height: 100%;
// background: #fff;
// z-index: -1;
// }
.fold-box {
width: 40px;
height: 40px;
border-radius: 30px;
border: 1px solid var(--Border-1, #d7d7d9);
background: #fff;
box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.15);
position: fixed;
right: 16px;
top: calc($navbar-height + 32px);
display: flex;
justify-content: center;
align-items: center;
}
.page-header {
position: sticky;
left: 0;
right: 0;
top: 0;
z-index: 1000;
min-width: 1200px;
.content {
height: $navbar-height;
// border-bottom: 1px solid var(--Border-1, #d7d7d9);
}
&::before {
width: 100%;
height: 100%;
background: url('@/assets/img/icon-app-header-bg.png') center top no-repeat !important;
background-size: cover !important;
bottom: 0;
content: '';
display: block;
left: 0;
position: absolute;
right: 0;
top: 0;
z-index: -998;
}
}
.cts {
color: #939499;
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&.bold {
font-family: $font-family-medium;
}
}
.page-wrap {
width: 100%;
flex: 1;
display: flex;
justify-content: center;
// background: #fff;
&.expand {
width: calc(100% - 456px);
}
.explore-detail-wrap {
min-height: 500px;
width: 684px;
.title {
color: var(--Text-1, #211f24);
font-family: $font-family-medium;
font-size: 28px;
font-style: normal;
font-weight: 400;
line-height: 40px; /* 142.857% */
}
}
.main-video-box {
width: 320px;
height: 472px;
background: #333;
aspect-ratio: 3 / 4;
}
.main-img-box {
width: 320px;
height: auto;
max-height: 472px;
background: #fff;
}
.desc-img-wrap {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
.desc-img-box {
width: 212px;
height: 283px;
background: #fff;
object-fit: contain;
aspect-ratio: 3/4;
}
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 222;
width: 64px;
height: 64px;
background-image: url('@/assets/img/creative-generation-workshop/icon-play.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
transition: background-image 0.3s ease;
}
.play-icon:hover {
background-image: url('@/assets/img/creative-generation-workshop/icon-play-hover.png');
}
}
}

View File

@ -0,0 +1,117 @@
<script lang="jsx">
import TextOverTips from '@/components/text-over-tips';
import { Image, Spin } from '@arco-design/web-vue';
import { exactFormatTime } from '@/utils/tools';
import { handleUserHome } from '@/utils/user.ts';
import { getShareWorksList } from '@/api/all/generationWorkshop';
import { ENUM_OPINION } from '../detail/constants';
import icon1 from '@/assets/img/error-img.png';
import icon2 from '@/assets/img/icon-logo.png';
export default {
setup(props, { emit, expose }) {
const route = useRoute();
const router = useRouter();
const dataSource = ref({});
const loading = ref(false);
const works = computed(() => dataSource.value.works ?? []);
const shareCode = computed(() => route.params.shareCode);
const onClickItem = (item) => {
router.push({
path: `/explore/detail/${shareCode.value}/${item.id}`,
});
};
const getShareWorks = async () => {
try {
loading.value = true;
const { code, data } = await getShareWorksList(shareCode.value);
if (code === 200) {
dataSource.value = data;
}
} finally {
loading.value = false;
}
};
onMounted(() => {
getShareWorks();
});
return () => {
return (
<div class="explore-page">
<header class="page-header">
<div class="content w-full px-24px flex items-center">
<div class="h-full flex items-center">
<img src={icon2} alt="" width="96" height="24" />
</div>
</div>
</header>
<section class="page-wrapper flex justify-center">
{loading.value ? (
<Spin spinning={loading.value} class="w-full flex justify-center items-center" size={60} />
) : (
<div class="explore-container">
<div class="explore-list-wrap pt-24px pb-28px">
<div class="mb-8px flex items-center w-fit">
<TextOverTips context={`${works.value[0]?.title?.slice(0,10)}...`} class="!w-fit mr-4px" />
<span class="cts color-#211F24">{`${works.value.length}个文件`}</span>
</div>
{/* <TextOverTips context={`${works.value[0]?.title}等${works.value.length}个文件`} /> */}
<p class="cts !color-#939499 mb-24px">
{`分享时间:${exactFormatTime(dataSource.value.created_at, 'YYYY-MM-DD HH:mm:ss')} 有效期${
dataSource.value.days
}`}
</p>
<div class="card-container">
{works.value.map((item) => {
return (
<div
class="card-item rounded-8px overflow-hidden"
key={item.id}
onClick={() => onClickItem(item)}
>
<Image
src={item.cover}
width={'100%'}
height={300}
preview={false}
fit="cover"
v-slots={{
error: () => <img src={icon1} class="w-full h-full" />,
}}
/>
<div class="p-16px">
<TextOverTips context={item.title} class=" !lh-24px !text-16px mb-8px bold" />
<div class="flex justify-between">
<p class="cts color-#737478">
{exactFormatTime(item.last_modified_at, 'YYYY年MM月DD日')}修改
</p>
{item.customer_opinion === ENUM_OPINION.confirm && (
<div class="h-24px px-8px flex justify-center items-center rounded-2px bg-#EBF7F2">
<span class="cts bold color-#25C883">已确认</span>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</section>
</div>
);
};
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,75 @@
.explore-page {
position: relative;
padding-top: $navbar-height;
min-width: 1200px;
.cts {
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&.bold {
font-family: $font-family-medium;
}
}
.page-header {
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 1000;
min-width: 1200px;
.content {
height: $navbar-height;
// border-bottom: 1px solid var(--Border-1, #d7d7d9);
}
&::before {
width: 100%;
height: 100%;
background: url('@/assets/img/icon-app-header-bg.png') center top no-repeat !important;
background-size: cover !important;
bottom: 0;
content: '';
display: block;
left: 0;
position: absolute;
right: 0;
top: 0;
z-index: -998;
}
}
.page-wrapper {
min-height: calc(100vh - $navbar-height);
.explore-container {
width: 1200px;
.explore-list-wrap {
:deep(.overflow-text) {
color: var(--Text-1, #211f24);
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&.bold {
font-family: $font-family-medium;
}
}
.card-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
.card-item {
border: 1px solid var(--Border-1, #d7d7d9);
cursor: pointer;
transition: all 0.3s;
&:hover {
box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.15);
border: 1.01px solid var(--Border-1, #d7d7d9);
border-radius: 8.08px;
}
}
}
}
}
}
}

View File

@ -1,22 +1,57 @@
<script lang="tsx">
import { Tabs, TabPane } from 'ant-design-vue';
import ManuscriptList from './manuscript/list/index.vue';
import ManuscriptCheckList from './manuscript/check-list/index.vue';
import ShareManuscriptModal from '@/views/material-center/components/finished-products/manuscript/components/share-manuscript-modal/index.vue';
import UploadManuscriptModal from '@/views/material-center/components/finished-products/manuscript/components/upload-manuscript-modal/index.vue';
import { TABS_LIST, AuditStatus } from './constants';
export default defineComponent({
setup(_, { attrs, slots, expose }) {
const activeKey = ref(AuditStatus.All);
const shareManuscriptModalRef = ref(null);
const uploadManuscriptModalRef = ref(null);
const audit_status = ref(AuditStatus.All);
const showManuscriptList = computed(() => {
return audit_status.value === AuditStatus.All;
});
const handleShareModal = () => {
shareManuscriptModalRef.value?.open();
};
const openUploadModal = () => {
uploadManuscriptModalRef.value?.open();
};
return () => (
<div class="finished-products-wrap h-full flex flex-col">
<div class="bg-#fff rounded-8px mb-16px">
<Tabs v-model:activeKey={activeKey.value} v-slots={{
'rightExtra': () => (
<div class="flex items-center">
123
</div>
)
}}>
<div class="bg-white rounded-t-8px">
<Tabs
v-model:activeKey={audit_status.value}
v-slots={{
rightExtra: () => (
<div class="flex items-center">
<a-button type="outline" size="medium" onClick={handleShareModal}>
分享内容稿件
</a-button>
{showManuscriptList.value && (
<a-button
type="primary"
size="medium"
class="ml-12px"
onClick={openUploadModal}
v-slots={{
icon: () => <icon-plus size="16" />,
default: () => '上传内容稿件',
}}
/>
)}
</div>
),
}}
>
{TABS_LIST.map((item) => (
<TabPane key={item.value} tab={item.label}>
{item.label}
@ -24,6 +59,10 @@ export default defineComponent({
))}
</Tabs>
</div>
{showManuscriptList.value ? <ManuscriptList /> : <ManuscriptCheckList audit_status={audit_status.value} />}
<ShareManuscriptModal ref={shareManuscriptModalRef} />
<UploadManuscriptModal ref={uploadManuscriptModalRef} />
</div>
);
},

View File

@ -0,0 +1,142 @@
<!-- eslint-disable vue/no-mutating-props -->
<!--
* @Author: RenXiaoDong
* @Date: 2025-06-25 14:02:40
-->
<template>
<div class="common-filter-wrap">
<div class="filter-row">
<div class="filter-row-item">
<span class="label">内容稿件标题</span>
<a-space size="medium">
<a-input
v-model="query.title"
class="!w-240px"
placeholder="请输入内容稿件标题"
size="medium"
allow-clear
@change="handleSearch"
>
<template #prefix>
<icon-search />
</template>
</a-input>
</a-space>
</div>
<div class="filter-row-item">
<span class="label">序号</span>
<a-space size="medium">
<a-input
v-model="query.uid"
class="!w-160px"
placeholder="请输入序号"
size="medium"
allow-clear
@change="handleSearch"
>
<template #prefix>
<icon-search />
</template>
</a-input>
</a-space>
</div>
<div class="filter-row-item" v-if="query.audit_status === AuditStatus.Pending">
<span class="label">上传时间</span>
<a-range-picker
v-model="created_at"
size="medium"
allow-clear
format="YYYY-MM-DD"
class="!w-280px"
@change="(value) => onDateChange(value, 'created_at')"
/>
</div>
<template v-if="[AuditStatus.Auditing, AuditStatus.Passed].includes(query.audit_status)">
<div class="filter-row-item">
<span class="label">审核平台</span>
<a-select
v-model="query.audit_platform"
size="medium"
placeholder="全部"
allow-clear
@change="handleSearch"
class="!w-160px"
>
<a-option v-for="(item, index) in PLATFORMS" :key="index" :value="item.value" :label="item.label">{{
item.label
}}</a-option>
</a-select>
</div>
<div class="filter-row-item">
<span class="label">审核时间</span>
<a-range-picker
v-model="audit_started_at"
size="medium"
allow-clear
format="YYYY-MM-DD"
class="!w-280px"
@change="(value) => onDateChange(value, 'audit_started_at')"
/>
</div>
</template>
<div class="filter-row-item">
<a-button type="outline" class="mr-12px" size="medium" @click="handleSearch">
<template #icon>
<icon-search />
</template>
<template #default>搜索</template>
</a-button>
<a-button size="medium" @click="handleReset">
<template #icon>
<icon-refresh />
</template>
<template #default>重置</template>
</a-button>
</div>
</div>
</div>
</template>
<script setup>
import { defineEmits, defineProps } from 'vue';
import { PLATFORMS } from '@/views/material-center/components/finished-products/manuscript/check-list/constants';
import { AuditStatus } from '@/views/material-center/components/finished-products/constants';
const props = defineProps({
query: {
type: Object,
required: true,
},
});
const emits = defineEmits('search', 'reset', 'update:query');
const created_at = ref([]);
const audit_started_at = ref([]);
const handleSearch = () => {
emits('update:query', props.query);
nextTick(() => {
emits('search');
});
};
const onDateChange = (value, type) => {
if (!value) {
props.query[type] = [];
handleSearch();
return;
}
const [start, end] = value;
const FORMAT_DATE = 'YYYY-MM-DD HH:mm:ss';
props.query[type] = [dayjs(start).startOf('day').format(FORMAT_DATE), dayjs(end).endOf('day').format(FORMAT_DATE)];
handleSearch();
};
const handleReset = () => {
created_at.value = [];
emits('reset');
};
</script>

View File

@ -0,0 +1,58 @@
<template>
<a-modal
v-model:visible="visible"
title="删除内容稿件"
width="480px"
@close="onClose"
>
<div class="flex items-center">
<img :src="icon1" width="20" height="20" class="mr-12px" />
<span>确认删除 {{ projectName }} 这个内容稿件吗</span>
</div>
<template #footer>
<a-button size="medium" @click="onClose">取消</a-button>
<a-button type="primary" class="ml-16px" status="danger" size="medium" @click="onDelete"
>确认删除</a-button
>
</template>
</a-modal>
</template>
<script setup>
import { ref } from 'vue';
import { deleteWork } from '@/api/all/generationWorkshop';
import icon1 from '@/assets/img/media-account/icon-warn-1.png';
const update = inject('update');
const visible = ref(false);
const projectId = ref(null);
const projectName = ref('');
const isBatch = computed(() => Array.isArray(projectId.value));
function onClose() {
visible.value = false;
projectId.value = null;
projectName.value = '';
}
const open = (record) => {
const { id = null, name = '' } = record;
projectId.value = id;
projectName.value = name;
visible.value = true;
};
async function onDelete() {
const { code } = await deleteWork(projectId.value);
if (code === 200) {
AMessage.success('删除成功');
update()
onClose();
}
}
defineExpose({ open });
</script>

View File

@ -0,0 +1,207 @@
<template>
<a-table
ref="tableRef"
:data="dataSource"
row-key="id"
column-resizable
:pagination="false"
:scroll="{ x: '100%' }"
class="flex-1 manuscript-table w-100%"
bordered
:row-selection="rowSelection"
:selected-row-keys="selectedRowKeys"
@sorter-change="handleSorterChange"
@select="(selectedKeys, rowKeyValue, record) => emits('select', selectedKeys, rowKeyValue, record)"
@select-all="(check) => emits('selectAll', check)"
>
<template #empty>
<NoData text="暂无稿件" />
</template>
<template #columns>
<a-table-column
v-for="column in tableColumns"
:key="column.dataIndex"
:data-index="column.dataIndex"
:fixed="column.fixed"
:width="column.width"
:min-width="column.minWidth"
:sortable="column.sortable"
:align="column.align"
ellipsis
tooltip
>
<template #title>
<div class="flex items-center">
<span class="cts mr-4px">{{ column.title }}</span>
<a-tooltip v-if="column.tooltip" :content="column.tooltip" position="top">
<icon-question-circle class="tooltip-icon color-#737478" size="16" />
</a-tooltip>
</div>
</template>
<template v-if="column.dataIndex === 'customer_opinion'" #cell="{ record }">
<p
class="h-28px px-8px flex items-center rounded-2px w-fit"
:style="{ background: getCustomerOpinionInfo(record.customer_opinion)?.bg }"
>
<span class="cts" :class="getCustomerOpinionInfo(record.customer_opinion)?.color">{{
getCustomerOpinionInfo(record.customer_opinion)?.label ?? '-'
}}</span>
</p>
</template>
<template v-else-if="column.dataIndex === 'platform'" #cell="{ record }">
<template v-if="!PLATFORMS.find((item) => item.value === record.platform)"> - </template>
<img
v-else
width="24"
height="24"
class="rounded-4px"
:src="PLATFORMS.find((item) => item.value === record.platform)?.icon"
/>
</template>
<template v-else-if="column.dataIndex === 'compliance_level'" #cell="{ record }">
<span class="cts num !color-#6D4CFE">{{
record.ai_review?.compliance_level ? `${record.ai_review?.compliance_level}%` : '-'
}}</span>
</template>
<template v-else-if="column.dataIndex === 'title'" #cell="{ record }">
<TextOverTips :context="record.title" :line="3" class="title" @click="onDetail(record)" />
</template>
<template v-else-if="column.dataIndex === 'type'" #cell="{ record }">
<div class="flex items-center">
<img
:src="record.type === EnumManuscriptType.Image ? icon2 : icon3"
width="16"
height="16"
class="mr-4px"
/>
<span class="cts" :class="record.type === EnumManuscriptType.Image ? '!color-#25C883' : '!color-#6D4CFE'">{{
record.type === EnumManuscriptType.Image ? '图文' : '视频'
}}</span>
</div>
</template>
<template v-else-if="['uploader', 'last_modifier'].includes(column.dataIndex)" #cell="{ record }">
{{ record[column.dataIndex].name || record[column.dataIndex].mobile }}
</template>
<template
#cell="{ record }"
v-else-if="
['created_at', 'last_modified_at', 'audit_started_at', 'audit_passed_at'].includes(column.dataIndex)
"
>
<span class="cts num">{{ exactFormatTime(record[column.dataIndex]) }}</span>
</template>
<template v-else-if="column.dataIndex === 'cover'" #cell="{ record }">
<HoverImagePreview :src="record.cover">
<a-image :width="64" :height="64" :src="record.cover" class="!rounded-6px" fit="cover">
<template #error>
<img :src="icon4" class="w-full h-full" />
</template>
</a-image>
</HoverImagePreview>
</template>
<template v-else-if="column.dataIndex === 'operation'" #cell="{ record }">
<div class="flex items-center">
<img class="mr-8px cursor-pointer" :src="icon1" width="14" height="14" @click="onDelete(record)" />
<a-button type="outline" size="mini" @click="onShare(record)" v-if="audit_status === AuditStatus.Passed"
>分享</a-button
>
<a-button
type="outline"
size="mini"
@click="onCheck(record)"
v-else-if="audit_status === AuditStatus.Pending"
>审核</a-button
>
<a-button type="outline" size="mini" @click="onScan(record)" v-else>查看</a-button>
</div>
</template>
<template v-else #cell="{ record }">
{{ formatTableField(column, record, true) }}
</template>
</a-table-column>
</template>
</a-table>
<ShareModal ref="shareModalRef" />
</template>
<script setup>
import { ref } from 'vue';
import { formatTableField, exactFormatTime } from '@/utils/tools';
import { EnumManuscriptType } from '@/views/material-center/components/finished-products/manuscript/list/constants';
import { patchWorkAuditsAudit } from '@/api/all/generationWorkshop';
import {
CUSTOMER_OPINION,
PLATFORMS,
} from '@/views/material-center/components/finished-products/manuscript/check-list/constants';
import { AuditStatus } from '@/views/material-center/components/finished-products/constants';
import { slsWithCatch } from '@/utils/stroage.ts';
import TextOverTips from '@/components/text-over-tips';
import ShareModal from '@/views/material-center/components/finished-products/manuscript/components/share-manuscript-modal/share-modal.vue';
import HoverImagePreview from '@/components/hover-image-preview';
import icon1 from '@/assets/img/media-account/icon-delete.png';
import icon2 from '@/assets/img/creative-generation-workshop/icon-photo.png';
import icon3 from '@/assets/img/creative-generation-workshop/icon-video.png';
import icon4 from '@/assets/img/error-img.png';
const emits = defineEmits(['edit', 'sorterChange', 'delete', 'select', 'selectAll']);
const router = useRouter();
const props = defineProps({
dataSource: {
type: Array,
default: () => [],
},
tableColumns: {
type: Array,
default: () => [],
},
rowSelection: {
type: Array,
default: () => [],
},
selectedRowKeys: {
type: Array,
default: () => [],
},
audit_status: {
type: String,
},
});
const tableRef = ref(null);
const shareModalRef = ref(null);
const handleSorterChange = (column, order) => {
emits('sorterChange', column, order === 'ascend' ? 'asc' : 'desc');
};
const onDelete = (item) => {
emits('delete', item);
};
const onShare = (item) => {
shareModalRef.value?.open([item.id]);
};
const onCheck = (item) => {
patchWorkAuditsAudit(item.id);
slsWithCatch('manuscriptCheckIds', [item.id]);
router.push({ name: 'ManuscriptCheck' });
};
const onScan = (item) => {
slsWithCatch('manuscriptCheckIds', [item.id]);
router.push({ name: 'ManuscriptCheck' });
};
const onDetail = (item) => {
router.push(`/material-center/check-list/detail/${item.id}?source=check&audit_status=${props.audit_status}`);
};
const getCustomerOpinionInfo = (value) => {
return CUSTOMER_OPINION.find((item) => item.value === value);
};
</script>
<style scoped lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,19 @@
.manuscript-table {
.cts {
color: var(--Text-1, #211f24);
font-family: $font-family-medium;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&.num {
font-family: $font-family-manrope-regular;
}
}
:deep(.title) {
cursor: pointer;
&:hover {
color: #6d4cfe;
}
}
}

View File

@ -0,0 +1,256 @@
import { AuditStatus } from '@/views/material-center/components/finished-products/constants';
export const TABLE_COLUMNS1 = [
{
title: '序号',
dataIndex: 'uid',
width: 120,
fixed: 'left',
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '图片/视频',
dataIndex: 'cover',
width: 120,
},
{
title: '内容稿件标题',
dataIndex: 'title',
width: 300,
},
{
title: '客户意见',
dataIndex: 'customer_opinion',
width: 120,
},
{
title: '稿件类型',
dataIndex: 'type',
width: 120,
},
{
title: '上传时间',
dataIndex: 'created_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '上传人员',
dataIndex: 'uploader',
width: 180,
},
{
title: '最后修改时间',
dataIndex: 'last_modified_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '修改人员',
dataIndex: 'last_modifier',
width: 180,
},
{
title: '操作',
dataIndex: 'operation',
width: 120,
fixed: 'right',
},
];
export const TABLE_COLUMNS2 = [
{
title: '序号',
dataIndex: 'uid',
width: 120,
fixed: 'left',
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '图片/视频',
dataIndex: 'cover',
width: 120,
},
{
title: '内容稿件标题',
dataIndex: 'title',
width: 300,
},
{
title: '客户意见',
dataIndex: 'customer_opinion',
width: 120,
},
{
title: '审核平台',
dataIndex: 'platform',
width: 120,
},
{
title: '合规程度',
dataIndex: 'compliance_level',
suffix: '%',
width: 120,
},
{
title: '稿件类型',
dataIndex: 'type',
width: 120,
},
{
title: '审核时间',
dataIndex: 'audit_started_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '最后修改时间',
dataIndex: 'last_modified_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '修改人员',
dataIndex: 'last_modifier',
width: 180,
},
{
title: '操作',
dataIndex: 'operation',
width: 120,
fixed: 'right',
},
];
export const TABLE_COLUMNS3 = [
{
title: '序号',
dataIndex: 'uid',
width: 120,
fixed: 'left',
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '图片/视频',
dataIndex: 'cover',
width: 120,
},
{
title: '内容稿件标题',
dataIndex: 'title',
width: 300,
},
{
title: '客户意见',
dataIndex: 'customer_opinion',
width: 120,
},
{
title: '审核平台',
dataIndex: 'platform',
width: 120,
},
{
title: '稿件类型',
dataIndex: 'type',
width: 120,
},
{
title: '通过时间',
dataIndex: 'audit_passed_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '最后修改时间',
dataIndex: 'last_modified_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '修改人员',
dataIndex: 'last_modifier',
width: 180,
},
{
title: '操作',
dataIndex: 'operation',
width: 120,
fixed: 'right',
},
];
export const AUDIT_STATUS_LIST = [
{
label: '待审核',
value: AuditStatus.Pending,
tableColumns: TABLE_COLUMNS1,
},
{
label: '审核中',
value: AuditStatus.Auditing,
tableColumns: TABLE_COLUMNS2,
},
{
label: '已通过',
value: AuditStatus.Passed,
tableColumns: TABLE_COLUMNS3,
},
];
export const INITIAL_QUERY = {
audit_status: AuditStatus.Pending,
title: '',
created_at: [],
audit_started_at: [],
audit_platform: '',
sort_column: undefined,
sort_order: undefined,
};
import icon1 from '@/assets/img/platform/icon-dy.png';
import icon2 from '@/assets/img/platform/icon-xhs.png';
export const PLATFORMS = [
{
label: '小红书',
value: 1,
icon: icon2,
},
{
label: '抖音',
value: 2,
icon: icon1,
},
];
export const CUSTOMER_OPINION = [
{
label: '待确认',
value: 0,
bg: '#F2F3F5',
color: 'color-#3C4043',
},
{
label: '已确认',
value: 1,
bg: '#F0EDFF',
color: '!color-#6D4CFE',
},
];

View File

@ -0,0 +1,200 @@
<template>
<div class="manuscript-check-wrap">
<div class="filter-wrap bg-#fff rounded-b-8px mb-16px">
<FilterBlock
v-model:query="query"
:audit_status="query.audit_status"
@search="handleSearch"
@reset="handleReset"
/>
</div>
<div class="table-wrap bg-#fff rounded-8px px-24px py-24px flex flex-col">
<div
class="flex justify-end mb-12px"
v-if="[AuditStatus.Pending, AuditStatus.Auditing].includes(query.audit_status)"
>
<a-button
type="outline"
class="w-fit"
size="medium"
@click="handleBatchCheck"
v-if="query.audit_status === AuditStatus.Pending"
>批量审核</a-button
>
<a-button
type="outline"
class="w-fit"
size="medium"
@click="handleBatchView"
v-if="query.audit_status === AuditStatus.Auditing"
>批量查看</a-button
>
</div>
<ManuscriptCheckTable
:key="query.audit_status"
:tableColumns="tableColumns"
:rowSelection="rowSelection"
:selectedRowKeys="selectedRowKeys"
:dataSource="dataSource"
:audit_status="query.audit_status"
@sorterChange="handleSorterChange"
@delete="handleDelete"
@edit="handleEdit"
@select="handleSelect"
@selectAll="handleSelectAll"
/>
<div v-if="pageInfo.total > 0" class="pagination-row">
<a-pagination
:total="pageInfo.total"
size="mini"
show-total
show-jumper
show-page-size
:current="pageInfo.page"
:page-size="pageInfo.page_size"
@change="onPageChange"
@page-size-change="onPageSizeChange"
/>
</div>
</div>
<DeleteManuscriptModal ref="deleteManuscriptModalRef" />
</div>
</template>
<script lang="jsx" setup>
import { Button, Message as AMessage } from '@arco-design/web-vue';
import FilterBlock from './components/filter-block';
import ManuscriptCheckTable from './components/manuscript-check-table';
import DeleteManuscriptModal from './components/manuscript-check-table/delete-manuscript-modal.vue';
import { getWorkAuditsPage, patchWorkAuditsBatchAudit } from '@/api/all/generationWorkshop.ts';
import { useTableSelectionWithPagination } from '@/hooks/useTableSelectionWithPagination';
import { slsWithCatch } from '@/utils/stroage.ts';
import { AuditStatus } from '@/views/material-center/components/finished-products/constants';
import { INITIAL_QUERY, TABLE_COLUMNS1, AUDIT_STATUS_LIST } from './constants';
const props = defineProps({
audit_status: {
type: String,
default: AuditStatus.All,
},
});
const {
dataSource,
pageInfo,
rowSelection,
onPageChange,
onPageSizeChange,
resetPageInfo,
selectedRowKeys,
selectedRows,
handleSelect,
handleSelectAll,
DEFAULT_PAGE_INFO,
} = useTableSelectionWithPagination({
onPageChange: () => {
getData();
},
onPageSizeChange: () => {
getData();
},
});
const router = useRouter();
const tableColumns = ref([]);
const query = ref(cloneDeep(INITIAL_QUERY));
const addManuscriptModalRef = ref(null);
const deleteManuscriptModalRef = ref(null);
const getData = async () => {
const { page, page_size } = pageInfo.value;
const { code, data } = await getWorkAuditsPage({
...query.value,
page,
page_size,
});
if (code === 200) {
dataSource.value = data?.data ?? [];
pageInfo.value.total = data.total;
}
};
const handleSearch = () => {
reload();
};
const reload = () => {
pageInfo.value.page = 1;
getData();
};
const handleReset = () => {
resetPageInfo();
const _audit_status = query.value.audit_status;
query.value = cloneDeep(INITIAL_QUERY);
query.value.audit_status = _audit_status;
reload();
};
const handleSorterChange = (column, order) => {
query.value.sort_column = column;
query.value.sort_order = order;
reload();
};
const handleBatchCheck = () => {
if (!selectedRows.value.length) {
AMessage.warning('请选择需审核的内容稿件');
return;
}
patchWorkAuditsBatchAudit({ ids: selectedRowKeys.value });
slsWithCatch('manuscriptCheckIds', selectedRowKeys.value);
router.push({ name: 'ManuscriptCheck' });
};
const handleBatchView = () => {
if (!selectedRows.value.length) {
AMessage.warning('请选择需查看的内容稿件');
return;
}
slsWithCatch('manuscriptCheckIds', selectedRowKeys.value);
router.push({ name: 'ManuscriptCheck' });
};
const handleTabClick = (key) => {
query.value = cloneDeep(INITIAL_QUERY);
dataSource.value = [];
selectedRowKeys.value = [];
selectedRows.value = [];
resetPageInfo();
query.value.audit_status = key;
tableColumns.value = AUDIT_STATUS_LIST.find((item) => item.value === key).tableColumns;
getData();
};
const handleDelete = (item) => {
const { id, title } = item;
deleteManuscriptModalRef.value?.open({ id, name: `${title}` });
};
const handleEdit = (item) => {
// addManuscriptModalRef.value?.open(item.id);
};
watch(
() => props.audit_status,
(newVal) => {
console.log('newVal', newVal)
handleTabClick(newVal);
},
{ immediate: false },
);
onMounted(() => {
tableColumns.value = TABLE_COLUMNS1;
getData();
});
provide('update', getData);
</script>
<style scoped lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,35 @@
.manuscript-check-wrap {
// height: 100%;
display: flex;
flex-direction: column;
.filter-wrap {
:deep(.arco-tabs) {
.arco-tabs-tab {
height: 56px;
padding: 0 8px;
}
.arco-tabs-nav-extra {
padding-right: 24px;
}
.arco-tabs-content {
display: none;
}
}
.top {
.title {
font-family: $font-family-medium;
font-style: normal;
}
:deep(.arco-btn) {
.arco-btn-icon {
line-height: 16px;
}
}
}
}
.table-wrap {
display: flex;
flex-direction: column;
}
}

View File

@ -0,0 +1,57 @@
<template>
<a-modal
v-model:visible="visible"
:title="action === 'exit' ? '退出审核' : '切换内容稿件'"
width="480px"
@close="onClose"
>
<div class="flex items-center">
<img :src="icon1" width="20" height="20" class="mr-12px" />
<span>{{
action === 'exit'
? '内容已修改尚未保存,若退出编辑,本次修改将不保存。'
: '当前内容已修改尚未保存,若切换内容稿件,本次修改将不保存。'
}}</span>
</div>
<template #footer>
<a-button size="medium" @click="onClose">继续编辑</a-button>
<a-button type="primary" class="ml-8px" size="medium" @click="onConfirm">
{{ action === 'exit' ? '确认退出' : '确认切换' }}
</a-button>
</template>
</a-modal>
</template>
<script setup>
import { ref } from 'vue';
import icon1 from '@/assets/img/media-account/icon-warn-1.png';
import { useRouter } from 'vue-router';
const emit = defineEmits(['selectCard']);
const router = useRouter();
const visible = ref(false);
const action = ref('');
const cardInfo = ref(null);
const onClose = () => {
action.value = '';
cardInfo.value = null;
visible.value = false;
};
const onConfirm = () => {
if (action.value === 'exit') {
router.push({ name: 'MaterialCenterFinishedProducts' });
} else {
emit('selectCard', cardInfo.value);
}
onClose();
};
const open = (type = 'exit', card = null) => {
action.value = type;
cardInfo.value = card;
visible.value = true;
};
defineExpose({ open });
</script>

View File

@ -0,0 +1,61 @@
<template>
<a-modal
v-model:visible="visible"
title="提示"
width="480px"
@close="onClose"
modal-class="upload-success11-modal"
:footer="null"
>
<div class="flex items-center flex-col justify-center">
<img :src="icon1" width="80" height="80" class="mb-16px" />
<span class="text-18px lh-26px font-400 color-#211F24 md mb-8px">内容稿件已通过审核</span>
<p class="text-14px lh-22px font-400 color-#737478 ld">想让内容更抓眼球更吸流量吗</p>
<p class="text-14px lh-22px font-400 color-#737478 ld">试试内容稿件分析功能吧</p>
</div>
<!-- <template #footer>
<a-button type="primary" class="ml-8px" size="medium" @click="onConfirm">内容稿件分析</a-button>
</template> -->
</a-modal>
</template>
<script setup>
import { ref } from 'vue';
import icon1 from '@/assets/img/media-account/icon-feedback-success.png';
const router = useRouter();
const visible = ref(false);
const workIds = ref([]);
const onClose = () => {
if (workIds.value.length === 1) {
router.push({ name: 'MaterialCenterFinishedProducts' });
}
workIds.value = [];
visible.value = false;
};
const open = (ids) => {
workIds.value = cloneDeep(ids);
visible.value = true;
};
defineExpose({ open });
</script>
<style lang="scss">
.upload-success11-modal {
.arco-modal-header {
border-bottom: none;
}
.md {
font-family: $font-family-medium;
}
.ld {
font-family: $font-family-regular;
}
.arco-modal-footer {
border-top: none;
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,87 @@
<script lang="jsx">
import { Drawer, Image } from '@arco-design/web-vue';
import TextOverTips from '@/components/text-over-tips';
import icon1 from '@/assets/img/error-img.png';
export default {
emits: ['cardClick'],
setup(props, { emit, expose }) {
const visible = ref(false);
const dataSource = ref([]);
const selectCardInfo = ref({});
const handleCardClick = (item) => {
emit('cardClick', item);
onClose();
};
const open = (data, _selectCardInfo) => {
dataSource.value = data;
selectCardInfo.value = _selectCardInfo;
visible.value = true;
};
const onClose = () => {
dataSource.value = [];
selectCardInfo.value = {};
visible.value = false;
};
expose({
open,
});
return () => (
<Drawer
title="审核列表"
visible={visible.value}
width={420}
class="check-list-drawer-xt"
footer={false}
header={false}
onCancel={onClose}
>
<div class="flex justify-between items-center h-56px px-24px">
<div class="flex items-center">
<div class="w-3px h-16px rounded-2px bg-#6D4CFE mr-8px"></div>
<span class="mr-8px cts bold">批量审核列表</span>
<span class="mr-8px cts !lh-22px !text-14px">{`${dataSource.value.length}`}</span>
</div>
<icon-menu-unfold size={16} class="color-##55585F cursor-pointer hover:color-#6D4CFE" onClick={onClose} />
</div>
<div class="flex-1 overflow-y-auto px-24px">
{dataSource.value.map((item) => (
<div
onClick={() => handleCardClick(item)}
key={item.id}
class={`card-item flex rounded-8px bg-#F7F8FA p-8px ${
selectCardInfo.value.id === item.id ? 'active' : ''
}`}
>
<Image
width={48}
height={48}
preview={false}
src={item.cover}
class="!rounded-4px mr-8px"
fit="cover"
v-slots={{
error: () => <img src={icon1} class="w-full h-full" />,
}}
/>
<div class="flex-1 overflow-hidden flex flex-col items-start">
<TextOverTips context={item.title} class={`cts !color-#211F24 title mb-4px !text-14px`} />
<p class="cts !text-14px">{`合规程度:${
item.ai_review?.compliance_level ? `${item.ai_review?.compliance_level}%` : '-'
}`}</p>
</div>
</div>
))}
</div>
</Drawer>
);
},
};
</script>
<style lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,45 @@
.check-list-drawer-xt {
.arco-drawer-mask {
background-color: transparent;
}
.arco-drawer {
box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.15);
.arco-drawer-body {
overflow: hidden;
display: flex;
flex-direction: column;
padding: 0 0 24px;
.cts {
color: var(--Text-1, #939499);
font-family: $font-family-regular;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 24px;
&.bold {
color: var(--Text-1, #211f24);
font-family: $font-family-medium;
}
}
.card-item {
cursor: pointer;
border: 1px solid transparent;
transition: all;
&:hover {
background-color: #e6e6e8;
}
&:not(:last-child) {
margin-bottom: 12px;
}
&.active {
border-color: #6d4cfe;
background-color: #f0edff;
:deep(.overflow-text) {
font-family: $font-family-medium !important;
}
}
}
}
}
}

View File

@ -0,0 +1,57 @@
export const escapeRegExp = (str: string) => {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
export const FORM_RULES = {
title: [{ required: true, message: '请输入标题' }],
};
export const enumTab = {
TEXT: 0,
IMAGE: 1,
};
export const TAB_LIST = [
{
label: '文本',
value: enumTab.TEXT,
},
// {
// label: '图片',
// value: enumTab.IMAGE,
// },
];
export enum Enum_Level {
LOW = 0,
MEDIUM = 1,
HIGH = 2,
}
export const LEVEL_MAP = new Map([
[Enum_Level.LOW, { label: '低风险', value: 'low_risk_number', color: '#6d4cfe' }],
[Enum_Level.MEDIUM, { label: '中风险', value: 'medium_risk_number', color: '#FFAE00' }],
[Enum_Level.HIGH, { label: '高风险', value: 'high_risk_number', color: '#F64B31' }],
]);
export const RESULT_LIST = [
{
label: '合规程度',
value: 'compliance_level',
color: LEVEL_MAP.get(Enum_Level.LOW)?.color,
suffix: '%',
},
{
label: '检验项',
value: 'inspection_count',
color: '#211F24',
},
{
label: '高风险',
value: 'high_risk_number',
color: LEVEL_MAP.get(Enum_Level.HIGH)?.color,
},
{
label: '中风险',
value: 'medium_risk_number',
color: LEVEL_MAP.get(Enum_Level.MEDIUM)?.color,
},
];

View File

@ -0,0 +1,234 @@
<template>
<div class="highlight-textarea-container">
<a-textarea
ref="textareaWrapRef"
v-model="inputValue"
placeholder="请输入作品描述"
:disabled="disabled"
show-word-limit
:max-length="1000"
size="large"
class="textarea-input h-full w-full"
@input="handleInput"
@focus="() => (focus = true)"
@blur="() => (focus = false)"
/>
<div
class="textarea-highlight"
:class="{ focus: focus }"
:style="{ visibility: inputValue ? 'visible' : 'hidden' }"
v-html="highlightedHtml"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, defineProps, defineEmits, onMounted, onUnmounted } from 'vue';
import { escapeRegExp } from './constants';
// 定义Props类型
interface ViolationItem {
word: string;
risk_level: number;
}
interface LevelMapItem {
color: string;
}
const props = defineProps<{
modelValue?: string;
prohibitedWords?: ViolationItem[];
levelMap?: Map<number, LevelMapItem>;
disabled?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
// 内部状态管理
const inputValue = ref(props.modelValue || '');
const scrollTop = ref(0);
const focus = ref(false);
const textareaWrapRef = ref();
let nativeTextarea: HTMLTextAreaElement | null = null;
const highlightedHtml = computed(() => generateHighlightedHtml());
// 监听外部modelValue变化
watch(
() => props.modelValue,
(newVal) => {
if (newVal !== inputValue.value) {
inputValue.value = (newVal || '').slice(0, 1000);
}
},
);
// 处理输入事件
const handleInput = (value: string) => {
emit('update:modelValue', value);
};
const escapeHtml = (str: string): string => {
if (!isString(str)) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
const generateHighlightedHtml = (): string => {
if (!inputValue.value) return '';
// 获取违禁词列表并按长度倒序排序(避免短词匹配长词)
const words = (props.prohibitedWords || [])
.filter((item) => item.word && item.risk_level !== undefined)
.sort((a, b) => b.word.length - a.word.length);
if (words.length === 0) {
return escapeHtml(inputValue.value);
}
// 创建匹配正则表达式
const pattern = new RegExp(`(${words.map((item) => escapeRegExp(item.word)).join('|')})`, 'gi');
// 替换匹配的违禁词为带样式的span标签
return inputValue.value.replace(pattern, (match) => {
// 找到对应的违禁词信息
const wordInfo = words.find((item) => item.word.toLowerCase() === match.toLowerCase());
if (!wordInfo) return match;
// 获取风险等级对应的样式
const levelStyle = props.levelMap?.get(wordInfo.risk_level);
const color = levelStyle?.color || '#F64B31';
return `<span class="text-14px font-400 lh-22px" style="color: ${color};">${escapeHtml(match)}</span>`;
});
};
onMounted(() => {
nativeTextarea =
(textareaWrapRef.value?.$el || textareaWrapRef.value)?.querySelector?.('textarea.arco-textarea') ||
document.querySelector('.textarea-input .arco-textarea');
if (nativeTextarea) {
nativeTextarea.addEventListener('scroll', handleTextareaScroll);
nativeTextarea.addEventListener('compositionstart', handleCompositionUpdate);
nativeTextarea.addEventListener('compositionupdate', handleCompositionUpdate);
nativeTextarea.addEventListener('compositionend', handleCompositionUpdate);
}
});
onUnmounted(() => {
if (nativeTextarea) {
nativeTextarea.removeEventListener('scroll', handleTextareaScroll);
nativeTextarea.removeEventListener('compositionstart', handleCompositionUpdate);
nativeTextarea.removeEventListener('compositionupdate', handleCompositionUpdate);
nativeTextarea.removeEventListener('compositionend', handleCompositionUpdate);
}
});
const handleTextareaScroll = (e: Event) => {
const _scrollTop = (e.target as HTMLTextAreaElement).scrollTop;
const highlightElement = document.querySelector('.textarea-highlight');
if (highlightElement) {
highlightElement.scrollTop = _scrollTop;
}
};
const handleCompositionUpdate = () => {
if (!nativeTextarea) return;
// 使用 rAF 等待浏览器把最新字符写入 textarea.value 再读取
requestAnimationFrame(() => {
if (!nativeTextarea) return;
const latest = nativeTextarea.value.slice(0, 1000);
if (latest !== inputValue.value) {
inputValue.value = latest;
}
});
};
</script>
<style scoped lang="scss">
.highlight-textarea-container {
position: relative;
width: 100%;
height: 100%;
@mixin textarea-padding {
padding: 8px 12px;
}
@mixin textarea-style {
box-sizing: border-box;
width: 100%;
height: 100%;
font-size: 14px;
line-height: 22px;
font-weight: 400px;
border: 1px solid #d7d7d9;
border-radius: 4px;
resize: none;
white-space: pre-wrap;
word-wrap: break-word;
z-index: inherit;
}
.textarea-highlight {
@include textarea-style;
position: absolute;
top: 0;
left: 0;
z-index: 0;
pointer-events: none;
background: #fff;
overflow: hidden;
@include textarea-padding;
&.focus {
border-color: rgb(var(--primary-6)) !important;
box-shadow: 0 0 0 0 var(--color-primary-light-2) !important;
}
}
:deep(.arco-textarea-wrapper) {
@include textarea-style;
.arco-textarea {
padding: 0;
overflow: hidden;
position: absolute;
z-index: 1;
width: 100%;
background: transparent;
color: transparent;
caret-color: #211f24 !important;
resize: none;
@include textarea-padding;
overflow-y: auto;
}
.arco-textarea-word-limit {
z-index: 2;
}
&.arco-textarea-disabled {
.arco-textarea {
background: #f2f3f5;
}
}
}
// 处于中文输入法合成态时,显示真实文本并隐藏高亮层
:deep(.textarea-input.composing .arco-textarea) {
color: #211f24 !important;
-webkit-text-fill-color: #211f24 !important;
}
}
</style>

View File

@ -0,0 +1,476 @@
<script lang="jsx">
import axios from 'axios';
import { Swiper, SwiperSlide } from 'swiper/vue';
import { IconLoading } from '@arco-design/web-vue/es/icon';
import {
Image,
Form,
FormItem,
Input,
Textarea,
Button,
Tabs,
Upload,
TabPane,
Spin,
Message as AMessage,
} from '@arco-design/web-vue';
import TextOverTips from '@/components/text-over-tips';
import HighlightTextarea from './highlight-textarea';
import 'swiper/css';
import 'swiper/css/navigation';
import { Navigation } from 'swiper/modules';
import { FORM_RULES, enumTab, TAB_LIST, RESULT_LIST, LEVEL_MAP, escapeRegExp } from './constants';
import { getImagePreSignedUrl } from '@/api/all/common';
import { EnumManuscriptType } from '@/views/material-center/components/finished-products/manuscript/list/constants';
import icon1 from '@/assets/img/creative-generation-workshop/icon-magic.png';
import icon2 from '@/assets/img/creative-generation-workshop/icon-line.png';
import icon3 from '@/assets/img/creative-generation-workshop/icon-success.png';
import icon4 from '@/assets/img/error-img.png';
import icon5 from '@/assets/img/creative-generation-workshop/icon-lf2.png';
export default {
props: {
modelValue: {
type: Object,
default: {},
},
selectedImageInfo: {
type: Object,
default: {},
},
checkLoading: {
type: Boolean,
default: false,
},
getDataLoading: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue', 'filesChange', 'selectImage', 'againCheck', 'startCheck'],
setup(props, { emit, expose }) {
const activeTab = ref(enumTab.TEXT);
const aiReplaceLoading = ref(false);
const formRef = ref(null);
const uploadRef = ref(null);
const modules = [Navigation];
const isTextTab = computed(() => activeTab.value === enumTab.TEXT);
const aiReview = computed(() => props.modelValue.ai_review);
const isDisabled = computed(() => props.checkLoading || aiReplaceLoading.value);
const onAiReplace = () => {
if (aiReplaceLoading.value) return;
aiReplaceLoading.value = true;
setTimeout(() => {
const content = props.modelValue.content;
const rules = aiReview.value?.violation_items ?? [];
const sortedRules = [...rules].sort((a, b) => b.word.length - a.word.length);
const replacedContent = sortedRules.reduce((result, rule) => {
if (!rule.word) return result;
const escapedWord = escapeRegExp(rule.word);
const regex = new RegExp(escapedWord, 'g');
return result.replace(regex, rule.replace_word);
}, content);
props.modelValue.content = replacedContent;
aiReplaceLoading.value = false;
}, 500);
};
const onAgainCheck = () => {
if (!isTextTab.value && !props.modelValue.files?.length) {
AMessage.warning('请先上传需审核图片');
return;
}
emit('againCheck');
};
const onReplaceImage = () => {
uploadRef.value?.upload?.();
};
const handleTabClick = (key) => {
activeTab.value = key;
};
const validate = () => {
return new Promise((resolve, reject) => {
formRef.value?.validate((errors) => {
if (errors) {
reject();
} else {
resolve();
}
});
});
};
const reset = () => {
formRef.value?.resetFields?.();
formRef.value?.clearValidate?.();
aiReplaceLoading.value = false;
};
const getFileExtension = (filename) => {
const match = filename.match(/\.([^.]+)$/);
return match ? match[1].toLowerCase() : '';
};
const handleSelectImage = (item) => {
emit('selectImage', item);
};
const onDeleteImage = (e, item, index) => {
e.stopPropagation();
const _newFiles = cloneDeep(props.modelValue.files);
_newFiles.splice(index, 1);
if (item.id === props.selectedImageInfo.id) {
emit('selectImage', _newFiles.length ? _newFiles[0] : {});
}
emit('filesChange', _newFiles);
};
const renderUpload = (UploadBtn, action = 'upload') => {
return (
<Upload
ref={uploadRef}
action="/"
draggable
class="w-fit"
custom-request={(option) => uploadImage(option, action)}
accept=".jpg,.jpeg,.png,.gif,.webp"
show-file-list={false}
multiple
>
{{
'upload-button': () => <UploadBtn />,
}}
</Upload>
);
};
const uploadImage = async (option, action = 'upload') => {
const {
fileItem: { file },
} = option;
const { name, size, type } = file;
const response = await getImagePreSignedUrl({ suffix: getFileExtension(name) });
const { file_name, upload_url, file_url } = response?.data;
const blob = new Blob([file], { type });
await axios.put(upload_url, blob, {
headers: { 'Content-Type': type },
});
const _file = {
url: file_url,
name: file_name,
size,
};
const _newFiles =
action === 'replaceImage'
? props.modelValue.files.map((item) => {
if (item.url === props.selectedImageInfo.url) {
return _file;
}
return item;
})
: [...props.modelValue.files, _file];
emit('filesChange', _newFiles);
emit('selectImage', _file);
};
const renderFooterRow = () => {
return (
<>
<Button class="mr-12px" size="medium" onClick={onAgainCheck} disabled={isDisabled.value}>
再次审核
</Button>
{isTextTab.value ? (
<Button size="medium" type="outline" class="w-123px check-btn" onClick={onAiReplace} disabled={isDisabled.value}>
{aiReplaceLoading.value ? (
<>
<IconLoading size={14} />
<span class="ml-8px check-text">AI生成中</span>
</>
) : (
<>
<img src={icon1} width={14} height={14} />
<span class="ml-8px check-text">替换违禁词</span>
</>
)}
</Button>
) : (
<div class="w-88px">
{renderUpload(
<Button size="medium" type="outline">
图片替换
</Button>,
'replaceImage',
)}
</div>
)}
</>
);
};
const renderTextForm = () => {
return (
<Form ref={formRef} model={props.modelValue} rules={FORM_RULES} layout="vertical" auto-label-width>
<FormItem label="标题" field="title" required>
<Input
v-model={props.modelValue.title}
placeholder="请输入标题"
size="large"
maxLength={30}
show-word-limit
disabled={isDisabled.value}
/>
</FormItem>
<FormItem label="作品描述" field="content" class="flex-1 content-form-item">
<HighlightTextarea
v-model={props.modelValue.content}
disabled={isDisabled.value}
prohibitedWords={aiReview.value?.violation_items ?? []}
levelMap={LEVEL_MAP}
class="w-full"
/>
</FormItem>
</Form>
);
};
const renderImageForm = () => {
if (props.modelValue.files?.length > 0) {
return (
<div class="w-full h-full py-16px flex justify-center">
<div class="w-380px flex flex-col justify-center">
<Image
src={props.selectedImageInfo.url}
width={370}
height={370}
preview={false}
class="flex items-center justify-center mb-8px"
fit="contain"
v-slots={{
error: () => <img src={icon4} class="w-full h-full" />,
}}
/>
<div class="swiper-wrap h-78px">
<Swiper
spaceBetween={16}
modules={modules}
slidesPerView="auto"
navigation={{
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev',
}}
>
{props.modelValue.files.map((item, index) => (
<SwiperSlide
key={item.id}
onClick={() => handleSelectImage(item)}
class={`!h-48px !w-48px !relative bg-#F7F8FA cursor-pointer !flex items-center ${
item.id === props.selectedImageInfo.id ? 'active' : ''
}`}
>
<div class="group relative w-full h-full rounded-5px">
<Image
width={'100%'}
height={'100%'}
src={item.url}
class="!rounded-4px"
fit="contain"
preview={false}
v-slots={{
error: () => <img src={icon4} class="w-full h-full" />,
}}
/>
</div>
<icon-close-circle-fill
size={16}
class="close-icon absolute top--8px right--8px hidden cursor-pointer color-#737478 hover:!color-#211F24 z-50"
onClick={(e) => onDeleteImage(e, item, index)}
/>
</SwiperSlide>
))}
<div class="swiper-box swiper-button-prev">
<img src={icon5} class="w-8px h-17px" />
</div>
<div class="swiper-box swiper-button-next">
<img src={icon5} class="w-8px h-17px rotate-180" />
</div>
</Swiper>
</div>
</div>
</div>
);
} else {
return (
<div class="w-full h-full flex flex-col items-center justify-center">
<div class="flex justify-center mb-16px">
{renderUpload(
<div class="upload-box">
<icon-plus size="14" class="mb-16px color-#3C4043" />
<span class="cts !color-#211F24">上传图片</span>
</div>,
)}
</div>
<span class="cts">上传要审核的图片素材</span>
</div>
);
}
};
const renderCheckSuccessBox = () => {
if (!aiReview.value?.violation_items?.length) {
return (
<div>
<div class="flex items-center mb-16px">
{RESULT_LIST.map((item, index) => (
<div class="flex flex-col justify-center items-center flex-1 result-item" key={index}>
<span class="s1" style={{ color: item.color }}>{`${aiReview.value?.[item.value] ?? '-'}${
item.suffix || ''
}`}</span>
<span class="cts mt-4px !lh-20px !text-12px !color-#737478">{item.label}</span>
</div>
))}
</div>
<div class="mb-16px suggestion-box p-12px rounded-8px bg-#F7F8FA flex flex-col">
<div class="mb-24px relative w-fit">
<span class="ai-text relative z-2">AI 审核建议</span>
<img src={icon2} class="w-80px h-10.8px absolute bottom-1px left-1px" />
</div>
<div class="flex flex-col items-center h-138px justify-center">
<img src={icon3} width={72} height={72} class="mb-12px" />
<span class="cts !color-#25C883">
{isTextTab.value ? '恭喜,您的文案中没有检测出违禁词' : '恭喜,您的图片中没有检测出违禁内容'}
</span>
</div>
</div>
</div>
);
}
return (
<>
<div class="flex items-center mb-16px">
{RESULT_LIST.map((item, index) => (
<div class="flex flex-col justify-center items-center flex-1 result-item" key={index}>
<span class="s1" style={{ color: item.color }}>{`${aiReview.value?.[item.value] ?? '-'}${
item.suffix || ''
}`}</span>
<span class="cts mt-4px !lh-20px !text-12px !color-#737478">{item.label}</span>
</div>
))}
</div>
<div class="mb-16px suggestion-box p-12px rounded-8px bg-#F7F8FA flex flex-col">
<div class=" mb-8px relative w-fit">
<span class="ai-text relative z-2">AI 审核建议</span>
<img src={icon2} class="w-80px h-10.8px absolute bottom-1px left-1px" />
</div>
{aiReview.value?.suggestion?.map((item, index) => (
<p class="cts !color-#55585F !text-12px !lh-20px" key={index}>{`${index + 1}. ${item}`}</p>
))}
</div>
<div class="forbid-word-box flex-1 flex">
<div class="left mr-32px w-56px">
<p class="mb-12px cts !text-12px">违禁词</p>
{aiReview.value?.violation_items?.map((item, index) => (
<TextOverTips
context={item.word}
class="mb-12px cts"
style={{ color: LEVEL_MAP.get(item.risk_level)?.color }}
key={index}
/>
))}
</div>
<div class="right flex-1 overflow-hidden">
<p class="mb-12px cts !text-12px">解释</p>
{aiReview.value?.violation_items?.map((item, index) => (
<TextOverTips context={item.reason} class="mb-12px" key={index} />
))}
</div>
</div>
</>
);
};
const renderRightBox = () => {
if (props.checkLoading) {
return (
<div class="right-box">
<p class="cts bold !text-16px !lh-24px !color-#211F24 mb-16px">审核结果</p>
<Spin
loading={true}
tip={`${isTextTab.value ? '文本' : '图片'}检测中`}
size={72}
class="h-298px !flex flex-col justify-center items-center"
/>
</div>
);
} else {
return (
<div class="right-box">
<p class="cts bold !text-16px !lh-24px !color-#211F24 mb-16px">审核结果</p>
{props.getDataLoading ? (
<Spin loading={true} size={72} class="h-298px !flex justify-center items-center" />
) : (
renderCheckSuccessBox()
)}
</div>
);
}
};
expose({
validate,
reset,
});
return () => {
return (
<div class="h-full w-full px-24px pt-16px pb-24px content-wrap flex">
<div class="flex-2 left-box mr-24px flex flex-col">
<div class="flex-1 mb-12px rounded-8px border-1px pt-8px flex flex-col pb-16px bg-#F7F8FA border-#E6E6E8 border-solid">
<Tabs v-model={activeTab.value} onTabClick={handleTabClick} class="mb-16px">
{TAB_LIST.map((item) => (
<TabPane
key={item.value}
v-slots={{
title: () => (
<div class="flex items-center relative">
<span>{item.label}</span>
{
// activeTab.value === item.value && aiReview.value?.violation_items.length > 0 && (
// <icon-exclamation-circle-fill size={14} class="color-#F64B31 absolute right--10px top-0" />
// )
}
</div>
),
}}
/>
))}
</Tabs>
<div class="flex-1 px-16px">{isTextTab.value ? renderTextForm() : renderImageForm()}</div>
</div>
<div class="flex items-center justify-end">{renderFooterRow()}</div>
</div>
{renderRightBox()}
</div>
);
};
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,205 @@
.content-wrap {
.cts {
color: #939499;
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&.bold {
font-family: $font-family-medium;
}
}
.check-btn {
.check-text {
background: linear-gradient(84deg, #266cff 4.57%, #a15af0 84.93%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
&:hover {
opacity: 0.8;
}
}
.left-box {
:deep(.arco-tabs) {
.arco-tabs-nav {
.arco-tabs-tab {
height: 40px;
// padding: 0 8px;
margin: 0 16px;
}
&::before {
display: none;
}
}
.arco-tabs-content {
display: none;
}
}
:deep(.arco-form) {
height: 100%;
display: flex;
flex-direction: column;
.arco-form-item {
margin-bottom: 24px;
.arco-form-item-label-col {
.arco-form-item-label {
color: #939499;
}
}
}
.content-form-item {
margin-bottom: 0;
display: flex;
flex-direction: column;
.arco-form-item-wrapper-col {
flex: 1;
.arco-form-item-content-wrapper,
.arco-form-item-content,
.arco-textarea-wrapper {
height: 100%;
}
}
}
}
.upload-box {
display: flex;
width: 100px;
height: 100px;
cursor: pointer;
transition: all 0.3s ease;
flex-direction: column;
justify-content: center;
align-items: center;
border-radius: 8px;
border: 1px dashed var(--Border-1, #d7d7d9);
background: var(--BG-200, #f2f3f5);
&:hover {
background: var(--Primary-1, #e6e6e8);
}
}
.swiper-wrap {
:deep(.swiper) {
height: 100%;
.swiper-wrapper {
align-items: center;
.swiper-slide {
transition: all;
&.active {
width: 60px !important;
height: 60px !important;
.group {
border: 2px solid var(--Brand-6, #6d4cfe);
background: url(<path-to-image>) lightgray 50% / cover no-repeat;
}
}
&:hover {
.close-icon {
display: block;
}
}
}
}
}
.swiper-box {
position: absolute;
margin-top: 0 !important;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
transition: all;
display: flex;
justify-content: center;
align-items: center;
top: 50%;
transform: translateY(-50%);
&:hover {
background: rgba(0, 0, 0, 0.6);
}
&.swiper-button-prev {
left: 16px;
}
&.swiper-button-next {
right: 16px;
}
&::after {
display: none;
}
&.swiper-button-disabled {
display: none;
}
}
}
}
.right-box {
border: 1px solid #e6e6e8;
flex: 1;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
overflow-y: auto;
height: fit-content;
max-height: 100%;
.s1 {
font-family: $font-family-manrope-medium;
font-size: 24px;
font-style: normal;
font-weight: 700;
line-height: 32px; /* 133.333% */
}
.result-item {
&:first-child {
position: relative;
&::after {
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
right: 0;
width: 1px;
height: 32px;
background: var(--Border-1, #d7d7d9);
}
}
}
.suggestion-box {
.ai-text {
background: linear-gradient(85deg, #7d419d 4.56%, #31353d 94.75%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-family: $font-family-medium;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 24px;
}
}
:deep(.overflow-text) {
color: #211f24;
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
.forbid-word-box {
:deep(.overflow-text) {
&.level0 {
color: #6d4cfe;
}
&.level2 {
color: #f64b31;
}
&.level1 {
color: #ffae00;
}
}
}
}
}

View File

@ -0,0 +1,116 @@
<script lang="jsx">
import { Image } from '@arco-design/web-vue';
import { Swiper, SwiperSlide } from 'swiper/vue';
import TextOverTips from '@/components/text-over-tips';
import 'swiper/css';
import 'swiper/css/navigation';
import { Navigation } from 'swiper/modules';
import { PLATFORMS } from '@/views/material-center/components/finished-products/manuscript/check-list/constants';
import icon1 from '@/assets/img/error-img.png';
import icon2 from '@/assets/img/creative-generation-workshop/icon-lf.png';
export default {
props: {
dataSource: {
type: Array,
default: () => [],
},
selectCardInfo: {
type: Object,
default: {},
},
},
emits: ['cardClick', 'platformChange'],
setup(props, { emit, expose }) {
const modules = [Navigation];
const handleCardClick = (item) => {
// emit('update:modelValue', item);
emit('cardClick', item);
};
return () => {
return (
<header class="header-wrap">
{props.dataSource.length > 1 && (
<div class="swiper-wrap pt-16px h-80px px-24px">
<Swiper
spaceBetween={16}
modules={modules}
slidesPerView="auto"
navigation={{
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev',
}}
>
{props.dataSource.map((item) => (
<SwiperSlide
key={item.id}
onClick={() => handleCardClick(item)}
class={`swiper-item !h-64px !w-280px bg-#F7F8FA border-1px cursor-pointer border-solid border-transparent rounded-8px p-8px overflow-hidden !flex items-center ${
item.id === props.selectCardInfo.id ? 'active' : ''
}`}
>
<Image
width={48}
height={48}
preview={false}
src={item.cover}
class="!rounded-4px mr-8px"
fit="cover"
v-slots={{
error: () => <img src={icon1} class="w-full h-full" />,
}}
/>
<div class="flex-1 overflow-hidden flex flex-col items-start">
<TextOverTips context={item.title} class={`cts !color-#211F24 title mb-4px`} />
<p class="cts">{`合规程度:${item.ai_review?.compliance_level ? `${item.ai_review?.compliance_level}%` : '-'}`}</p>
</div>
</SwiperSlide>
))}
<div class="swiper-box swiper-button-prev">
<div class="swiper-button">
<img src={icon2} class="w-16px h-16px" />
</div>
</div>
<div class="swiper-box swiper-button-next">
<div class="swiper-button">
<img src={icon2} class="w-16px h-16px rotate-180" />
</div>
</div>
</Swiper>
</div>
)}
<div class="platform-row py-16px flex items-center px-24px">
<span class="mr-16px cts !color-#211F24">审核平台选择</span>
<div class="flex items-center">
{PLATFORMS.map((item) => (
<div
key={item.value}
onClick={() => {
emit('platformChange', item.value);
}}
class={`w-100px flex items-center mr-16px py-8px px-12px flex border-1px border-solid border-transparent transition-all
items-center rounded-8px cursor-pointer bg-#F2F3F5 hover:bg-#E6E6E8 ${
props.selectCardInfo.platform === item.value ? '!bg-#F0EDFF !border-#6D4CFE' : ''
}`}
>
<img src={item.icon} alt="" width={20} height={20} class="mr-4px" />
<span class={`cts !color-#211F24 ${props.selectCardInfo.platform === item.value ? 'bold' : ''}`}>
{item.label}
</span>
</div>
))}
</div>
</div>
</header>
);
};
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,74 @@
.header-wrap {
.cts {
color: #939499;
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&.bold {
font-family: $font-family-medium;
}
}
.swiper-wrap {
.swiper-item {
transition: all;
&:hover {
background-color: #e6e6e8;
}
&.active {
background-color: #f0edff;
border-color: #6d4cfe;
:deep(.overflow-text) {
font-family: $font-family-medium !important;
}
}
}
.swiper-box {
width: 100px;
height: 64px;
position: absolute;
&.swiper-button-prev {
background: linear-gradient(270deg, rgba(255, 255, 255, 0) 0%, #fff 43.06%);
margin-top: 0 !important;
top: 0;
left: 0;
justify-content: flex-start;
padding-left: 8px;
}
&.swiper-button-next {
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, #fff 43.06%);
margin-top: 0 !important;
top: 0;
right: 0;
justify-content: flex-end;
padding-right: 8px;
}
&::after {
display: none;
}
.swiper-button {
width: 40px;
height: 40px;
background-color: #fff;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
border: 1px solid var(--Border-1, #d7d7d9);
&:hover {
background-color: #f7f8fa;
}
&.click {
background-color: #f2f3f5;
}
}
&.swiper-button-disabled {
display: none;
}
}
}
.platform-row {
border-bottom: 1px solid var(--Border-2, #e6e6e8);
}
}

View File

@ -0,0 +1,266 @@
<script lang="jsx">
import { Button, Message as AMessage, Spin } from '@arco-design/web-vue';
import CancelCheckModal from './cancel-check-modal.vue';
import CheckSuccessModal from './check-success-modal.vue';
import HeaderCard from './components/header-card';
import ContentCard from './components/content-card';
import CheckListDrawer from './components/check-list-drawer';
import { slsWithCatch, rlsWithCatch, glsWithCatch } from '@/utils/stroage.ts';
import useGetAiReviewResult from '@/hooks/useGetAiReviewResult.ts';
import { useSidebarStore } from '@/stores/modules/side-bar';
import {
patchWorkAuditsAudit,
patchWorkAuditsBatchAudit,
putWorkAuditsUpdate,
putWorkAuditsAuditPass,
getWorkAuditsDetail,
getWorkAuditsBatchDetail,
postWorkAuditsAiReview,
getWorkAuditsAiReviewResult,
} from '@/api/all/generationWorkshop.ts';
export default {
setup(props, { emit, expose }) {
const router = useRouter();
const route = useRoute();
const sidebarStore = useSidebarStore();
const workIds = ref([]);
const isSaved = ref(false);
const dataSource = ref([]);
const remoteDataSource = ref([]);
const cancelCheckModalRef = ref(null);
const checkSuccessModalRef = ref(null);
const submitLoading = ref(false);
const contentCardRef = ref(null);
const checkListDrawerRef = ref(null);
const getDataLoading = ref(false);
const selectCardInfo = ref({});
const selectedImageInfo = ref(null);
const collapsed = computed(() => {
return sidebarStore.menuCollapse;
});
const { handleStartCheck, handleAgainCheck, ticket, checkLoading, resetAiReviewInfo } = useGetAiReviewResult({
cardInfo: selectCardInfo,
startAiReviewFn: postWorkAuditsAiReview,
getAiReviewResultFn: getWorkAuditsAiReviewResult,
updateAiReview(ai_review) {
selectCardInfo.value.ai_review = ai_review;
},
});
const onBack = () => {
router.push({ name: 'MaterialCenterFinishedProducts' });
};
const onChangeCard = (item) => {
contentCardRef.value.reset();
isSaved.value = false;
submitLoading.value = false;
getDataLoading.value = false;
checkLoading.value = false;
resetAiReviewInfo();
const { files = [], ai_review } = item;
selectCardInfo.value = cloneDeep(item);
selectedImageInfo.value = cloneDeep(files?.[0] ?? {});
if (isEmpty(ai_review)) {
handleStartCheck();
}
};
const onCardClick = async (item) => {
const isModified = await isSelectCardModified();
if (isModified) {
cancelCheckModalRef.value?.open('toggle', item);
} else {
onChangeCard(item);
}
};
const onSelectImage = (item) => {
selectedImageInfo.value = cloneDeep(item);
};
const getWorkAudits = async () => {
try {
getDataLoading.value = true;
const { code, data } = await getWorkAuditsBatchDetail({ ids: workIds.value });
if (code === 200) {
const _data = (data ?? []).map((item) => ({
...item,
platform: item.platform === 0 ? 1 : item.platform,
}));
dataSource.value = _data;
remoteDataSource.value = cloneDeep(_data);
const _firstCard = _data?.[0] ?? {};
const { id, ai_review } = _firstCard;
selectCardInfo.value = cloneDeep(_firstCard);
selectedImageInfo.value = cloneDeep(_firstCard.files?.[0] ?? {});
if (isEmpty(ai_review)) {
handleStartCheck();
}
}
} finally {
getDataLoading.value = false;
}
};
const isSelectCardModified = () => {
return new Promise((resolve) => {
const _item = remoteDataSource.value.find((item) => item.id === selectCardInfo.value.id);
resolve(!isEqual(selectCardInfo.value, _item) && !isSaved.value);
});
};
const onPlatformChange = (platform) => {
selectCardInfo.value.platform = platform;
};
const onExit = async () => {
const isModified = await isSelectCardModified();
if (isModified) {
cancelCheckModalRef.value?.open();
} else {
onBack();
}
};
const onSave = async () => {
if (!selectCardInfo.value.title) {
AMessage.warning('标题不能为空');
}
contentCardRef.value?.validate().then(async () => {
const { code, data } = await putWorkAuditsUpdate(selectCardInfo.value);
if (code === 200) {
isSaved.value = true;
AMessage.success('当前内容稿件已保存');
}
});
};
const onCheckSuccess = () => {
checkSuccessModalRef.value?.open(workIds.value);
if (workIds.value.length > 1) {
const _id = selectCardInfo.value.id;
workIds.value = workIds.value.filter((v) => v != _id);
dataSource.value = dataSource.value.filter((v) => v.id != _id);
slsWithCatch('manuscriptCheckIds', workIds.value.join(','));
onChangeCard(dataSource.value.length ? dataSource.value[0] : {});
}
};
const onSubmit = async () => {
contentCardRef.value?.validate().then(async () => {
try {
submitLoading.value = true;
const { code, data } = await putWorkAuditsAuditPass(selectCardInfo.value);
if (code === 200) {
onCheckSuccess();
}
} finally {
submitLoading.value = false;
}
});
};
const onFilesChange = (files) => {
selectCardInfo.value.files = cloneDeep(files);
};
const onAgainCheck = () => {
handleAgainCheck();
};
const renderFooterRow = () => {
return (
<>
<Button size="medium" type="outline" class="mr-12px" onClick={onExit}>
退出
</Button>
<Button size="medium" type="outline" class="mr-12px" onClick={onSave}>
保存
</Button>
<Button type="primary" size="medium" onClick={onSubmit} loading={submitLoading.value}>
{submitLoading.value ? '通过审核中...' : '通过审核'}
</Button>
</>
);
};
onMounted(() => {
workIds.value = glsWithCatch('manuscriptCheckIds')?.split(',') ?? [];
getWorkAudits();
});
onUnmounted(() => {
rlsWithCatch('manuscriptCheckIds');
});
return () => (
<>
<div class="manuscript-check-wrap flex flex-col">
<div class="flex items-center mb-10px">
<span class="cts color-#4E5969 cursor-pointer" onClick={onExit}>
成品库
</span>
<icon-oblique-line size="12" class="color-#C9CDD4 mx-4px" />
<span class="cts bold !color-#1D2129">{`${workIds.value.length > 0 ? '批量' : ''}审核内容稿件`}</span>
</div>
{dataSource.value.length > 1 && (
<div
class="check-list-icon"
onClick={() => checkListDrawerRef.value.open(dataSource.value, selectCardInfo.value)}
>
<icon-menu-fold size={16} class="color-#55585F icon mr-4px" />
<span class="cts !color-#211F24">审核列表</span>
</div>
)}
<div class="flex-1 flex flex-col overflow-hidden bg-#fff rounded-8px ">
<HeaderCard
dataSource={dataSource.value}
selectCardInfo={selectCardInfo.value}
onCardClick={onCardClick}
onPlatformChange={onPlatformChange}
/>
<section class="flex-1 overflow-hidden">
<ContentCard
ref={contentCardRef}
v-model={selectCardInfo.value}
selectCardInfo={selectCardInfo.value}
onFilesChange={onFilesChange}
selectedImageInfo={selectedImageInfo.value}
onSelectImage={onSelectImage}
checkLoading={checkLoading.value}
getDataLoading={getDataLoading.value}
onAgainCheck={onAgainCheck}
onStartCheck={handleStartCheck}
/>
</section>
</div>
</div>
<footer
class={`flex justify-end items-center px-16px py-16px w-full bg-#F6F5FC footer-row ${
collapsed.value ? 'collapsed' : ''
}`}
>
{renderFooterRow()}
</footer>
<CancelCheckModal ref={cancelCheckModalRef} onSelectCard={onChangeCard} />
<CheckSuccessModal ref={checkSuccessModalRef} />
<CheckListDrawer ref={checkListDrawerRef} onCardClick={onCardClick} />
</>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,48 @@
$footer-height: 68px;
.manuscript-check-wrap {
width: 100%;
height: calc(100% - ($footer-height - $layout-padding-bottom));
.cts {
color: #939499;
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&.bold {
font-family: $font-family-medium;
}
}
.check-list-icon {
// width: 92px;
cursor: pointer;
height: 36px;
display: flex;
padding: 8px 12px;
justify-content: center;
align-items: center;
border-radius: 30px 0 0 30px;
border: 1px solid var(--Border-1, #d7d7d9);
background: #fff;
position: absolute;
right: 0;
top: calc($navbar-height + 8px);
&:hover {
.icon,
.cts {
color: #6d4cfe !important;
}
}
}
}
.footer-row {
position: fixed;
bottom: 0;
left: $sidebar-width;
width: calc(100% - $sidebar-width);
height: $footer-height;
&.collapsed {
left: $sidebar-width-collapse;
width: calc(100% - $sidebar-width-collapse);
}
}

View File

@ -0,0 +1,99 @@
<template>
<a-form-item field="files">
<template #label>
<div class="flex items-center">
<span class="cts !color-#211F24 mr-4px">图片</span>
<span class="cts mr-8px !color-#939499">{{ `(${files.length ?? 0}/18)` }}</span>
<span class="cts !color-#939499">第一张为首图支持拖拽排序</span>
</div>
</template>
<div class="flex items-center">
<VueDraggable v-model="files" class="grid grid-cols-7 gap-y-8px" @end="handleChange" draggable=".group">
<div
v-for="(file, index) in files"
:key="file.url"
class="group relative cursor-move overflow-visible py-8px pr-8px"
>
<div class="group-container relative rounded-8px w-100px h-100px">
<img :src="file.url" class="object-cover w-full h-full border-1px border-#E6E6E8 rounded-8px" />
<icon-close-circle-fill
:size="16"
class="absolute top--8px right--8px cursor-pointer hidden color-#939499 hidden group-hover:block z-50"
@click="() => handleDeleteFile(index)"
/>
</div>
</div>
<a-upload
v-if="files.length < 18"
ref="uploadRef"
action="/"
draggable
:custom-request="(option) => emit('upload', option)"
accept=".jpg,.jpeg,.png,.gif,.webp"
:show-file-list="false"
multiple
class="!flex !items-center"
:limit="18 - files.length"
>
<template #upload-button>
<div class="upload-box">
<icon-plus size="14" class="mb-16px color-#3C4043" />
<span class="cts !color-#211F24">上传图片</span>
</div>
</template>
</a-upload>
</VueDraggable>
</div>
</a-form-item>
</template>
<script setup>
import { VueDraggable } from 'vue-draggable-plus';
const props = defineProps({
files: {
type: Array,
default: () => [],
},
});
const files = ref([]);
const uploadRef = ref(null);
const emit = defineEmits(['change', 'delete', 'upload']);
const handleChange = () => {
emit('change', files.value);
};
const handleDeleteFile = (index) => {
emit('delete', index);
};
watch(
() => props.files,
(newVal) => {
files.value = newVal;
},
{ immediate: true, deep: true },
);
</script>
<style scoped lang="scss">
.upload-box {
display: flex;
width: 100px;
height: 100px;
cursor: pointer;
transition: all 0.3s ease;
flex-direction: column;
justify-content: center;
align-items: center;
border-radius: 8px;
border: 1px dashed var(--Border-1, #d7d7d9);
background: var(--BG-200, #f2f3f5);
&:hover {
background: var(--Primary-1, #e6e6e8);
}
}
</style>

View File

@ -0,0 +1,373 @@
<script lang="jsx">
import axios from 'axios';
import { Form, FormItem, Input, Textarea, Upload, Message as AMessage, Button } from '@arco-design/web-vue';
// import CommonSelect from '@/components/common-select';
// import { VueDraggable } from 'vue-draggable-plus';
import TextOverTips from '@/components/text-over-tips';
import ImgBox from './img-box';
import { formatFileSize, getVideoInfo, formatDuration, formatUploadSpeed } from '@/utils/tools';
// import { getProjectList } from '@/api/all/propertyMarketing';
import { EnumManuscriptType } from '@/views/material-center/components/finished-products/manuscript/list/constants.ts';
import { getImagePreSignedUrl, getVideoPreSignedUrl } from '@/api/all/common';
// import icon1 from '@/assets/img/creative-generation-workshop/icon-close.png';
// 表单验证规则
const FORM_RULES = {
title: [{ required: true, message: '请输入标题' }],
};
export const ENUM_UPLOAD_STATUS = {
DEFAULT: 'default',
UPLOADING: 'uploading',
END: 'end',
};
export const INITIAL_VIDEO_INFO = {
name: '',
size: '',
percent: 0,
duration: 0,
time: '',
uploadSpeed: '0 KB/s',
startTime: 0,
lastTime: 0,
lastLoaded: 0,
estimatedTime: 0,
poster: '',
uploadStatus: ENUM_UPLOAD_STATUS.DEFAULT,
};
export default {
name: 'ManuscriptForm',
props: {
modelValue: {
type: Object,
default: () => {},
},
rules: {
type: Object,
default: () => FORM_RULES,
},
formData: {
type: Object,
default: () => ({}),
},
},
emits: ['reValidate', 'change', 'update:modelValue', 'updateVideoInfo'],
setup(props, { emit, expose }) {
const formRef = ref(null);
const formData = ref({});
const uploadRef = ref(null);
function getFileExtension(filename) {
const match = filename.match(/\.([^.]+)$/);
return match ? match[1].toLowerCase() : '';
}
const isVideo = computed(() => formData.value.type === EnumManuscriptType.Video);
const setVideoInfo = (file) => {
formData.value.videoInfo.percent = 0;
formData.value.videoInfo.name = file.name;
formData.value.videoInfo.size = formatFileSize(file.size);
formData.value.videoInfo.startTime = Date.now();
formData.value.videoInfo.lastTime = Date.now();
formData.value.videoInfo.lastLoaded = 0;
formData.value.videoInfo.uploadSpeed = '0 KB/s';
emit('updateVideoInfo', formData.value.videoInfo);
getVideoInfo(file)
.then(({ duration, firstFrame }) => {
formData.value.videoInfo.poster = firstFrame;
formData.value.videoInfo.duration = Math.floor(duration);
formData.value.videoInfo.time = formatDuration(duration);
emit('updateVideoInfo', formData.value.videoInfo);
})
.catch((error) => {
console.error('获取视频时长失败:', error);
});
};
const handleUploadProgress = (progressEvent) => {
const percentCompleted = Math.round(progressEvent.progress * 100);
formData.value.videoInfo.percent = percentCompleted;
const currentTime = Date.now();
const currentLoaded = progressEvent.loaded;
const totalSize = progressEvent.total;
if (formData.value.videoInfo.lastLoaded === 0) {
formData.value.videoInfo.lastLoaded = currentLoaded;
formData.value.videoInfo.lastTime = currentTime;
return;
}
const timeDiff = (currentTime - formData.value.videoInfo.lastTime) / 1000;
const bytesDiff = currentLoaded - formData.value.videoInfo.lastLoaded;
// 避免频繁更新至少间隔200ms计算一次速率
if (timeDiff >= 0.2) {
const bytesPerSecond = bytesDiff / timeDiff;
formData.value.videoInfo.uploadSpeed = formatUploadSpeed(bytesPerSecond);
formData.value.videoInfo.lastLoaded = currentLoaded;
formData.value.videoInfo.lastTime = currentTime;
// 计算预估剩余时间
if (totalSize && bytesPerSecond > 0) {
const remainingBytes = totalSize - currentLoaded;
const remainingSeconds = remainingBytes / bytesPerSecond;
formData.value.videoInfo.estimatedTime = formatDuration(remainingSeconds);
} else {
formData.value.videoInfo.estimatedTime = 0;
}
}
emit('updateVideoInfo', formData.value.videoInfo);
};
const uploadVideo = async (option) => {
try {
formData.value.videoInfo.uploadStatus = ENUM_UPLOAD_STATUS.UPLOADING;
emit('updateVideoInfo', formData.value.videoInfo);
const {
fileItem: { file },
} = option;
setVideoInfo(file);
const response = await getVideoPreSignedUrl({ suffix: getFileExtension(file.name) });
const { file_name, upload_url, file_url } = response?.data;
if (!upload_url) {
throw new Error('未能获取有效的预签名上传地址');
}
const blob = new Blob([file], { type: file.type });
await axios.put(upload_url, blob, {
headers: { 'Content-Type': file.type },
onUploadProgress: (progressEvent) => {
handleUploadProgress(progressEvent);
},
});
const { name, duration, size } = formData.value.videoInfo;
formData.value.files.push({ url: file_url, name, duration, size });
onChange();
} finally {
formData.value.videoInfo.uploadStatus = ENUM_UPLOAD_STATUS.END;
emit('updateVideoInfo', formData.value.videoInfo);
}
};
const onChange = () => {
emit('change', formData.value);
};
// 文件上传处理
const uploadImage = async (option) => {
const {
fileItem: { file },
} = option;
// 验证文件数量
if (formData.value.files?.length >= 18) {
AMessage.error('最多只能上传18张图片');
return;
}
const { name, size, type } = file;
const response = await getImagePreSignedUrl({ suffix: getFileExtension(name) });
const { file_name, upload_url, file_url } = response?.data;
const blob = new Blob([file], { type });
await axios.put(upload_url, blob, {
headers: { 'Content-Type': type },
});
formData.value.files.push({ url: file_url, name, size });
onChange();
};
const handleDeleteFile = (index) => {
formData.value.files.splice(index, 1);
onChange();
};
const validate = () => {
return new Promise((resolve, reject) => {
formRef.value?.validate((errors) => {
if (errors) {
reject(formData.value);
} else {
resolve();
}
});
});
};
const resetForm = () => {
formData.value = {};
formRef.value?.resetFields?.();
formRef.value?.clearValidate?.();
};
const renderVideoUpload = () => {
return (
<Upload
ref={uploadRef}
action="/"
draggable
custom-request={uploadVideo}
accept=".mp4,.mov,.avi,.flv,.wmv"
show-file-list={false}
>
{{
'upload-button': () => {
if (formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.DEFAULT) {
return (
<div class="upload-box">
<icon-plus size="14" class="mb-16px color-#3C4043" />
<span class="cts !color-#211F24">上传视频</span>
</div>
);
} else {
return <Button type="text">替换视频</Button>;
}
},
}}
</Upload>
);
};
const renderVideo = () => {
const isUploading = formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.UPLOADING;
const isEnd = formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.END;
return (
<FormItem
field="files"
v-slots={{
label: () => (
<div class="flex items-center">
<span class="cts !color-#211F24 mr-8px">视频</span>
<span class="cts !color-#939499">截取视频第一帧为首图</span>
</div>
),
}}
>
{formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.DEFAULT ? (
renderVideoUpload()
) : (
<div class="flex items-center justify-between p-12px rounded-8px bg-#F7F8FA w-784px">
<div class="flex items-center mr-12px">
{isUploading ? (
<div class="w-80px h-80px flex items-center justify-center bg-#fff rounded-8px mr-16px">
<icon-loading size="24" class="color-#B1B2B5" />
</div>
) : (
<img src={formData.value.videoInfo.poster} class="w-80 h-80 object-cover mr-16px rounded-8px" />
)}
<div class="flex flex-col">
<TextOverTips
context={formData.value.videoInfo.name}
class="mb-4px cts !text-14px !lh-22px color-#211F24"
/>
{isEnd ? (
<p>
<span class="cts color-#939499 mr-24px">视频大小{formData.value.videoInfo.size}</span>
<span class="cts color-#939499">视频时长{formData.value.videoInfo.time}</span>
</p>
) : (
<div class="flex items-center">
<div class="flex items-center mr-24px w-100px">
<icon-loading size="16" class="color-#6D4CFE mr-8px" />
<span class="cts !color-#6D4CFE mr-4px">上传中</span>
<span class="cts !color-#6D4CFE ">{formData.value.videoInfo.percent}%</span>
</div>
<div class="flex items-center w-160px mr-24px">
<span class="cts color-#939499">上传速度{formData.value.videoInfo.uploadSpeed}</span>
</div>
<div class="flex items-center">
<span class="cts color-#939499">预估剩余时间{formData.value.videoInfo.estimatedTime}</span>
</div>
</div>
)}
</div>
</div>
<div>{renderVideoUpload()}</div>
</div>
)}
</FormItem>
);
};
const handleImagesChange = (files) => {
formData.value.files = cloneDeep(files);
onChange();
};
// 暴露方法
expose({
validate,
resetForm,
});
watch(
() => props.formData,
(val) => {
formData.value = val;
},
{ deep: true, immediate: true },
);
return () => (
<Form ref={formRef} model={formData.value} rules={props.rules} layout="vertical" auto-label-width>
<FormItem label="标题" field="title" required>
<Input
v-model={formData.value.title}
onInput={() => {
onChange();
emit('reValidate');
}}
placeholder="请输入标题"
size="large"
class="!w-500px"
maxLength={30}
show-word-limit
/>
</FormItem>
<FormItem label="作品描述" field="content">
<Textarea
v-model={formData.value.content}
onInput={onChange}
placeholder="请输入作品描述"
size="large"
class="textarea-box !w-784px"
show-word-limit
max-length={1000}
/>
</FormItem>
{isVideo.value ? (
renderVideo()
) : (
<ImgBox
files={formData.value.files}
onChange={handleImagesChange}
onDelete={handleDeleteFile}
onUpload={uploadImage}
/>
)}
{/* <FormItem label="所属项目" field="project_ids">
<CommonSelect
v-model={formData.value.project_ids}
onChange={() => emit('change')}
options={projects.value}
placeholder="请选择所属项目"
size="large"
class="!w-280px"
/>
</FormItem>*/}
</Form>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,38 @@
.cts {
font-family: $font-family-regular;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.upload-box {
display: flex;
width: 100px;
height: 100px;
cursor: pointer;
transition: all 0.3s ease;
flex-direction: column;
justify-content: center;
align-items: center;
border-radius: 8px;
border: 1px dashed var(--Border-1, #d7d7d9);
background: var(--BG-200, #f2f3f5);
&:hover {
background: var(--Primary-1, #e6e6e8);
}
}
.group {
border-radius: 8px;
&:hover {
.group-container {
background: linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
url(<path-to-image>) lightgray 0px -40.771px / 100% 149.766% no-repeat;
}
}
}
.textarea-box {
:deep(.arco-textarea) {
height: 140px;
max-height: 298px;
}
}

View File

@ -0,0 +1,40 @@
export const INITIAL_FORM = {
audit_status: '',
sort_column: undefined,
sort_order: undefined,
};
export const TABLE_COLUMNS = [
{
title: '序号',
dataIndex: 'uid',
width: 120,
fixed: 'left',
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '内容稿件标题',
dataIndex: 'title',
width: 220,
},
{
title: '审核状态',
dataIndex: 'audit_status',
width: 120,
},
{
title: '稿件类型',
dataIndex: 'type',
width: 120,
},
{
title: '最后修改时间',
dataIndex: 'last_modified_at',
width: 160,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
];

View File

@ -0,0 +1,296 @@
<script lang="jsx">
import {
Input,
Table,
Modal,
TableColumn,
Checkbox,
Pagination,
Button,
Tooltip,
Notification,
} from '@arco-design/web-vue';
import CommonSelect from '@/components/common-select';
import TextOverTips from '@/components/text-over-tips';
import ShareModal from '@/views/material-center/components/finished-products/manuscript/components/share-manuscript-modal/share-modal';
import { INITIAL_FORM, TABLE_COLUMNS } from './constants';
import { formatTableField, exactFormatTime } from '@/utils/tools';
import { CHECK_STATUS, EnumManuscriptType } from '@/views/material-center/components/finished-products/manuscript/list/constants';
import { useTableSelectionWithPagination } from '@/hooks/useTableSelectionWithPagination';
import { getWorksPage, getWriterLinksGenerate } from '@/api/all/generationWorkshop.ts';
import icon2 from '@/assets/img/creative-generation-workshop/icon-photo.png';
import icon3 from '@/assets/img/creative-generation-workshop/icon-video.png';
export default {
setup(props, { emit, expose }) {
const {
selectedRowKeys,
selectedRows,
dataSource,
pageInfo,
onPageChange,
onPageSizeChange,
rowSelection,
handleSelect,
handleSelectAll,
DEFAULT_PAGE_INFO,
} = useTableSelectionWithPagination({
onPageChange: () => {
getData();
},
onPageSizeChange: () => {
getData();
},
});
const visible = ref(false);
const query = ref(cloneDeep(INITIAL_FORM));
const tableRef = ref(null);
const shareModalRef = ref(null);
const reset = () => {
query.value = cloneDeep(INITIAL_FORM);
pageInfo.value = cloneDeep(DEFAULT_PAGE_INFO);
selectedRowKeys.value = [];
selectedRows.value = [];
dataSource.value = [];
};
const getData = async () => {
const { page, page_size } = pageInfo.value;
const { code, data } = await getWorksPage({
...query.value,
page,
page_size,
});
if (code === 200) {
dataSource.value = data?.data ?? [];
pageInfo.value.total = data.total;
}
};
const handleSearch = (value) => {
query.value.audit_status = value;
reload();
};
const reload = () => {
pageInfo.value.page = 1;
getData();
};
const open = () => {
getData();
visible.value = true;
};
const onClose = () => {
visible.value = false;
reset();
};
const renderColumn = () => {
return TABLE_COLUMNS.map((column) => (
<TableColumn
key={column.dataIndex}
data-index={column.dataIndex}
fixed={column.fixed}
width={column.width}
min-width={column.minWidth}
sortable={column.sortable}
align={column.align}
ellipsis
tooltip
v-slots={{
title: () => (
<div class="flex items-center">
<span class="cts mr-4px">{column.title}</span>
{column.tooltip && (
<Tooltip content={column.tooltip} position="top">
<IconQuestionCircle class="tooltip-icon color-#737478" size={16} />
</Tooltip>
)}
</div>
),
cell: ({ record }) => renderCell(record),
}}
/>
));
};
const onShare = () => {
shareModalRef.value?.open(selectedRowKeys.value);
};
const handleSorterChange = (column, order) => {
query.value.sort_column = column;
query.value.sort_order = order === 'ascend' ? 'asc' : 'desc';
reload();
};
const getStatusInfo = (audit_status) => {
return CHECK_STATUS.find((v) => v.id === audit_status) ?? {};
};
expose({
open,
});
return () => (
<>
<Modal
v-model:visible={visible.value}
title="分享内容稿件"
width="920px"
onClose={onClose}
unmount-on-close
modal-class="share-manuscript-modal"
v-slots={{
footer: () => (
<div class="flex justify-between w-full items-center">
<p class="cts color-#737478">
已选择 <span class="cts color-#211F24 bold">{selectedRows.value.length}</span>
</p>
<div class="flex items-center">
<Button size="medium" onClick={onClose}>
取消
</Button>
<Button
type="primary"
class="ml-16px"
size="medium"
onClick={onShare}
disabled={!selectedRows.value.length}
>
分享
</Button>
</div>
</div>
),
}}
>
<div class="flex flex-col h-100%">
<div class="filter-row-item mb-16px">
<span class="cts text-#211f24 !text-14px mr-12px">审核状态</span>
<CommonSelect
placeholder="全部"
options={CHECK_STATUS}
v-model={query.value.audit_status}
class="!w-200px"
multiple={false}
onChange={handleSearch}
/>
</div>
<Table
ref={tableRef}
data={dataSource.value}
row-key="id"
column-resizable
row-selection={rowSelection.value}
selected-keys={selectedRowKeys.value}
pagination={false}
scroll={{ x: '100%', y: '100%' }}
class="overflow-hidden"
bordered
onSorterChange={handleSorterChange}
onSelect={handleSelect}
onSelectAll={handleSelectAll}
v-slots={{
empty: () => <NoData />,
columns: () => (
<>
{TABLE_COLUMNS.map((column) => (
<TableColumn
key={column.dataIndex}
data-index={column.dataIndex}
fixed={column.fixed}
width={column.width}
min-width={column.minWidth}
sortable={column.sortable}
align={column.align}
ellipsis
tooltip
v-slots={{
title: () => (
<div class="flex items-center">
<span class="cts mr-4px bold color-#211F24">{column.title}</span>
{column.tooltip && (
<Tooltip content={column.tooltip} position="top">
<IconQuestionCircle class="tooltip-icon color-#737478" size={16} />
</Tooltip>
)}
</div>
),
cell: ({ record }) => {
if (column.dataIndex === 'audit_status') {
return (
<div
class="flex items-center w-fit h-24px px-8px rounded-2px"
style={{ backgroundColor: getStatusInfo(record.audit_status).backgroundColor }}
>
<span class="cts s1 bold" style={{ color: getStatusInfo(record.audit_status).color }}>
{getStatusInfo(record.audit_status).name}
</span>
</div>
);
} else if (column.dataIndex === 'title') {
return <TextOverTips context={record.title} />;
} else if (column.dataIndex === 'type') {
return (
<div class="flex items-center">
<img
src={record.type === EnumManuscriptType.Image ? icon2 : icon3}
width="16"
height="16"
class="mr-4px"
/>
<span
class={`cts !text-14px !lh-22px ${
record.type === EnumManuscriptType.Image ? '!color-#25C883' : '!color-#6D4CFE'
}`}
>
{record.type === EnumManuscriptType.Image ? '图文' : '视频'}
</span>
</div>
);
} else if (column.dataIndex === 'last_modified_at') {
return (
<span class="cts num">
{exactFormatTime(
record.last_modified_at,
'YYYY-MM-DD HH:mm:ss',
'YYYY-MM-DD HH:mm:ss',
)}
</span>
);
} else {
return formatTableField(column, record, true);
}
},
}}
/>
))}
</>
),
}}
/>
{pageInfo.value.total > 0 && (
<div class="flex justify-end mt-16px">
<Pagination
total={pageInfo.value.total}
size="mini"
show-total
show-jumper
show-page-size
current={pageInfo.value.page}
page-size={pageInfo.value.page_size}
onChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
</div>
)}
</div>
</Modal>
<ShareModal ref={shareModalRef} onClose={onClose} />
</>
);
},
};
</script>
<style lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,152 @@
<script lang="jsx">
import { Modal, Form, FormItem, Input, Button, Message as AMessage } from '@arco-design/web-vue';
import CommonSelect from '@/components/common-select';
import { useClipboard } from '@vueuse/core';
import { postShareLinksGenerate } from '@/api/all/generationWorkshop';
import { generateFullUrl } from '@/utils/tools';
const INITIAL_FORM = {
work_ids: [],
days: 1,
receiver: '',
};
const OPTIONS = [
{
id: 1,
name: '1天',
},
{
id: 2,
name: '3天',
},
{
id: 3,
name: '7天',
},
{
id: 4,
name: '15天',
},
{
id: 5,
name: '30天',
},
{
id: 6,
name: '60天',
},
];
export default {
emits: ['close'],
setup(props, { emit, expose }) {
const visible = ref(false);
const formRef = ref(null);
const formData = ref(cloneDeep(INITIAL_FORM));
const loading = ref(false);
const router = useRouter();
const { copy } = useClipboard({ source: formData.value.link });
const rules = {
receiver: [{ required: true, message: '请输入分享对象' }],
};
const reset = () => {
loading.value = false;
formData.value = cloneDeep(INITIAL_FORM);
formRef.value?.resetFields?.();
formRef.value?.clearValidate?.();
};
const onClose = () => {
visible.value = false;
reset();
};
const onGenerateLink = () => {
formRef.value.validate().then(async (errors) => {
if (!errors) {
try {
loading.value = true;
const { code, data } = await postShareLinksGenerate(formData.value);
if (code === 200) {
onClose();
const url = router.resolve({
path: `/explore/list/${data.code}`,
}).href;
copy(generateFullUrl(url));
AMessage.success('链接已复制!');
emit('close');
}
} finally {
loading.value = false;
}
}
});
};
const open = (workIds) => {
formData.value.work_ids = workIds;
visible.value = true;
};
expose({
open,
});
return () => (
<Modal
v-model:visible={visible.value}
title="分享内容稿件"
width="480px"
onClose={onClose}
unmount-on-close
auto-label-width
v-slots={{
footer: () => (
<>
<Button size="medium" onClick={onClose}>
取消
</Button>
<Button type="primary" class="ml-16px" size="medium" onClick={onGenerateLink} disabled={loading.value}>
{loading.value ? '生成中...' : '生成链接'}
</Button>
</>
),
}}
>
<Form ref={formRef} rules={rules} model={formData.value} auto-label-width>
<FormItem label="有效期" prop="days" row-class="!items-center">
<CommonSelect
v-model={formData.value.days}
options={OPTIONS}
multiple={false}
placeholder="请选择有效期"
size="large"
class="!w-240px"
allClear={false}
/>
</FormItem>
<FormItem
label="分享对象"
prop="receiver"
row-class="!items-center"
v-slots={{
label: () => (
<div class="flex items-center">
<span>分享对象</span>
<a-tooltip content="可填写客户名称、昵称等,非必填" position="top">
<icon-question-circle class="tooltip-icon color-#737478 ml-4px" size="14" />
</a-tooltip>
</div>
),
}}
>
<Input v-model={formData.value.receiver} class="!w-240px" size="large" placeholder="请输入分享对象" />
</FormItem>
</Form>
</Modal>
);
},
};
</script>

View File

@ -0,0 +1,68 @@
.share-manuscript-modal {
.cts {
font-family: $font-family-regular;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px;
&.bold {
font-family: $font-family-medium;
}
&.num {
font-family: $font-family-manrope-regular;
}
}
.filter-row-item {
.label {
color: var(--Text-1, #211f24);
font-size: 14px;
}
}
.arco-modal-body {
height: 464px;
display: flex;
flex-direction: column;
overflow: hidden;
.arco-scrollbar-track {
display: none !important;
}
.arco-table {
.arco-table-container {
.arco-table-element {
thead {
.arco-table-tr {
.arco-table-th {
.arco-table-cell {
padding: 10px 16px !important;
}
}
}
}
tbody {
.arco-table-tr {
.arco-table-td {
.arco-table-cell {
padding: 6px 16px;
.arco-table-cell-content,
.arco-table-td-content {
font-size: 12px;
line-height: 20px;
}
}
}
}
}
}
}
}
.arco-pagination {
.arco-pagination-total,
.arco-pagination-jumper-prepend {
font-size: 14px;
}
.arco-pagination-jumper-prepend {
font-family: $font-family-regular;
}
}
}
}

View File

@ -0,0 +1,419 @@
<script lang="jsx">
import {
Modal,
Form,
FormItem,
Input,
RadioGroup,
Radio,
Upload,
Button,
Message as AMessage,
Textarea,
} from '@arco-design/web-vue';
import { useClipboard } from '@vueuse/core';
import { getWriterLinksGenerate, getTemplateUrl, postWorksByLink, postWorksByFile } from '@/api/all/generationWorkshop';
import { generateFullUrl } from '@/utils/tools';
import { slsWithCatch } from '@/utils/stroage.ts';
import TextOverTips from '@/components/text-over-tips';
import icon1 from '@/assets/img/media-account/icon-feedback-fail.png';
// 状态枚举
const TASK_STATUS = {
DEFAULT: 1,
LOADING: 2,
FAILED: 3,
SUCCESS: 4,
};
const UPLOAD_TYPE = {
LINK: 'link',
LOCAL: 'local',
HANDWRITE: 'handwrite',
};
// 初始表单数据
const INITIAL_FORM = {
link: '',
writerLink: '',
};
export default {
setup(props, { emit, expose }) {
const update = inject('update');
const router = useRouter();
// 响应式状态
const visible = ref(false);
const formRef = ref(null);
const uploadType = ref(UPLOAD_TYPE.LINK);
const taskStatus = ref(TASK_STATUS.DEFAULT);
const form = ref(cloneDeep(INITIAL_FORM));
const uploadingFiles = ref([]); // 上传中
const uploadSuccessFiles = ref([]); // 上传成功
const works = ref([]);
// 生成自增 id基于当前列表中最大的 id
const getNextWorkId = () => {
const currentMax = works.value.reduce((max, item) => {
const numericId = Number(item?.id);
return Number.isFinite(numericId) ? Math.max(max, numericId) : max;
}, 0);
return currentMax + 1;
};
// 剪贴板功能
const { copy } = useClipboard({ source: form.value.writerLink });
const isLink = computed(() => uploadType.value === UPLOAD_TYPE.LINK);
const isLocal = computed(() => uploadType.value === UPLOAD_TYPE.LOCAL);
const isHandwrite = computed(() => uploadType.value === UPLOAD_TYPE.HANDWRITE);
const isDefault = computed(() => taskStatus.value === TASK_STATUS.DEFAULT);
// 模态框标题
const getTitle = () => {
const titleMap = {
[TASK_STATUS.DEFAULT]: '上传内容稿件',
[TASK_STATUS.LOADING]: isLink.value ? '链接上传' : isLocal.value ? '本地批量上传' : '上传内容稿件',
[TASK_STATUS.FAILED]: isLink.value ? '链接上传' : isLocal.value ? '本地批量上传' : '上传内容稿件',
[TASK_STATUS.SUCCESS]: '上传内容稿件列表',
};
return titleMap[taskStatus.value];
};
// 重置状态
const reset = () => {
formRef.value?.resetFields?.();
uploadType.value = UPLOAD_TYPE.LINK;
taskStatus.value = TASK_STATUS.DEFAULT;
form.value = cloneDeep(INITIAL_FORM);
works.value = [];
uploadingFiles.value = [];
uploadSuccessFiles.value = [];
};
const getWriterLink = async () => {
const { code, data } = await getWriterLinksGenerate();
if (code === 200) {
const url = router.resolve({
path: `/writer/manuscript/list/${data.code}`,
}).href;
form.value.writerLink = generateFullUrl(url);
}
};
const open = () => {
getWriterLink();
visible.value = true;
};
const onClose = () => {
reset();
visible.value = false;
};
// 防抖提交
const debouncedSubmit = debounce(async () => {
if (isHandwrite.value) {
handleHandwriteSubmit();
return;
}
formRef.value?.validate(async (errors) => {
if (!errors) {
taskStatus.value = TASK_STATUS.LOADING;
const { link } = form.value;
const { code, data } = await postWorksByLink({ link });
if (code === 200) {
taskStatus.value = TASK_STATUS.SUCCESS;
works.value = data ? [data] : [];
}
}
});
}, 300);
// 提交处理
const onSubmit = () => {
debouncedSubmit();
};
// 手写提交处理
const handleHandwriteSubmit = () => {
if (!form.value.writerLink) {
AMessage.warning('请输入上传链接!');
return;
}
copy(form.value.writerLink);
AMessage.success('复制成功!');
onClose();
};
// 取消上传
const onCancelUpload = () => {
taskStatus.value = TASK_STATUS.DEFAULT;
AMessage.info('已取消上传');
};
// 文件上传处理
const handleUpload = async (option) => {
taskStatus.value = TASK_STATUS.LOADING;
const {
fileItem: { file },
} = option;
const formData = new FormData();
formData.append('file', file);
uploadingFiles.value.push(file);
const { code, data } = await postWorksByFile(formData, {
timeout: 0,
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (code === 200) {
if (data) {
const id = data.id ?? getNextWorkId();
const _data = { ...data, id };
works.value.push(_data);
uploadSuccessFiles.value.push(_data);
}
if (uploadingFiles.value.length === uploadSuccessFiles.value.length) {
taskStatus.value = TASK_STATUS.SUCCESS;
}
}
};
// 跳转上传页面
const goUpload = () => {
slsWithCatch('waitUploadWorks', JSON.stringify(works.value));
router.push({
name: 'ManuscriptUpload',
});
onClose();
};
// 删除项目
const onDelete = (index) => {
works.value.splice(index, 1);
if (!works.value.length) {
taskStatus.value = TASK_STATUS.DEFAULT;
}
};
// 上传方式切换
const onUploadTypeChange = (val) => {
if (val === UPLOAD_TYPE.HANDWRITE) {
getWriterLink();
}
// uploadType.value = val;
// formRef.value?.clearValidate?.();
};
// 下载模板
const handleDownloadTemplate = async () => {
const { code, data } = await getTemplateUrl();
if (code === 200) {
window.open(data.download_url, '_blank');
}
};
// 渲染链接上传表单
const renderLinkForm = () => (
<FormItem label="链接地址" field="link" required>
<Textarea
v-model={form.value.link}
size="large"
placeholder="请输入飞书链接地址"
autoSize={{ minRows: 5, maxRows: 8 }}
/>
</FormItem>
);
// 渲染手写上传表单
const renderHandwriteForm = () => (
<FormItem label="上传链接" field="writerLink">
<Input v-model={form.value.writerLink} placeholder="请输入上传链接" disabled size="large" />
</FormItem>
);
// 渲染本地上传表单
const renderLocalForm = () => (
<FormItem label="内容稿件">
<div class="flex flex-col w-full">
<Upload
action="/"
draggable
multiple
customRequest={handleUpload}
accept=".xlsx,.xls,.docx,.doc,.mp4,.mov,.avi,.flv,.wmv,.m4v"
show-file-list={false}
>
{{
'upload-button': () => (
<div class="upload-box">
<icon-plus size="14" class="mb-16px" />
<span class="text mb-4px">点击或拖拽文件到此处上传</span>
<span class="tip">支持文档文本+, 视频批量上传</span>
</div>
),
}}
</Upload>
<div class="flex items-center cursor-pointer mt-8px" onClick={handleDownloadTemplate}>
<icon-download size="14" class="mr-4px !color-#6D4CFE" />
<span class="cts color-#6D4CFE">下载示例文档</span>
</div>
</div>
</FormItem>
);
// 渲染加载状态
const renderLoadingState = () => (
<div class="flex flex-col items-center justify-center rounded-8px bg-#F7F8FA h-208px">
<icon-loading size="48" class="!color-#6D4CFE mb-16px" />
<p class="tip !text-#768893">上传过程耗时可能较长请耐心等待</p>
<p class="tip !text-#768893">刷新页面将会终止本次数据的上传请谨慎操作</p>
</div>
);
// 渲染失败状态
const renderFailedState = () => (
<div class="flex flex-col items-center justify-center rounded-8px bg-#F7F8FA h-208px">
<img src={icon1} class="w-80px h-80px mb-16px" />
<p class="text mb-4px">上传失败</p>
<p class="tip !text-#768893">可能是网络不稳定导致请检查网络后重试</p>
</div>
);
// 渲染成功状态
const renderSuccessState = () => (
<div class="flex flex-col py-12px max-h-540px rounded-8px bg-#F7F8FA">
<span class="tip mb-8px px-12px fs-14px !text-left"> {works.value.length} 个内容稿件</span>
<div class="flex-1 overflow-y-auto overflow-x-hidden px-12px">
{works.value.map((item, index) => (
<div key={item.id} class="rounded-8px bg-#fff px-8px py-8px flex justify-between items-center mt-8px">
<div class="flex-1 overflow-hidden flex items-center mr-12px">
<img src={item.cover} width="32" height="32" class="rounded-3px mr-8px" />
<TextOverTips class="text !text-left" context={item.title} />
</div>
<icon-delete
size="16px"
class="color-#737478 cursor-pointer hover:!color-#211F24"
onClick={() => onDelete(index)}
/>
</div>
))}
</div>
</div>
);
// 渲染表单内容
const renderFormContent = () => {
const contentMap = {
[TASK_STATUS.DEFAULT]: () => {
const formMap = {
[UPLOAD_TYPE.LINK]: renderLinkForm,
[UPLOAD_TYPE.HANDWRITE]: renderHandwriteForm,
[UPLOAD_TYPE.LOCAL]: renderLocalForm,
};
return formMap[uploadType.value]?.();
},
[TASK_STATUS.LOADING]: renderLoadingState,
[TASK_STATUS.FAILED]: renderFailedState,
[TASK_STATUS.SUCCESS]: renderSuccessState,
};
return contentMap[taskStatus.value]?.();
};
// 渲染底部按钮
const renderFooterButtons = () => {
const buttonMap = {
[TASK_STATUS.LOADING]: () => (
<Button type="primary" size="medium" onClick={onCancelUpload}>
取消上传
</Button>
),
[TASK_STATUS.DEFAULT]: () => (
<>
<Button size="medium" onClick={onClose}>
取消
</Button>
<Button type="primary" size="medium" onClick={onSubmit}>
{isHandwrite.value ? '复制链接' : '确认'}
</Button>
</>
),
[TASK_STATUS.FAILED]: () => (
<>
<Button size="medium" onClick={onClose}>
取消
</Button>
<Button type="primary" size="medium" onClick={onClose}>
重新上传
</Button>
</>
),
[TASK_STATUS.SUCCESS]: () => (
<>
<Button size="medium" onClick={onClose}>
取消
</Button>
<Button type="primary" size="medium" onClick={goUpload}>
确认
</Button>
</>
),
};
return buttonMap[taskStatus.value]?.();
};
expose({ open });
return () => (
<Modal
v-model:visible={visible.value}
title={getTitle()}
modal-class="upload-manuscript-modal"
width="500px"
mask-closable={false}
unmount-on-close
onClose={onClose}
footer={!(isDefault.value && isLocal.value)}
v-slots={{
footer: () => renderFooterButtons(),
}}
>
<Form
ref={formRef}
rules={{
link: [{ required: true, message: '请输入飞书链接地址' }],
}}
model={form.value}
layout="horizontal"
auto-label-width
>
{isDefault.value && (
<FormItem label="上传方式">
<RadioGroup v-model={uploadType.value} onChange={onUploadTypeChange}>
<Radio value={UPLOAD_TYPE.LINK}>链接上传</Radio>
<Radio value={UPLOAD_TYPE.LOCAL}>本地上传</Radio>
<Radio value={UPLOAD_TYPE.HANDWRITE}>写手上传</Radio>
</RadioGroup>
</FormItem>
)}
{renderFormContent()}
</Form>
</Modal>
);
},
};
</script>
<style lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,31 @@
.upload-manuscript-modal {
.text {
color: var(--Text-1, #211f24);
text-align: center;
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
.tip {
color: var(--Text-3, #737478);
text-align: center;
font-family: $font-family-regular;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.upload-box {
display: flex;
height: 120px;
padding: 0 16px;
flex-direction: column;
justify-content: center;
align-items: center;
border-radius: 2px;
border: 1px dashed var(--Border-1, #d7d7d9);
background: var(--BG-200, #f2f3f5);
}
}

View File

@ -0,0 +1,208 @@
<script lang="jsx">
import { Button, Message as AMessage, Spin } from '@arco-design/web-vue';
import { useRouter, useRoute } from 'vue-router';
import { AuditStatus } from '@/views/material-center/components/finished-products/constants';
import { getWorksDetail } from '@/api/all/generationWorkshop';
import { EnumManuscriptType } from '@/views/material-center/components/finished-products/manuscript/list/constants.ts';
import { convertVideoUrlToCoverUrl, exactFormatTime } from '@/utils/tools.ts';
import { slsWithCatch } from '@/utils/stroage.ts';
import { useSidebarStore } from '@/stores/modules/side-bar';
const DEFAULT_SOURCE_INFO = {
title: '成品库',
routeName: 'MaterialCenterFinishedProducts',
};
const SOURCE_MAP = new Map([['check', { title: '成品库', routeName: 'MaterialCenterFinishedProducts' }]]);
export default {
setup(props, { emit, expose }) {
const router = useRouter();
const route = useRoute();
const sidebarStore = useSidebarStore();
const workId = ref(route.params.id);
const { source, audit_status } = route.query;
// 视频播放相关状态
const isPlaying = ref(false);
const videoRef = ref(null);
const videoUrl = ref('');
const coverImageUrl = ref('');
const isVideoLoaded = ref(false);
const dataSource = ref({});
const images = ref([]);
const loading = ref(false);
const isVideo = computed(() => dataSource.value.type === EnumManuscriptType.Video);
const sourceInfo = computed(() => SOURCE_MAP.get(source) ?? DEFAULT_SOURCE_INFO);
const collapsed = computed(() => {
return sidebarStore.menuCollapse;
});
const onBack = () => {
router.push({ name: sourceInfo.value.routeName });
};
const initData = () => {
const [fileOne, ...fileOthers] = dataSource.value.files ?? [];
if (isVideo.value) {
videoUrl.value = fileOne.url;
coverImageUrl.value = convertVideoUrlToCoverUrl(fileOne.url);
} else {
coverImageUrl.value = fileOne.url;
images.value = fileOthers;
}
};
const getData = async () => {
try {
loading.value = true;
const { code, data } = await getWorksDetail(workId.value);
if (code === 200) {
dataSource.value = data;
initData();
}
} finally {
loading.value = false;
}
};
const renderMainImg = () => {
if (!coverImageUrl.value) return null;
if (isVideo.value) {
return (
<div class="main-video-box mb-16px relative overflow-hidden cursor-pointer" onClick={togglePlay}>
<video ref={videoRef} class="w-100% h-100% object-contain" onEnded={onVideoEnded}></video>
{!isPlaying.value && (
<>
<img src={coverImageUrl.value} class="w-100% h-100% object-contain absolute z-0 top-0 left-0" />
<div v-show={!isPlaying.value} class="play-icon"></div>
</>
)}
</div>
);
} else {
return (
<div class="main-img-box mb-16px overflow-hidden cursor-pointer">
<img src={coverImageUrl.value} class="w-100% h-100% object-contain" />
</div>
);
}
};
const togglePlay = () => {
if (!videoRef.value) return;
if (isPlaying.value) {
videoRef.value.pause();
} else {
if (!isVideoLoaded.value) {
videoRef.value.src = videoUrl.value;
isVideoLoaded.value = true;
}
videoRef.value.play();
}
isPlaying.value = !isPlaying.value;
};
const onVideoEnded = () => {
isPlaying.value = false;
};
const renderFooterRow = () => {
const _fn = () => {
slsWithCatch('manuscriptCheckIds', [workId.value]);
router.push({ name: 'ManuscriptCheck' });
};
return (
<>
<Button size="medium" type="outline" class="mr-12px" onClick={onBack}>
退出
</Button>
<Button
size="medium"
type="outline"
class="mr-12px"
onClick={() => router.push({
name: 'ManuscriptEdit',
params: {
id: workId.value
},
})}
>
编辑
</Button>
{audit_status !== AuditStatus.Passed && (
<Button type="primary" size="medium" onClick={_fn}>
去审核
</Button>
)}
</>
);
};
onMounted(() => {
workId && getData();
});
onBeforeUnmount(() => {
if (videoRef.value) {
videoRef.value.pause();
videoRef.value = null;
}
});
return () => (
<Spin loading={loading.value} class="manuscript-detail-wrap" size={50}>
<div class="h-full w-full flex flex-col">
<div class="flex items-center mb-8px">
<span class="cts color-#4E5969 cursor-pointer" onClick={onBack}>
{sourceInfo.value.title}
</span>
<icon-oblique-line size="12" class="color-#C9CDD4 mx-4px" />
<span class="cts bold !color-#1D2129">内容稿件详情</span>
</div>
<div class="flex-1 bg-#fff rounded-8px py-32px">
<div class="w-684px mx-auto flex flex-col items-center">
<div class="flex justify-start flex-col w-full">
<p class="mb-8px cts bold !text-28px !lh-40px !color-#211F24">{dataSource.value.title}</p>
<p class="cts !text-12px !color-#737478 mb-32px w-full text-left">
{exactFormatTime(dataSource.value.update_time)}修改
</p>
</div>
{renderMainImg()}
<div class="w-full">
<p class="cts !color-#211F24 whitespace-pre-line">{dataSource.value.content}</p>
</div>
{/* 仅图片类型显示图片列表 */}
{!isVideo.value && (
<div class="desc-img-wrap mt-40px">
{images.value.map((item, index) => (
<div class="desc-img-box" key={index}>
<img src={item.url} class="w-100% h-100% object-contain" />
</div>
))}
</div>
)}
</div>
</div>
</div>
<footer
class={`flex justify-end items-center px-16px py-16px w-full bg-#F6F5FC footer-row ${
collapsed.value ? 'collapsed' : ''
}`}
>
{renderFooterRow()}
</footer>
</Spin>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,70 @@
$footer-height: 68px;
.manuscript-detail-wrap {
width: 100%;
height: calc(100% - ($footer-height - $layout-padding-bottom));
.cts {
color: #939499;
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&.bold {
font-family: $font-family-medium;
}
}
.main-video-box {
width: 320px;
height: 472px;
background: #333;
aspect-ratio: 3 / 4;
}
.main-img-box {
width: 320px;
height: auto;
max-height: 472px;
background: #fff;
// aspect-ratio: 3/4;
}
.desc-img-wrap {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
.desc-img-box {
width: 212px;
height: 283px;
background: #fff;
object-fit: contain;
aspect-ratio: 3/4;
}
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 222;
width: 64px;
height: 64px;
background-image: url('@/assets/img/creative-generation-workshop/icon-play.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
transition: background-image 0.3s ease;
}
.play-icon:hover {
background-image: url('@/assets/img/creative-generation-workshop/icon-play-hover.png');
}
}
.footer-row {
position: fixed;
bottom: 0;
left: $sidebar-width;
width: calc(100% - $sidebar-width);
height: $footer-height;
&.collapsed {
left: $sidebar-width-collapse;
width: calc(100% - $sidebar-width-collapse);
}
}

View File

@ -0,0 +1,34 @@
<template>
<a-modal v-model:visible="visible" title="退出编辑" width="480px" @close="onClose">
<div class="flex items-center">
<img :src="icon1" width="20" height="20" class="mr-12px" />
<span>内容已修改尚未保存若退出编辑本次修改将不保存</span>
</div>
<template #footer>
<a-button size="medium" @click="onClose">继续编辑</a-button>
<a-button type="primary" class="ml-8px" size="medium" @click="onConfirm">确认退出</a-button>
</template>
</a-modal>
</template>
<script setup>
import { ref } from 'vue';
import icon1 from '@/assets/img/media-account/icon-warn-1.png';
const router = useRouter();
const visible = ref(false);
const onClose = () => {
visible.value = false;
};
const onConfirm = () => {
onClose();
router.push({ name: 'MaterialCenterFinishedProducts' });
};
const open = () => {
visible.value = true;
};
defineExpose({ open });
</script>

View File

@ -0,0 +1,142 @@
<script lang="jsx">
import { Button, Message as AMessage } from '@arco-design/web-vue';
import EditForm, { ENUM_UPLOAD_STATUS, INITIAL_VIDEO_INFO } from '../components/edit-form';
import CancelEditModal from './cancel-edit-modal.vue';
import { useSidebarStore } from '@/stores/modules/side-bar';
import { getWorksDetail, putWorksUpdate } from '@/api/all/generationWorkshop';
import { EnumManuscriptType } from '@/views/material-center/components/finished-products/manuscript/list/constants.ts';
import { formatDuration, formatFileSize, convertVideoUrlToCoverUrl } from '@/utils/tools';
import { slsWithCatch } from '@/utils/stroage.ts';
export default {
components: {
EditForm,
},
setup(props, { emit, expose }) {
const router = useRouter();
const route = useRoute();
const sidebarStore = useSidebarStore();
const formRef = ref(null);
const cancelEditModal = ref(null);
const dataSource = ref({});
const remoteDataSource = ref({});
const uploadLoading = ref(false);
const isSaved = ref(false);
const workId = ref(route.params.id);
const collapsed = computed(() => {
return sidebarStore.menuCollapse;
});
const onCancel = () => {
const isModified = !isEqual(dataSource.value, remoteDataSource.value);
if (isModified && !isSaved.value) {
cancelEditModal.value?.open();
} else {
onBack();
}
};
const onSave = async (check = false) => {
formRef.value?.validate().then(async () => {
if (dataSource.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.UPLOADING) {
AMessage.warning('有视频正在上传中,请等待上传完成后再提交');
return;
}
const filteredWorks = omit(dataSource.value, 'videoInfo');
const { code, data } = await putWorksUpdate({ id: workId.value, ...filteredWorks });
if (code === 200) {
AMessage.success('保存成功');
isSaved.value = true;
if (check) {
slsWithCatch('manuscriptCheckIds', [workId.value]);
router.push({ name: 'ManuscriptCheck' });
} else {
onBack();
}
}
});
};
const getData = async () => {
const { code, data } = await getWorksDetail(workId.value);
if (code === 200) {
const { type, files } = data;
const _data = { ...data, videoInfo: cloneDeep(INITIAL_VIDEO_INFO) };
// 初始化视频数据
if (type === EnumManuscriptType.Video && files.length) {
_data.videoInfo.uploadStatus = 'end';
const { name, size, duration, url } = files[0];
_data.videoInfo.name = name;
_data.videoInfo.size = formatFileSize(size);
_data.videoInfo.time = formatDuration(duration);
_data.videoInfo.poster = convertVideoUrlToCoverUrl(url);
}
dataSource.value = cloneDeep(_data);
remoteDataSource.value = cloneDeep(_data);
}
};
const onChange = (val) => {
dataSource.value = val;
};
const onUpdateVideoInfo = (newVideoInfo) => {
dataSource.value.videoInfo = { ...dataSource.value.videoInfo, ...newVideoInfo };
};
const onBack = () => {
router.push({ name: 'MaterialCenterFinishedProducts' });
};
onMounted(() => {
workId && getData();
});
return () => (
<>
<div class="manuscript-edit-wrap">
<div class="flex items-center mb-8px">
<span class="cts color-#4E5969 cursor-pointer" onClick={onCancel}>
成品库
</span>
<icon-oblique-line size="12" class="color-#C9CDD4 mx-4px" />
<span class="cts bold !color-#1D2129">编辑内容稿件</span>
</div>
<div class="flex-1 overflow-y-auto p-24px bg-#fff rounded-8px ">
<EditForm
ref={formRef}
formData={dataSource.value}
onChange={onChange}
onUpdateVideoInfo={onUpdateVideoInfo}
/>
</div>
</div>
<footer
class={`flex justify-end items-center px-16px py-16px w-full bg-#F6F5FC footer-row ${
collapsed.value ? 'collapsed' : ''
}`}
>
<Button size="medium" type="outline" onClick={onCancel} class="mr-12px">
退出
</Button>
<Button size="medium" type="outline" onClick={() => onSave()} class="mr-12px">
保存
</Button>
<Button type="primary" size="medium" onClick={() => onSave(true)} loading={uploadLoading.value}>
保存并审核
</Button>
</footer>
<CancelEditModal ref={cancelEditModal} />
</>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,28 @@
$footer-height: 68px;
.manuscript-edit-wrap {
height: calc(100% - ($footer-height - $layout-padding-bottom));
display: flex;
flex-direction: column;
.cts {
color: #939499;
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&.bold {
font-family: $font-family-medium;
}
}
}
.footer-row {
position: fixed;
bottom: 0;
left: $sidebar-width;
width: calc(100% - $sidebar-width);
height: $footer-height;
&.collapsed {
left: $sidebar-width-collapse;
width: calc(100% - $sidebar-width-collapse);
}
}

View File

@ -0,0 +1,149 @@
<template>
<div class="common-filter-wrap">
<div class="filter-row">
<div class="filter-row-item">
<span class="label">内容稿件标题</span>
<a-input
v-model="query.title"
class="!w-240px"
placeholder="请输入内容稿件标题"
size="medium"
allow-clear
@change="handleSearch"
>
<template #prefix>
<icon-search />
</template>
</a-input>
</div>
<!-- <div class="filter-row-item">
<span class="label">所属项目</span>
<CommonSelect
placeholder="请选择所属项目"
v-model="query.project_id"
:options="projects"
class="!w-166px"
@change="handleSearch"
/>
</div> -->
<div class="filter-row-item">
<span class="label">序号</span>
<a-space size="medium">
<a-input
v-model="query.uid"
class="!w-160px"
placeholder="请输入序号"
size="medium"
allow-clear
@change="handleSearch"
>
<template #prefix>
<icon-search />
</template>
</a-input>
</a-space>
</div>
<div class="filter-row-item">
<span class="label">审核状态</span>
<CommonSelect
placeholder="全部"
:options="CHECK_STATUS"
v-model="query.audit_status"
class="!w-160px"
:multiple="false"
@change="handleSearch"
/>
</div>
<div class="filter-row-item">
<span class="label">上传时间</span>
<a-range-picker
v-model="created_at"
size="medium"
allow-clear
format="YYYY-MM-DD"
class="w-280px"
@change="onDateChange"
/>
</div>
<div class="filter-row-item">
<a-button type="outline" class="mr-12px" size="medium" @click="handleSearch">
<template #icon>
<icon-search />
</template>
<template #default>搜索</template>
</a-button>
<a-button size="medium" @click="handleReset">
<template #icon>
<icon-refresh />
</template>
<template #default>重置</template>
</a-button>
</div>
</div>
</div>
</template>
<script setup>
import { defineEmits, defineProps } from 'vue';
import { CHECK_STATUS } from '@/views/material-center/components/finished-products/manuscript/list/constants';
import CommonSelect from '@/components/common-select';
// import { getProjectList } from '@/api/all/propertyMarketing';
const props = defineProps({
query: {
type: Object,
required: true,
},
});
const emits = defineEmits('search', 'reset', 'update:query');
const created_at = ref([]);
// const projects = ref([]);
const handleSearch = () => {
emits('update:query', props.query);
nextTick(() => {
emits('search');
});
};
const onDateChange = (value) => {
if (!value) {
props.query.created_at = [];
handleSearch();
return;
}
const [start, end] = value;
const FORMAT_DATE = 'YYYY-MM-DD HH:mm:ss';
props.query.created_at = [
dayjs(start).startOf('day').format(FORMAT_DATE),
dayjs(end).endOf('day').format(FORMAT_DATE),
];
handleSearch();
};
// 获取项目列表
// const getProjects = async () => {
// try {
// const { code, data } = await getProjectList();
// if (code === 200) {
// projects.value = data;
// }
// } catch (error) {
// console.error('获取项目列表失败:', error);
// }
// };
const handleReset = () => {
created_at.value = [];
// projects.value = [];
emits('reset');
};
onMounted(() => {
// getProjects();
});
</script>

View File

@ -0,0 +1,73 @@
export const TABLE_COLUMNS = [
{
title: '序号',
dataIndex: 'uid',
width: 120,
fixed: 'left',
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '图片/视频',
dataIndex: 'cover',
width: 120,
},
{
title: '内容稿件标题',
dataIndex: 'title',
width: 240,
},
{
title: '客户意见',
dataIndex: 'customer_opinion',
width: 120,
},
// {
// title: '所属项目',
// dataIndex: 'projects',
// width: 240,
// },
{
title: '稿件类型',
dataIndex: 'type',
width: 120,
},
{
title: '审核状态',
dataIndex: 'audit_status',
width: 120,
},
{
title: '上传时间',
dataIndex: 'created_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '上传人员',
dataIndex: 'uploader',
width: 180,
},
{
title: '最后修改时间',
dataIndex: 'last_modified_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '最后修改人员',
dataIndex: 'last_modifier',
width: 180,
},
{
title: '操作',
dataIndex: 'operation',
width: 180,
fixed: 'right',
},
];

View File

@ -0,0 +1,58 @@
<template>
<a-modal
v-model:visible="visible"
title="删除稿件"
width="480px"
@close="onClose"
>
<div class="flex items-center">
<img :src="icon1" width="20" height="20" class="mr-12px" />
<span>确认删除 {{ projectName }} 这个稿件吗</span>
</div>
<template #footer>
<a-button size="large" @click="onClose">取消</a-button>
<a-button type="primary" class="ml-16px" status="danger" size="large" @click="onDelete"
>确认删除</a-button
>
</template>
</a-modal>
</template>
<script setup>
import { ref } from 'vue';
import { deleteWork } from '@/api/all/generationWorkshop';
import icon1 from '@/assets/img/media-account/icon-warn-1.png';
const update = inject('update');
const visible = ref(false);
const projectId = ref(null);
const projectName = ref('');
const isBatch = computed(() => Array.isArray(projectId.value));
function onClose() {
visible.value = false;
projectId.value = null;
projectName.value = '';
}
const open = (record) => {
const { id = null, name = '' } = record;
projectId.value = id;
projectName.value = name;
visible.value = true;
};
async function onDelete() {
const { code } = await deleteWork(projectId.value);
if (code === 200) {
AMessage.success('删除成功');
update()
onClose();
}
}
defineExpose({ open });
</script>

View File

@ -0,0 +1,167 @@
<template>
<a-table
ref="tableRef"
:data="dataSource"
row-key="id"
column-resizable
:pagination="false"
:scroll="{ x: '100%' }"
class="manuscript-table w-100% flex-1"
bordered
@sorter-change="handleSorterChange"
>
<template #empty>
<NoData text="暂无稿件" />
</template>
<template #columns>
<a-table-column
v-for="column in TABLE_COLUMNS"
:key="column.dataIndex"
:data-index="column.dataIndex"
:fixed="column.fixed"
:width="column.width"
:min-width="column.minWidth"
:sortable="column.sortable"
:align="column.align"
ellipsis
tooltip
>
<template #title>
<div class="flex items-center">
<span class="cts mr-4px">{{ column.title }}</span>
<a-tooltip v-if="column.tooltip" :content="column.tooltip" position="top">
<icon-question-circle class="tooltip-icon color-#737478" size="16" />
</a-tooltip>
</div>
</template>
<template v-if="column.dataIndex === 'create_at'" #cell="{ record }">
{{ exactFormatTime(record.create_at) }}
</template>
<template v-else-if="column.dataIndex === 'customer_opinion'" #cell="{ record }">
<p
class="h-28px px-8px flex items-center rounded-2px w-fit"
:style="{ background: getCustomerOpinionInfo(record.customer_opinion)?.bg }"
>
<span class="cts" :class="getCustomerOpinionInfo(record.customer_opinion)?.color">{{
getCustomerOpinionInfo(record.customer_opinion)?.label ?? '-'
}}</span>
</p>
</template>
<template v-else-if="column.dataIndex === 'title'" #cell="{ record }">
<TextOverTips :context="record.title" :line="3" class="title" @click="onDetail(record)" />
</template>
<template v-else-if="column.dataIndex === 'audit_status'" #cell="{ record }">
<div
class="flex items-center w-fit h-28px px-8px rounded-2px"
:style="{ backgroundColor: getStatusInfo(record.audit_status).backgroundColor }"
>
<span class="cts s1" :style="{ color: getStatusInfo(record.audit_status).color }">{{
getStatusInfo(record.audit_status).name
}}</span>
</div>
</template>
<template v-else-if="column.dataIndex === 'type'" #cell="{ record }">
<div class="flex items-center">
<img
:src="record.type === EnumManuscriptType.Image ? icon2 : icon3"
width="16"
height="16"
class="mr-4px"
/>
<span class="cts" :class="record.type === EnumManuscriptType.Image ? '!color-#25C883' : '!color-#6D4CFE'">{{
record.type === EnumManuscriptType.Image ? '图文' : '视频'
}}</span>
</div>
</template>
<template v-else-if="['uploader', 'last_modifier'].includes(column.dataIndex)" #cell="{ record }">
{{ record[column.dataIndex].name || record[column.dataIndex].mobile }}
</template>
<template v-else-if="['created_at', 'last_modified_at'].includes(column.dataIndex)" #cell="{ record }">
{{ exactFormatTime(record[column.dataIndex]) }}
</template>
<template v-else-if="column.dataIndex === 'cover'" #cell="{ record }">
<HoverImagePreview :src="record.cover">
<a-image :width="64" :height="64" :src="record.cover" class="!rounded-6px" fit="cover">
<template #error>
<img :src="icon4" class="w-full h-full" />
</template>
</a-image>
</HoverImagePreview>
</template>
<template v-else-if="column.dataIndex === 'operation'" #cell="{ record }">
<div class="flex items-center">
<img class="mr-8px cursor-pointer" :src="icon1" width="14" height="14" @click="onDelete(record)" />
<a-button type="outline" size="mini" class="mr-8px" @click="onEdit(record)">编辑</a-button>
<a-button type="outline" size="mini" @click="onDetail(record)">详情</a-button>
</div>
</template>
<template v-else #cell="{ record }">
{{ formatTableField(column, record, true) }}
</template>
</a-table-column>
</template>
</a-table>
</template>
<script setup>
import { ref } from 'vue';
import { formatTableField, exactFormatTime } from '@/utils/tools';
import { TABLE_COLUMNS } from './constants';
import { CHECK_STATUS, EnumManuscriptType } from '@/views/material-center/components/finished-products/manuscript/list/constants';
import { CUSTOMER_OPINION } from '@/views/material-center/components/finished-products/manuscript/check-list/constants';
import TextOverTips from '@/components/text-over-tips';
import HoverImagePreview from '@/components/hover-image-preview';
import icon1 from '@/assets/img/media-account/icon-delete.png';
import icon2 from '@/assets/img/creative-generation-workshop/icon-photo.png';
import icon3 from '@/assets/img/creative-generation-workshop/icon-video.png';
import icon4 from '@/assets/img/error-img.png';
const emits = defineEmits(['edit', 'sorterChange', 'delete']);
const router = useRouter();
const props = defineProps({
dataSource: {
type: Array,
default: () => [],
},
});
const tableRef = ref(null);
const handleSorterChange = (column, order) => {
emits('sorterChange', column, order === 'ascend' ? 'asc' : 'desc');
};
const onDelete = (item) => {
emits('delete', item);
};
const onEdit = (item) => {
router.push({
name: 'ManuscriptEdit',
params: {
id: item.id,
},
});
};
const onDetail = (item) => {
router.push({
name: 'ManuscriptDetail',
params: {
id: item.id,
},
});
};
const getStatusInfo = (audit_status) => {
return CHECK_STATUS.find((v) => v.id === audit_status) ?? {};
};
const getCustomerOpinionInfo = (value) => {
return CUSTOMER_OPINION.find((item) => item.value === value);
};
</script>
<style scoped lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,16 @@
.manuscript-table {
.cts {
color: var(--Text-1, #211f24);
font-family: $font-family-medium;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
:deep(.title) {
cursor: pointer;
&:hover {
color: #6d4cfe;
}
}
}

View File

@ -0,0 +1,56 @@
export const INITIAL_QUERY = {
title: '',
// project_ids: [],
uid: '',
audit_status: '',
created_at: [],
sort_column: undefined,
sort_order: undefined,
};
export enum EnumCheckStatus {
All = '',
Wait = 1,
Checking = 2,
Passed = 3,
}
export enum EnumManuscriptType {
All = '',
Image = 0,
Video = 1,
}
export const CHECK_STATUS = [
{
name: '待审核',
id: EnumCheckStatus.Wait,
backgroundColor: '#F2F3F5',
color: '#3C4043'
},
{
name: '审核中',
id: EnumCheckStatus.Checking,
backgroundColor: '#FFF7E5',
color: '#FFAE00'
},
{
name: '已通过',
id: EnumCheckStatus.Passed,
backgroundColor: '#EBF7F2',
color: '#25C883'
},
];
export const MANUSCRIPT_TYPE = [
{
label: '全部',
value: EnumManuscriptType.All,
},
{
label: '图片',
value: EnumManuscriptType.Image,
},
{
label: '视频',
value: EnumManuscriptType.Video,
},
]

View File

@ -0,0 +1,94 @@
<template>
<div class="manuscript-list-wrap">
<div class="filter-wrap bg-#fff rounded-b-8px mb-16px">
<FilterBlock v-model:query="query" @search="handleSearch" @reset="handleReset" />
</div>
<div
class="table-wrap bg-#fff rounded-8px px-24px py-24px flex flex-col"
>
<ManuscriptTable :dataSource="dataSource" @sorterChange="handleSorterChange" @delete="handleDelete" />
<div v-if="pageInfo.total > 0" class="pagination-row">
<a-pagination
:total="pageInfo.total"
size="mini"
show-total
show-jumper
show-page-size
:current="pageInfo.page"
:page-size="pageInfo.page_size"
@change="onPageChange"
@page-size-change="onPageSizeChange"
/>
</div>
</div>
<DeleteManuscriptModal ref="deleteManuscriptModalRef" />
</div>
</template>
<script lang="jsx" setup>
import { defineComponent } from 'vue';
import { Button } from '@arco-design/web-vue';
import FilterBlock from './components/filter-block';
import ManuscriptTable from './components/manuscript-table';
import DeleteManuscriptModal from './components/manuscript-table/delete-manuscript-modal.vue';
import { useTableSelectionWithPagination } from '@/hooks/useTableSelectionWithPagination';
import { getWorksPage } from '@/api/all/generationWorkshop.ts';
import { INITIAL_QUERY, EnumCheckStatus } from '@/views/material-center/components/finished-products/manuscript/list/constants.ts';
const { dataSource, pageInfo, onPageChange, onPageSizeChange, resetPageInfo } = useTableSelectionWithPagination({
onPageChange: () => {
getData();
},
onPageSizeChange: () => {
getData();
},
});
const query = ref(cloneDeep(INITIAL_QUERY));
const addManuscriptModalRef = ref(null);
const deleteManuscriptModalRef = ref(null);
const getData = async () => {
const { page, page_size } = pageInfo.value;
const { code, data } = await getWorksPage({
...query.value,
page,
page_size,
});
if (code === 200) {
dataSource.value = data?.data ?? [];
pageInfo.value.total = data.total;
}
};
const handleSearch = () => {
reload();
};
const reload = () => {
pageInfo.value.page = 1;
getData();
};
const handleReset = () => {
resetPageInfo();
query.value = cloneDeep(INITIAL_QUERY);
reload();
};
const handleSorterChange = (column, order) => {
query.value.sort_column = column;
query.value.sort_order = order;
reload();
};
const handleDelete = (item) => {
const { id, title } = item;
deleteManuscriptModalRef.value?.open({ id, name: `${title}` });
};
onMounted(() => {
getData();
});
provide('update', getData);
</script>
<style scoped lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,11 @@
.manuscript-list-wrap {
// height: 100%;
display: flex;
flex-direction: column;
.filter-wrap {
}
.table-wrap {
display: flex;
flex-direction: column;
}
}

View File

@ -0,0 +1,38 @@
<template>
<a-modal v-model:visible="visible" title="确认提示" width="480px" @close="onClose">
<div class="flex items-center">
<img :src="icon1" width="20" height="20" class="mr-12px" />
<span>确认取消上传这 {{ num }} 个文件吗此操作不可恢复</span>
</div>
<template #footer>
<a-button size="medium" @click="onClose">继续编辑</a-button>
<a-button type="primary" class="ml-8px" size="medium" @click="onConfirm">确认取消</a-button>
</template>
</a-modal>
</template>
<script setup>
import { ref } from 'vue';
import icon1 from '@/assets/img/media-account/icon-warn-1.png';
const router = useRouter();
const visible = ref(false);
const num = ref('');
const onClose = () => {
num.value = '';
visible.value = false;
};
const onConfirm = () => {
onClose();
router.push({ name: 'MaterialCenterFinishedProducts' });
};
const open = (manusNum) => {
num.value = manusNum;
visible.value = true;
};
defineExpose({ open });
</script>

View File

@ -0,0 +1,325 @@
<script lang="jsx">
import { Button, Message as AMessage } from '@arco-design/web-vue';
import TextOverTips from '@/components/text-over-tips';
import EditForm, { ENUM_UPLOAD_STATUS, INITIAL_VIDEO_INFO } from '../components/edit-form';
import CancelUploadModal from './cancel-upload-modal.vue';
import UploadSuccessModal from './upload-success-modal.vue';
import { EnumManuscriptType } from '@/views/material-center/components/finished-products/manuscript/list/constants';
import { postWorksBatch } from '@/api/all/generationWorkshop.ts';
import { glsWithCatch, rlsWithCatch, slsWithCatch } from '@/utils/stroage.ts';
import { formatDuration, formatFileSize, convertVideoUrlToCoverUrl } from '@/utils/tools';
import { useSidebarStore } from '@/stores/modules/side-bar';
import icon1 from '@/assets/img/creative-generation-workshop/icon-photo.png';
import icon2 from '@/assets/img/creative-generation-workshop/icon-video.png';
export default {
components: {
EditForm,
},
setup(props, { emit, expose }) {
const formRef = ref(null);
const route = useRoute();
const router = useRouter();
const sidebarStore = useSidebarStore();
const cancelUploadModal = ref(null);
const uploadSuccessModal = ref(null);
const works = ref([]);
const selectCardInfo = ref({});
const errorDataCards = ref([]);
const uploadLoading = ref(false);
const collapsed = computed(() => {
return sidebarStore.menuCollapse;
});
const onCancel = () => {
cancelUploadModal.value?.open(works.value.length);
};
const validateDataSource = () => {
return new Promise((resolve) => {
const uploadingVideos = works.value.filter(
(item) => item.videoInfo?.uploadStatus === ENUM_UPLOAD_STATUS.UPLOADING,
);
if (uploadingVideos.length > 0) {
AMessage.warning(`${uploadingVideos.length} 个视频正在上传中,请等待上传完成后再提交`);
return;
}
works.value.forEach((item) => {
if (!item.title?.trim()) {
errorDataCards.value.push(item);
}
});
if (!errorDataCards.value.length) {
resolve();
}
if (errorDataCards.value.length > 0) {
AMessage.warning(`${errorDataCards.value.length} 个必填信息未填写,请检查`);
setTimeout(() => {
const el = document.getElementById(`card-${errorDataCards.value[0]?.id}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
return;
}
});
};
const onSubmit = async (action) => {
uploadLoading.value = true;
const filteredWorks = map(works.value, (work) => omit(work, 'videoInfo'));
const { code, data } = await postWorksBatch({ works: filteredWorks });
if (code === 200) {
uploadLoading.value = false;
if (action === 'batchUpload') {
uploadSuccessModal.value?.open(data);
} else {
AMessage.success('上传成功');
if (action === 'uploadAndCheck') {
slsWithCatch('manuscriptCheckIds', data);
router.push({ name: 'ManuscriptCheck' });
} else {
router.push({ name: 'MaterialCenterFinishedProducts' });
}
}
}
};
// 本地存储同步
const syncLocalStorage = () => {
slsWithCatch('waitUploadWorks', JSON.stringify(works.value ?? []));
};
const onUpload = async (action) => {
formRef.value
?.validate()
.then(() => {
return validateDataSource();
})
.then(() => {
onSubmit(action);
})
.catch((err) => {
errorDataCards.value.push(err);
});
};
const handleReValidate = () => {
formRef.value?.validate().then(() => {
deleteErrorCard(selectCardInfo.value);
});
};
const onSelect = (item) => {
formRef.value.resetForm();
selectCardInfo.value = cloneDeep(item);
};
const deleteErrorCard = (item) => {
const errorIndex = errorDataCards.value.findIndex((v) => v.id === item.id);
errorIndex > -1 && errorDataCards.value.splice(errorIndex, 1);
};
const onDelete = (e, item, index) => {
e.stopPropagation();
works.value.splice(index, 1);
deleteErrorCard(item);
if (item.id === selectCardInfo.value.id) {
if (works.value.length) {
const _target = works.value[0] ?? {};
selectCardInfo.value = cloneDeep(_target);
} else {
selectCardInfo.value = {};
}
}
syncLocalStorage();
};
const renderFooterRow = () => {
if (works.value.length > 1) {
return (
<>
<Button size="medium" type="outline" onClick={onCancel} class="mr-12px">
取消上传
</Button>
<Button type="primary" size="medium" onClick={() => onUpload('batchUpload')} loading={uploadLoading.value}>
{uploadLoading.value ? '批量上传中...' : '批量上传'}
</Button>
</>
);
} else {
return (
<>
<Button size="medium" type="outline" onClick={onCancel} class="mr-12px">
取消上传
</Button>
<Button
size="medium"
type="outline"
onClick={() => onUpload('singleUpload')}
class="mr-12px"
loading={uploadLoading.value}
>
上传
</Button>
<Button
type="primary"
size="medium"
onClick={() => onUpload('uploadAndCheck')}
loading={uploadLoading.value}
>
上传并审核
</Button>
</>
);
}
};
const getData = () => {
const _works = JSON.parse(glsWithCatch('waitUploadWorks') ?? '[]')?.map((item) => {
const { type, files } = item;
const _data = { ...item, videoInfo: cloneDeep(INITIAL_VIDEO_INFO) };
// 初始化视频数据
if (type === EnumManuscriptType.Video && files.length) {
_data.videoInfo.uploadStatus = 'end';
const { name, size, duration, url } = files[0];
_data.videoInfo.name = name;
_data.videoInfo.size = formatFileSize(size);
_data.videoInfo.time = formatDuration(duration);
_data.videoInfo.poster = convertVideoUrlToCoverUrl(url);
}
return _data;
});
works.value = _works;
selectCardInfo.value = cloneDeep(_works[0] ?? {});
};
const getCardClass = (item) => {
if (selectCardInfo.value.id === item.id) {
return '!border-#6D4CFE !bg-#F0EDFF';
}
if (errorDataCards.value.find((v) => v.id === item.id)) {
return '!border-#F64B31';
}
return '';
};
const onChange = (val) => {
const index = works.value.findIndex((v) => v.id === selectCardInfo.value.id);
if (index !== -1) {
works.value[index] = cloneDeep(val);
}
selectCardInfo.value = val;
syncLocalStorage();
};
const onUpdateVideoInfo = (newVideoInfo) => {
const index = works.value.findIndex((v) => v.id === selectCardInfo.value.id);
if (index !== -1) {
works.value[index].videoInfo = cloneDeep(newVideoInfo);
}
selectCardInfo.value.videoInfo = { ...selectCardInfo.value.videoInfo, ...newVideoInfo };
syncLocalStorage();
};
onMounted(() => {
getData();
});
onUnmounted(() => {
rlsWithCatch('waitUploadWorks');
});
return () => (
<>
<div class="manuscript-upload-wrap bg-#fff rounded-8px flex">
<div class="left flex-1 overflow-y-auto p-24px">
<EditForm
ref={formRef}
formData={selectCardInfo.value}
onReValidate={handleReValidate}
onChange={onChange}
onUpdateVideoInfo={onUpdateVideoInfo}
/>
</div>
{works.value.length > 1 && (
<div class="right pt-24px pb-12px flex flex-col">
<div class="title flex items-center px-24px">
<div class="w-3px h-16px bg-#6D4CFE rounded-2px mr-8px"></div>
<p class="cts bold !color-#211F24 !text-16px !lh-24px mr-8px">上传内容稿件列表</p>
<p class="cts">{`${works.value.length}`}</p>
</div>
<div class="flex-1 px-24px overflow-y-auto pt-24px">
{works.value.map((item, index) => (
<div
key={item.id}
id={`card-${item.id}`}
class={`group h-66px relative mb-12px px-12px py-8px flex flex-col rounded-8px bg-#F7F8FA border-1px border-solid border-transparent transition-all duration-300 cursor-pointer hover:bg-#E6E6E8 ${getCardClass(
item,
)}`}
onClick={() => onSelect(item)}
>
<icon-close-circle-fill
size={16}
class="absolute top--8px right--8px color-#737478 hover:color-#211F24 hidden group-hover:block"
onClick={(e) => onDelete(e, item, index)}
/>
<TextOverTips
context={item.title || '-'}
line={1}
class={`cts !color-#211F24 mb-8px ${selectCardInfo.value.id === item.id ? 'bold' : ''}`}
/>
<div class="flex items-center justify-between">
<div class="flex items-center ">
<img
src={item.type === EnumManuscriptType.Image ? icon1 : icon2}
width="14"
height="14"
class="mr-4px"
/>
<span
class={`cts !text-12px ${
item.type === EnumManuscriptType.Image ? '!color-#25C883' : '!color-#6D4CFE'
}`}
>
{item.type === EnumManuscriptType.Image ? '图文' : '视频'}
</span>
</div>
{errorDataCards.value.find((v) => v.id === item.id) && (
<p class="cts !text-12px !color-#F64B31">必填项未填</p>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
<footer
class={`flex justify-end items-center px-16px py-16px w-full bg-#F6F5FC footer-row ${
collapsed.value ? 'collapsed' : ''
}`}
>
{renderFooterRow()}
</footer>
<CancelUploadModal ref={cancelUploadModal} />
<UploadSuccessModal ref={uploadSuccessModal} />
</>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,32 @@
$footer-height: 68px;
.manuscript-upload-wrap {
height: calc(100% - ($footer-height - $layout-padding-bottom));
.cts,
:deep(.overflow-text) {
color: #939499;
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&.bold {
font-family: $font-family-medium;
}
}
.right {
border-left: 1px solid var(--Border-2, #e6e6e8);
width: 320px;
flex-shrink: 0;
}
}
.footer-row {
position: fixed;
bottom: 0;
left: $sidebar-width;
width: calc(100% - $sidebar-width);
height: $footer-height;
&.collapsed {
left: $sidebar-width-collapse;
width: calc(100% - $sidebar-width-collapse);
}
}

View File

@ -0,0 +1,64 @@
<template>
<a-modal v-model:visible="visible" title="提示" width="480px" @close="onClose" modal-class="upload-success11-modal">
<div class="flex items-center flex-col justify-center">
<img :src="icon1" width="80" height="80" class="mb-16px" />
<span class="text-18px lh-26px font-400 color-#211F24 md">上传成功</span>
<p class="text-14px lh-22px font-400 color-#737478 ld">为确保内容合规建议您立即进行审核</p>
<p class="text-14px lh-22px font-400 color-#737478 ld">检测是否存在违规内容</p>
</div>
<template #footer>
<a-button size="medium" @click="onBack">回到列表</a-button>
<a-button type="primary" class="ml-8px" size="medium" @click="onConfirm">批量审核</a-button>
</template>
</a-modal>
</template>
<script setup>
import { ref } from 'vue';
import { slsWithCatch } from '@/utils/stroage.ts';
import icon1 from '@/assets/img/media-account/icon-feedback-success.png';
const router = useRouter();
const visible = ref(false);
const workIds = ref([]);
const onClose = () => {
workIds.value = [];
visible.value = false;
};
const onBack = () => {
onClose();
router.push({ name: 'MaterialCenterFinishedProducts' });
};
const onConfirm = () => {
visible.value = false;
slsWithCatch('manuscriptCheckIds', workIds.value);
router.push({ name: 'ManuscriptCheck' });
};
const open = (_workIds) => {
workIds.value = _workIds;
visible.value = true;
};
defineExpose({ open });
</script>
<style lang="scss">
.upload-success11-modal {
.arco-modal-header {
border-bottom: none;
}
.md {
font-family: $font-family-medium;
}
.ld {
font-family: $font-family-regular;
}
.arco-modal-footer {
border-top: none;
justify-content: center;
}
}
</style>