feat: 内容审核-写手端

This commit is contained in:
rd
2025-08-11 17:13:31 +08:00
parent 74c989d736
commit 77cf169607
45 changed files with 5208 additions and 47 deletions

View File

@ -4,125 +4,127 @@
import Http from '@/api';
const getWriterCode = () => {
const route = useRoute();
return route.params.writerCode as string;
};
// 内容稿件-批量添加(写手)
export const postWorksBatchWriter = (writerCode: string, data: any) => {
return Http.post('/v1/writer/works/batch', data, {
headers: { 'writer-code': writerCode },
export const postWorksBatchWriter = (params: any) => {
return Http.post('/v1/writer/works/batch', params, {
headers: { 'writer-code': getWriterCode() },
});
};
// 内容稿件-分页(写手)
export const getWorksPageWriter = (writerCode: string, params: any) => {
export const getWorksPageWriter = (params: any) => {
return Http.get(
'/v1/writer/works',
{ params },
{
headers: { 'writer-code': writerCode },
headers: { 'writer-code': getWriterCode() },
},
);
};
// 内容稿件-详情(写手)
export const getWorkDetailWriter = (writerCode: string, id: string) => {
export const getWorksDetailWriter = (id: string) => {
return Http.get(`/v1/writer/works/${id}`, {
headers: { 'writer-code': writerCode },
headers: { 'writer-code': getWriterCode() },
});
};
// 内容稿件-修改(写手)
export const putWorksUpdateWriter = (params = {}) => {
const { id, writerCode, ...rest } = params as { id: string; writerCode: string; [key: string]: any };
const { id, ...rest } = params as { id: string; [key: string]: any };
return Http.put(`/v1/writer/works/${id}`, rest, {
headers: { 'writer-code': writerCode },
headers: { 'writer-code': getWriterCode() },
});
};
// 内容稿件-删除(写手)
export const deleteWorkWriter = (writerCode: string, id: string) => {
export const deleteWorkWriter = (id: string) => {
return Http.delete(`/v1/writer/works/${id}`, {
headers: { 'writer-code': writerCode },
headers: { 'writer-code': getWriterCode() },
});
};
// 内容稿件-获取模板(写手)
export const getTemplateUrlWriter = (writerCode: string) => {
export const getTemplateUrlWriter = () => {
return Http.get('/v1/writer/works/template', {
headers: { 'writer-code': writerCode },
headers: { 'writer-code': getWriterCode() },
});
};
// 内容稿件审核-分页(写手)
export const getWorkAuditsPageWriter = (writerCode: string, params: any) => {
export const getWorkAuditsPageWriter = (params: any) => {
return Http.get(
'/v1/writer/work-audits',
{ params },
{
headers: { 'writer-code': writerCode },
headers: { 'writer-code': getWriterCode() },
},
);
};
// 内容稿件审核-详情(写手)
export const getWorkAuditDetailWriter = (writerCode: string, id: string) => {
export const getWorkAuditDetailWriter = (id: string) => {
return Http.get(`/v1/writer/work-audits/${id}`, {
headers: { 'writer-code': writerCode },
headers: { 'writer-code': getWriterCode() },
});
};
// 内容稿件审核-多个详情(写手)
export const getWorkAuditsBatchDetailWriter = (writerCode: string, params: any) => {
export const getWorkAuditsBatchDetailWriter = (params: any) => {
return Http.get(
'/v1/writer/work-audits/list',
{ params },
{
headers: { 'writer-code': writerCode },
headers: { 'writer-code': getWriterCode() },
},
);
};
// 内容稿件-审核(写手)
export const patchWorkAuditsAuditWriter = (writerCode: string, id: string, data: any) => {
return Http.patch(`/v1/writer/work-audits/${id}/audit`, data, {
headers: { 'writer-code': writerCode },
export const patchWorkAuditsAuditWriter = (id: string, params = {}) => {
return Http.patch(`/v1/writer/work-audits/${id}/audit`, params, {
headers: { 'writer-code': getWriterCode() },
});
};
// 内容稿件-批量审核(写手)
export const patchWorkAuditsBatchAuditWriter = (writerCode: string, data: any) => {
return Http.patch('/v1/writer/work-audits/batch-audit', data, {
headers: { 'writer-code': writerCode },
export const patchWorkAuditsBatchAuditWriter = (params: {}) => {
return Http.patch('/v1/writer/work-audits/batch-audit', params, {
headers: { 'writer-code': getWriterCode() },
});
};
// 内容稿件审核-修改(写手)
export const putWorkAuditsUpdateWriter = (writerCode: string, params = {}) => {
const { id: auditId, ...rest } = params as { id: string; [key: string]: any };
export const putWorkAuditsUpdateWriter = (params = {}) => {
const { id: auditId, ...rest } = params as { id: string; [key: string]: any };
return Http.put(`/v1/writer/work-audits/${auditId}`, rest, {
headers: { 'writer-code': writerCode },
headers: { 'writer-code': getWriterCode() },
});
};
// 内容稿件审核-审核通过(写手)
export const putWorkAuditsAuditPassWriter = (writerCode: string, id: string) => {
return Http.put(
`/v1/writer/work-audits/${id}/audit-pass`,
{},
{
headers: { 'writer-code': writerCode },
},
);
export const putWorkAuditsAuditPassWriter = (params = {}) => {
const { id: auditId, ...rest } = params as { id: string; [key: string]: any };
return Http.put(`/v1/writer/work-audits/${auditId}/audit-pass`, rest, {
headers: { 'writer-code': getWriterCode() },
});
};
// 内容稿件审核-AI审查(写手)
export const postWorkAuditsAiReviewWriter = (writerCode: string, id: string, data: any) => {
return Http.post(`/v1/writer/work-audits/${id}/ai-review`, data, {
headers: { 'writer-code': writerCode },
export const postWorkAuditsAiReviewWriter = (params = {}) => {
const { id: auditId, ...rest } = params as { id: string; [key: string]: any };
return Http.post(`/v1/writer/work-audits/${auditId}/ai-review`, rest, {
headers: { 'writer-code': getWriterCode() },
});
};
// 内容稿件审核-获取AI审查结果(写手)
export const getWorkAuditsAiReviewResultWriter = (writerCode: string, id: string, ticket: string) => {
export const getWorkAuditsAiReviewResultWriter = (id: string, ticket: string) => {
return Http.get(`/v1/writer/work-audits/${id}/ai-review/${ticket}`, {
headers: { 'writer-code': writerCode },
headers: { 'writer-code': getWriterCode() },
});
};

View File

@ -95,8 +95,8 @@ export const putWorkAuditsAuditPass = (params = {}) => {
// 内容稿件审核-AI审查
export const postWorkAuditsAiReview = (params = {}) => {
const { id, ...rest } = params as { id: string; [key: string]: any };
return Http.post(`/v1/work-audits/${id}/ai-review`, rest);
const { id, ...rest } = params as { id: string; [key: string]: any };
return Http.post(`/v1/work-audits/${id}/ai-review`, rest);
};
// 内容稿件审核-获取AI审查结果
@ -128,9 +128,13 @@ export const getShareWorksDetail = (id: string, shareCode: string) => {
// 内容稿件-确认(客户)
export const patchShareWorksConfirm = (id: string, shareCode: string) => {
return Http.patch(`/v1/share/works/${id}/confirm`, {}, {
headers: { 'share-code': shareCode },
});
return Http.patch(
`/v1/share/works/${id}/confirm`,
{},
{
headers: { 'share-code': shareCode },
},
);
};
// 内容稿件-评论(客户)
@ -146,4 +150,3 @@ export const deleteShareWorksComments = (id: string, commentId: string, shareCod
headers: { 'share-code': shareCode },
});
};

View File

@ -0,0 +1,134 @@
<!-- 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">
<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, AuditStatus } from '@/views/creative-generation-workshop/manuscript/check-list/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,195 @@
<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" :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="
['updated_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 }">
<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>
</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/creative-generation-workshop/manuscript/list/constants';
import { patchWorkAuditsAudit } from '@/api/all/generationWorkshop';
import {
AuditStatus,
CUSTOMER_OPINION,
PLATFORMS,
} from '@/views/creative-generation-workshop/manuscript/check-list/constants';
import { slsWithCatch } from '@/utils/stroage.ts';
import TextOverTips from '@/components/text-over-tips';
import ShareModal from '@/views/creative-generation-workshop/manuscript/components/share-manuscript-modal/share-modal.vue';
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(`/manuscript/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,254 @@
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: 'type',
width: 180,
},
{
title: '上传时间',
dataIndex: 'updated_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: 220,
},
{
title: '审核平台',
dataIndex: 'platform',
width: 120,
},
{
title: '合规程度',
dataIndex: 'compliance_level',
suffix: '%',
width: 120,
},
{
title: '稿件类型',
dataIndex: 'type',
width: 180,
},
{
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: 200,
},
{
title: '审核平台',
dataIndex: 'platform',
width: 120,
},
{
title: '稿件类型',
dataIndex: 'type',
width: 180,
},
{
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 enum AuditStatus {
Pending = '1',
Auditing = '2',
Passed = '3',
}
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/media-account/icon-dy.png';
import icon2 from '@/assets/img/media-account/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,205 @@
<template>
<div class="manuscript-check-wrap">
<div class="filter-wrap bg-#fff rounded-8px border-1px border-#D7D7D9 border-solid mb-16px">
<a-tabs v-model="query.audit_status" @tab-click="handleTabClick">
<a-tab-pane :title="item.label" v-for="item in AUDIT_STATUS_LIST" :key="item.value"></a-tab-pane>
<template #extra>
<a-button type="outline" size="medium" @click="handleShareModal">分享内容稿件</a-button>
</template>
</a-tabs>
<FilterBlock
v-model:query="query"
:audit_status="query.audit_status"
@search="handleSearch"
@reset="handleReset"
/>
</div>
<div
class="table-wrap bg-#fff rounded-8px border-1px border-#D7D7D9 border-solid px-24px py-24px flex-1 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-box">
<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" />
<ShareManuscriptModal ref="shareManuscriptModalRef" />
</div>
</template>
<script lang="jsx" setup>
import { defineComponent } from 'vue';
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 ShareManuscriptModal from '@/views/creative-generation-workshop/manuscript/components/share-manuscript-modal';
import { getWorkAuditsPage, patchWorkAuditsBatchAudit } from '@/api/all/generationWorkshop.ts';
import { useTableSelectionWithPagination } from '@/hooks/useTableSelectionWithPagination';
import { slsWithCatch } from '@/utils/stroage.ts';
// import { getProjects } from '@/api/all/propertyMarketing';
import {
AuditStatus,
INITIAL_QUERY,
AUDIT_STATUS_LIST,
TABLE_COLUMNS1,
TABLE_COLUMNS2,
TABLE_COLUMNS3,
} from '@/views/creative-generation-workshop/manuscript/check-list/constants';
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 shareManuscriptModalRef = 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();
query.value = cloneDeep(INITIAL_QUERY);
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 handleShareModal = () => {
shareManuscriptModalRef.value.open();
};
const handleDelete = (item) => {
const { id, title } = item;
deleteManuscriptModalRef.value?.open({ id, name: `${title}` });
};
const handleEdit = (item) => {
// addManuscriptModalRef.value?.open(item.id);
};
onMounted(() => {
tableColumns.value = TABLE_COLUMNS1;
getData();
});
provide('update', getData);
</script>
<style scoped lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,42 @@
.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;
.pagination-box {
display: flex;
width: 100%;
padding: 16px 24px;
justify-content: flex-end;
align-items: center;
}
}
}

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: 'ManuscriptCheckList' });
} 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: 'ManuscriptCheckList' });
}
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,78 @@
<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 {
setup(props, { emit, expose }) {
const visible = ref(false);
const dataSource = ref([]);
const selectCardInfo = ref({});
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}
>
<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">{`${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
class={`card-item flex rounded-8px bg-#F7F8FA p-8px ${
selectCardInfo.value.id === item.id ? 'active' : ''
}`}
key={item.id}
>
<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">{`合规程度:${90}%`}</p>
</div>
</div>
))}
</div>
</Drawer>
);
},
};
</script>
<style lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,31 @@
.check-list-drawer-xt {
.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 {
border: 1px solid transparent;
&:not(:last-child) {
margin-bottom: 12px;
}
&.active {
border-color: #6d4cfe;
background-color: #f0edff;
}
}
}
}

View File

@ -0,0 +1,53 @@
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_items',
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,443 @@
<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 'swiper/css';
import 'swiper/css/navigation';
import { Navigation } from 'swiper/modules';
import { FORM_RULES, enumTab, TAB_LIST, RESULT_LIST, LEVEL_MAP } from './constants';
import { getImagePreSignedUrl } from '@/api/all/common';
import { EnumManuscriptType } from '@/views/creative-generation-workshop/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,
},
},
emits: ['update:modelValue', 'filesChange', 'selectImage', 'againCheck', 'startCheck'],
setup(props, { emit, expose }) {
const activeTab = ref(enumTab.TEXT);
const aiCheckLoading = 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 onAiReplace = () => {
if (aiCheckLoading.value) return;
aiCheckLoading.value = true;
setTimeout(() => {
aiCheckLoading.value = false;
}, 2000);
};
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?.();
aiCheckLoading.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={props.checkLoading}>
再次审核
</Button>
{isTextTab.value ? (
<Button size="medium" type="outline" class="w-123px" onClick={onAiReplace} disabled={props.checkLoading}>
{aiCheckLoading.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={props.checkLoading}
/>
</FormItem>
<FormItem label="作品描述" field="content" class="flex-1 content-form-item">
<Textarea
v-model={props.modelValue.content}
placeholder="请输入作品描述"
size="large"
show-word-limit
maxLength={1000}
show-word-limit
disabled={props.checkLoading}
/>
</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 renderRightBox = () => {
if (props.checkLoading) {
return (
<div class="right-box flex-1 h-210px rounded-8px border-1px border-#E6E6E8 border-solid p-16px flex flex-col overflow-y-auto">
<p class="cts bold !text-16px !lh-24px !color-#211F24 mb-16px">审核结果</p>
<Spin loading={true} tip={`${isTextTab.value ? '文本' : '图片'}检测中`} size={72} class="" />
</div>
);
} else {
if (!aiReview.value?.violation_items?.length) {
return (
<div class="right-box flex-1 h-372px rounded-8px border-1px border-#E6E6E8 border-solid p-16px flex flex-col overflow-y-auto">
<p class="cts bold !text-16px !lh-24px !color-#211F24 mb-16px">审核结果</p>
<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">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="right-box flex-1 rounded-8px border-1px border-#E6E6E8 border-solid p-16px flex flex-col overflow-y-auto">
<p class="cts bold !text-16px !lh-24px !color-#211F24 mb-16px">审核结果</p>
<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">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>
</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,190 @@
.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-text {
background: linear-gradient(84deg, #266cff 4.57%, #a15af0 84.93%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.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 {
.s1 {
font-family: $font-family-manrope-regular;
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/creative-generation-workshop/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">{`合规程度:${90}%`}</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,246 @@
<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 {
patchWorkAuditsAudit,
patchWorkAuditsBatchAudit,
putWorkAuditsUpdate,
putWorkAuditsAuditPass,
getWorkAuditsDetail,
getWorkAuditsBatchDetail,
postWorkAuditsAiReview,
} from '@/api/all/generationWorkshop.ts';
export default {
setup(props, { emit, expose }) {
const router = useRouter();
const route = useRoute();
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 selectCardInfo = ref({});
const selectedImageInfo = ref(null);
const { handleStartCheck, handleAgainCheck, ticket, checkLoading } = useGetAiReviewResult({
cardInfo: selectCardInfo,
updateAiReview(ai_review) {
selectCardInfo.value.ai_review = ai_review;
},
});
const onBack = () => {
router.push({ name: 'ManuscriptCheckList' });
};
const onChangeCard = (item) => {
contentCardRef.value.reset();
isSaved.value = false;
submitLoading.value = false;
checkLoading.value = false;
ticket.value = '';
const { files = [], ai_review } = item;
selectCardInfo.value = cloneDeep(item);
selectedImageInfo.value = cloneDeep(files?.[0] ?? {});
console.log({ ai_review });
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 () => {
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();
}
}
};
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 mr-4px hover:color-#6D4CFE" />
<span class="cts !color-#211F24">审核列表</span>
</div>
)}
<div class="flex-1 flex flex-col overflow-hidden bg-#fff rounded-8px border-1px border-#D7D7D9 border-solid">
<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}
onAgainCheck={onAgainCheck}
onStartCheck={handleStartCheck}
/>
</section>
</div>
</div>
<footer class="flex justify-end items-center px-16px py-20px w-full bg-#fff footer-row">
{renderFooterRow()}
</footer>
<CancelCheckModal ref={cancelCheckModalRef} onSelectCard={onChangeCard} />
<CheckSuccessModal ref={checkSuccessModalRef} />
<CheckListDrawer ref={checkListDrawerRef} />
</>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,39 @@
$footer-height: 68px;
.manuscript-check-wrap {
width: 100%;
height: calc(100% - 72px);
.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);
}
}
.footer-row {
position: fixed;
bottom: 0;
left: $sidebar-width;
width: calc(100% - $sidebar-width);
border-top: 1px solid #e6e6e8;
height: $footer-height;
}

View File

@ -0,0 +1,415 @@
<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 { formatFileSize, getVideoInfo, formatDuration, formatUploadSpeed } from '@/utils/tools';
import { getProjectList } from '@/api/all/propertyMarketing';
import { EnumManuscriptType } from '@/views/creative-generation-workshop/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 renderImage = () => {
return (
<FormItem
field="files"
v-slots={{
label: () => (
<div class="flex items-center">
<span class="cts !color-#211F24 mr-4px">图片</span>
<span class="cts mr-8px !color-#939499">{`(${formData.value.files?.length ?? 0}/18)`}</span>
<span class="cts !color-#939499">第一张为首图支持拖拽排序</span>
</div>
),
}}
>
<div>
{/* 已上传的图片列表 */}
<VueDraggable v-model={formData.value.files} class="grid grid-cols-7 gap-y-8px">
{formData.value.files?.map((file, index) => (
<div key={index} 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"
onClick={() => handleDeleteFile(index)}
/>
</div>
</div>
))}
</VueDraggable>
{formData.value.files?.length < 18 && (
<Upload
ref={uploadRef}
action="/"
draggable
custom-request={uploadImage}
accept=".jpg,.jpeg,.png,.gif,.webp"
show-file-list={false}
multiple
limit={18 - formData.value.files?.length}
>
{{
'upload-button': () => (
<div class="upload-box">
<icon-plus size="14" class="mb-16px color-#3C4043" />
<span class="cts !color-#211F24">上传图片</span>
</div>
),
}}
</Upload>
)}
</div>
</FormItem>
);
};
// 暴露方法
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="h-300px !w-784px"
show-word-limit
max-length={1000}
auto-size={{ minRows: 7, maxRows: 12 }}
/>
</FormItem>
{isVideo.value ? renderVideo() : renderImage()}
{/* <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,32 @@
.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;
}
}
}

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,293 @@
<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/creative-generation-workshop/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/creative-generation-workshop/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">{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" 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"
class={
record.type === EnumManuscriptType.Image ? '!color-#25C883' : '!color-#6D4CFE'
}
>
{record.type === EnumManuscriptType.Image ? '图文' : '视频'}
</span>
</div>
);
} else if (column.dataIndex === 'last_modified_at') {
return exactFormatTime(
record.last_modified_at,
'YYYY-MM-DD HH:mm:ss',
'YYYY-MM-DD HH:mm:ss',
);
} 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,137 @@
<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
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">
<CommonSelect
v-model={formData.value.days}
options={OPTIONS}
multiple={false}
placeholder="请选择有效期"
size="large"
class="!w-240px"
allClear={false}
/>
</FormItem>
<FormItem label="分享对象" prop="receiver" required>
<Input v-model={formData.value.receiver} class="!w-240px" size="large" placeholder="请输入分享对象" />
</FormItem>
</Form>
</Modal>
);
},
};
</script>

View File

@ -0,0 +1,27 @@
.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;
}
}
.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;
}
}
}

View File

@ -0,0 +1,195 @@
<script lang="jsx">
import { Button, Message as AMessage, Spin } from '@arco-design/web-vue';
import { useRouter, useRoute } from 'vue-router';
import { AuditStatus } from '@/views/creative-generation-workshop/manuscript/check-list/constants';
import { getWorksDetail } from '@/api/all/generationWorkshop';
import { EnumManuscriptType } from '@/views/creative-generation-workshop/manuscript/list/constants.ts';
import { convertVideoUrlToCoverUrl, exactFormatTime } from '@/utils/tools.ts';
import { slsWithCatch } from '@/utils/stroage.ts';
const DEFAULT_SOURCE_INFO = {
title: '内容稿件列表',
routeName: 'ManuscriptList',
};
const SOURCE_MAP = new Map([['check', { title: '内容稿件审核', routeName: 'ManuscriptCheckList' }]]);
export default {
setup(props, { emit, expose }) {
const router = useRouter();
const route = useRoute();
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 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 relative overflow-hidden cursor-pointer">
<img src={coverImageUrl.value} class="w-100% h-100% object-contain absolute z-0 top-0 left-0" />
</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 isPassed = audit_status === AuditStatus.Passed;
const _fn = () => {
if (isPassed) {
console.log('审核详情');
} else {
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(`/manuscript/edit/${workId.value}`)}
>
编辑
</Button>
<Button type="primary" size="medium" onClick={_fn}>
{isPassed ? '审核详情' : '去审核'}
</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 overflow-y-auto bg-#fff rounded-8px border-1px border-#D7D7D9 border-solid 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 ">{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>
<div class="flex justify-end items-center px-16px py-20px w-full bg-#fff footer-row">{renderFooterRow()}</div>
</Spin>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,65 @@
$footer-height: 68px;
.manuscript-detail-wrap {
width: 100%;
height: calc(100% - 72px);
.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: auto;
background: #fff;
}
.main-img-box {
width: 320px;
height: auto;
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);
border-top: 1px solid #e6e6e8;
height: $footer-height;
}

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: 'ManuscriptList' });
};
const open = () => {
visible.value = true;
};
defineExpose({ open });
</script>

View File

@ -0,0 +1,130 @@
<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 { getWorksDetail, putWorksUpdate } from '@/api/all/generationWorkshop';
import { EnumManuscriptType } from '@/views/creative-generation-workshop/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 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 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: 'ManuscriptList' });
};
onMounted(() => {
workId && getData();
});
return () => (
<>
<div class="manuscript-edit-wrap">
<div class="flex items-center mb-8px">
<span class="cts color-#4E5969 cursor-pointer" onClick={onBack}>
内容稿件列表
</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 border-1px border-#D7D7D9 border-solid">
<EditForm
ref={formRef}
formData={dataSource.value}
onChange={onChange}
onUpdateVideoInfo={onUpdateVideoInfo}
/>
</div>
</div>
<div class="flex justify-end items-center px-16px py-20px w-full bg-#fff footer-row">
<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>
</div>
<CancelEditModal ref={cancelEditModal} />
</>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,25 @@
$footer-height: 68px;
.manuscript-edit-wrap {
height: calc(100% - 72px);
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);
border-top: 1px solid #e6e6e8;
height: $footer-height;
}

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-166px"
: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/creative-generation-workshop/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,68 @@
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: 'projects',
// width: 240,
// },
{
title: '稿件类型',
dataIndex: 'type',
width: 180,
},
{
title: '审核状态',
dataIndex: 'audit_status',
width: 180,
},
{
title: '上传时间',
dataIndex: 'updated_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,140 @@
<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
@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 === '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="['updated_at', 'last_modified_at'].includes(column.dataIndex)" #cell="{ record }">
{{ exactFormatTime(record[column.dataIndex]) }}
</template>
<template v-else-if="column.dataIndex === 'cover'" #cell="{ record }">
<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>
</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/creative-generation-workshop/manuscript/list/constants';
import TextOverTips from '@/components/text-over-tips';
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(`/manuscript/edit/${item.id}`);
};
const onDetail = (item) => {
router.push(`/manuscript/detail/${item.id}`);
};
const getStatusInfo = (audit_status) => {
return CHECK_STATUS.find((v) => v.id === audit_status) ?? {};
};
</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,394 @@
<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 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 works = ref([]);
// 剪贴板功能
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 = [];
};
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);
console.log('getWriterLinksGenerate -----writer-code---->', form.value.writerLink);
}
};
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;
}
}
});
}, 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) => {
try {
taskStatus.value = TASK_STATUS.LOADING;
const {
fileItem: { file },
} = option;
const formData = new FormData();
formData.append('file', file);
const { code, data } = await postWorksByFile(formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (code === 200) {
taskStatus.value = TASK_STATUS.SUCCESS;
works.value = data;
}
} finally {
taskStatus.value = TASK_STATUS.DEFAULT;
}
};
// 跳转编辑
const goUpload = () => {
router.push(`/manuscript/upload/1`);
onClose();
};
// 删除项目
const onDelete = (index) => {
works.value.splice(index, 1);
// AMessage.success('删除成功');
};
// 上传方式切换
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
customRequest={handleUpload}
accept=".xlsx,.xls,.docx,.doc"
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" 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,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,121 @@
<template>
<div class="manuscript-list-wrap">
<div class="filter-wrap bg-#fff rounded-8px border-1px border-#D7D7D9 border-solid mb-16px">
<div class="top flex h-64px px-24px py-10px justify-between items-center">
<p class="text-18px font-400 lh-26px color-#211F24 title">内容稿件列表</p>
<div class="flex items-center">
<a-button type="outline" size="medium" class="mr-12px" @click="handleShareModal">
分享内容稿件
</a-button>
<a-button type="primary" size="medium" @click="openUploadModal">
<template #icon>
<icon-plus size="16" />
</template>
<template #default>上传内容稿件</template>
</a-button>
</div>
</div>
<FilterBlock v-model:query="query" @search="handleSearch" @reset="handleReset" />
</div>
<div
class="table-wrap bg-#fff rounded-8px border-1px border-#D7D7D9 border-solid px-24px py-24px flex-1 flex flex-col"
>
<ManuscriptTable :dataSource="dataSource" @sorterChange="handleSorterChange" @delete="handleDelete" />
<div v-if="pageInfo.total > 0" class="pagination-box">
<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" />
<UploadManuscriptModal ref="uploadManuscriptModalRef" />
<ShareManuscriptModal ref="shareManuscriptModalRef" />
</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 UploadManuscriptModal from './components/upload-manuscript-modal';
import ShareManuscriptModal from '@/views/creative-generation-workshop/manuscript/components/share-manuscript-modal';
import { useTableSelectionWithPagination } from '@/hooks/useTableSelectionWithPagination';
import { getWorksPage } from '@/api/all/generationWorkshop.ts';
import { INITIAL_QUERY, EnumCheckStatus } from '@/views/creative-generation-workshop/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 uploadManuscriptModalRef = ref(null);
const shareManuscriptModalRef = 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 handleShareModal = () => {
shareManuscriptModalRef.value.open()
};
const openUploadModal = () => {
uploadManuscriptModalRef.value?.open();
};
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,29 @@
.manuscript-list-wrap {
height: 100%;
display: flex;
flex-direction: column;
.filter-wrap {
.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;
.pagination-box {
display: flex;
width: 100%;
padding: 16px 24px;
justify-content: flex-end;
align-items: center;
}
}
}

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: 'ManuscriptList' });
};
const open = (manusNum) => {
num.value = manusNum;
visible.value = true;
};
defineExpose({ open });
</script>

View File

@ -0,0 +1,275 @@
<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/creative-generation-workshop/manuscript/list/constants';
import { postWorksBatch } from '@/api/all/generationWorkshop.ts';
import icon1 from '@/assets/img/creative-generation-workshop/icon-photo.png';
import icon2 from '@/assets/img/creative-generation-workshop/icon-video.png';
const generateMockData = (count = 4) =>
Array.from({ length: count }, (_, i) => ({
id: `${i + 1}`,
title: `标题${i + 1}`,
content: '挖到宝了!这个平价好物让我素颜出门都自信✨挖到宝了!这个平价好物让我素颜出门都自信✨',
type: i % 2 === 0 ? EnumManuscriptType.Image : EnumManuscriptType.Video,
files: [],
videoInfo: cloneDeep(INITIAL_VIDEO_INFO),
}));
export default {
components: {
EditForm,
},
setup(props, { emit, expose }) {
const formRef = ref(null);
const route = useRoute();
const router = useRouter();
const cancelUploadModal = ref(null);
const uploadSuccessModal = ref(null);
const works = ref([]);
const selectCardInfo = ref({});
const errorDataCards = ref([]);
const uploadLoading = ref(false);
const workId = route.params.id;
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 (check) => {
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 (check) {
uploadSuccessModal.value?.open(workId);
} else {
router.push({ name: 'ManuscriptList' });
}
}
};
const onUpload = async (e, check = false) => {
console.log('onUpload', works.value);
formRef.value
?.validate()
.then(() => {
return validateDataSource();
})
.then(() => {
onSubmit(check);
})
.catch((err) => {
console.log('catch');
errorDataCards.value.push(err);
});
};
const onUploadAndCheck = (e) => {
onUpload(e, true);
};
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 = {};
}
}
};
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} 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} class="mr-12px" loading={uploadLoading.value}>
上传
</Button>
<Button type="primary" size="medium" onClick={onUploadAndCheck} loading={uploadLoading.value}>
上传并审核
</Button>
</>
);
}
};
const getData = () => {
works.value = generateMockData();
selectCardInfo.value = cloneDeep(works.value[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;
};
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 };
};
onMounted(() => {
getData();
});
return () => (
<>
<div class="manuscript-upload-wrap bg-#fff rounded-8px border-1px border-#D7D7D9 border-solid 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 relative mb-12px px-8px py-12px 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.content}
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="16"
height="16"
class="mr-4px"
/>
<span
class={`cts ${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 !color-#F64B31">必填项未填</p>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
<div class="flex justify-end items-center px-16px py-20px w-full bg-#fff footer-row">{renderFooterRow()}</div>
<CancelUploadModal ref={cancelUploadModal} />
<UploadSuccessModal ref={uploadSuccessModal} />
</>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,28 @@
$footer-height: 68px;
.manuscript-upload-wrap {
height: calc(100% - 72px);
.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;
}
}
.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);
border-top: 1px solid #e6e6e8;
height: $footer-height;
}

View File

@ -0,0 +1,65 @@
<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 workId = ref('');
const onClose = () => {
workId.value = '';
visible.value = false;
};
const onBack = () => {
onClose();
router.push({ name: 'ManuscriptList' });
};
const onConfirm = () => {
visible.value = false;
slsWithCatch('manuscriptCheckIds', [workId.value]);
router.push({ name: 'ManuscriptCheck' });
};
const open = (id) => {
console.log({ id });
workId.value = id;
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>