feat: 素材中心成品库-写手端处理

This commit is contained in:
rd
2025-08-23 15:04:24 +08:00
parent 786d36ab0d
commit 23becf5884
57 changed files with 5614 additions and 141 deletions

View File

@ -1,3 +1,5 @@
import { GROUP_WRITER_NAME } from '@/router/routes/modules/materialCenter-writer';
export interface typeMenuItem { export interface typeMenuItem {
key?: string | number; // 菜单组key key?: string | number; // 菜单组key
label?: string; // 菜单组标题 label?: string; // 菜单组标题
@ -103,28 +105,35 @@ export const MENU_LIST = <Record<string, typeMenuItem[]>>{
routeName: 'TaskManagement', routeName: 'TaskManagement',
}, },
], ],
GroupMainWriter: [ [GROUP_WRITER_NAME]: [
{ {
key: 'ModMaterialCenter', key: 'ModWriterMaterialCenter',
label: '素材中心', label: '素材中心',
icon: 'svg-materialCenter', icon: 'svg-materialCenter',
children: [ children: [
{ {
key: 'ModMediaFinishProductsWareHouseWriter', key: 'ModWriterMaterialCenterFinishedProductsWareHouse',
icon: 'svg-finishProductsWareHouseWriter', icon: 'svg-finishProductsWareHouse',
label: '成品库', label: '成品库',
routeName: 'FinishProductsWareHouseWriter', routeName: 'WriterMaterialCenterFinishedProducts',
requireLogin: true, requireLogin: true,
activeMatch: ['FinishProductsWareHouseWriter', 'FinishProductsWareHouseWriter'], activeMatch: [
}, 'WriterMaterialCenterFinishedProducts',
{ 'WriterManuscriptUpload',
key: 'ModMediaRawMaterialStorageWriter', 'WriterManuscriptEdit',
icon: 'svg-rawMaterialStorageWriter', 'WriterManuscriptDetail',
label: '原料库', 'WriterManuscriptCheckListDetail',
routeName: 'RawMaterialStorageWriter', 'WriterManuscriptCheck',
requireLogin: true, ],
activeMatch: ['RawMaterialStorageWriter', 'RawMaterialStorageWriter'],
}, },
// {
// key: 'ModWriterMaterialCenterRawMaterialStorage',
// icon: 'svg-rawMaterialStorage',
// label: '原料库',
// routeName: 'WriterMaterialCenterRawMaterial',
// requireLogin: true,
// activeMatch: ['WriterMaterialCenterRawMaterial'],
// },
], ],
}, },
], ],

View File

@ -1,115 +1,115 @@
import type { AppRouteRecordRaw } from '../types'; // import type { AppRouteRecordRaw } from '../types';
import IconContentManuscript from '@/assets/svg/svg-contentManuscript.svg'; // import IconContentManuscript from '@/assets/svg/svg-contentManuscript.svg';
import { MENU_GROUP_IDS } from '@/router/constants'; // import { MENU_GROUP_IDS } from '@/router/constants';
// 内容稿件-写手端 // // 内容稿件-写手端
const COMPONENTS: AppRouteRecordRaw[] = [ // const COMPONENTS: AppRouteRecordRaw[] = [
{ // {
path: '/writer/manuscript', // path: '/writer/manuscript',
name: 'WriterManuscript', // name: 'WriterManuscript',
redirect: 'writer/manuscript/list', // redirect: 'writer/manuscript/list',
meta: { // meta: {
locale: '内容稿件', // locale: '内容稿件',
icon: IconContentManuscript, // icon: IconContentManuscript,
requiresAuth: false, // requiresAuth: false,
requireLogin: false, // requireLogin: false,
roles: ['*'], // roles: ['*'],
id: MENU_GROUP_IDS.WRITER_CREATIVE_GENERATION_WORKSHOP_ID, // id: MENU_GROUP_IDS.WRITER_CREATIVE_GENERATION_WORKSHOP_ID,
}, // },
children: [ // children: [
{ // {
path: 'list/:writerCode', // path: 'list/:writerCode',
name: 'WriterManuscriptList', // name: 'WriterManuscriptList',
meta: { // meta: {
locale: '内容稿件列表', // locale: '内容稿件列表',
requiresAuth: false, // requiresAuth: false,
requireLogin: false, // requireLogin: false,
roles: ['*'], // roles: ['*'],
}, // },
component: () => import('@/views/creative-generation-workshop/manuscript-writer/list/index.vue'), // component: () => import('@/views/creative-generation-workshop/manuscript-writer/list/index.vue'),
}, // },
{ // {
path: 'upload/:writerCode', // path: 'upload/:writerCode',
name: 'WriterManuscriptUpload', // name: 'WriterManuscriptUpload',
meta: { // meta: {
locale: '稿件上传', // locale: '稿件上传',
requiresAuth: false, // requiresAuth: false,
requireLogin: false, // requireLogin: false,
hideFooter: true, // hideFooter: true,
roles: ['*'], // roles: ['*'],
hideInMenu: true, // hideInMenu: true,
activeMenu: 'WriterManuscriptList', // activeMenu: 'WriterManuscriptList',
}, // },
component: () => import('@/views/creative-generation-workshop/manuscript-writer/upload/index.vue'), // component: () => import('@/views/creative-generation-workshop/manuscript-writer/upload/index.vue'),
}, // },
{ // {
path: 'edit/:writerCode/:id', // path: 'edit/:writerCode/:id',
name: 'WriterManuscriptEdit', // name: 'WriterManuscriptEdit',
meta: { // meta: {
locale: '账号详情', // locale: '账号详情',
requiresAuth: false, // requiresAuth: false,
requireLogin: false, // requireLogin: false,
hideFooter: true, // hideFooter: true,
roles: ['*'], // roles: ['*'],
hideInMenu: true, // hideInMenu: true,
activeMenu: 'WriterManuscriptList', // activeMenu: 'WriterManuscriptList',
}, // },
component: () => import('@/views/creative-generation-workshop/manuscript-writer/edit/index.vue'), // component: () => import('@/views/creative-generation-workshop/manuscript-writer/edit/index.vue'),
}, // },
{ // {
path: 'detail/:writerCode/:id', // path: 'detail/:writerCode/:id',
name: 'WriterManuscriptDetail', // name: 'WriterManuscriptDetail',
meta: { // meta: {
locale: '稿件详情', // locale: '稿件详情',
requiresAuth: false, // requiresAuth: false,
requireLogin: false, // requireLogin: false,
hideFooter: true, // hideFooter: true,
roles: ['*'], // roles: ['*'],
hideInMenu: true, // hideInMenu: true,
activeMenu: 'ManuscriptList', // activeMenu: 'ManuscriptList',
}, // },
component: () => import('@/views/creative-generation-workshop/manuscript-writer/detail/index.vue'), // component: () => import('@/views/creative-generation-workshop/manuscript-writer/detail/index.vue'),
}, // },
{ // {
path: 'check-list/:writerCode', // path: 'check-list/:writerCode',
name: 'WriterManuscriptCheckList', // name: 'WriterManuscriptCheckList',
meta: { // meta: {
locale: '内容稿件审核', // locale: '内容稿件审核',
requiresAuth: false, // requiresAuth: false,
requireLogin: false, // requireLogin: false,
roles: ['*'], // roles: ['*'],
}, // },
component: () => import('@/views/creative-generation-workshop/manuscript-writer/check-list/index.vue'), // component: () => import('@/views/creative-generation-workshop/manuscript-writer/check-list/index.vue'),
}, // },
{ // {
path: 'check-list/detail/:id/:writerCode', // path: 'check-list/detail/:id/:writerCode',
name: 'WriterManuscriptCheckListDetail', // name: 'WriterManuscriptCheckListDetail',
meta: { // meta: {
locale: '内容稿件审核详情', // locale: '内容稿件审核详情',
requiresAuth: false, // requiresAuth: false,
requireLogin: false, // requireLogin: false,
hideFooter: true, // hideFooter: true,
hideInMenu: true, // hideInMenu: true,
roles: ['*'], // roles: ['*'],
activeMenu: 'WriterManuscriptCheckList', // activeMenu: 'WriterManuscriptCheckList',
}, // },
component: () => import('@/views/creative-generation-workshop/manuscript-writer/detail/index.vue'), // component: () => import('@/views/creative-generation-workshop/manuscript-writer/detail/index.vue'),
}, // },
{ // {
path: 'check/:writerCode', // path: 'check/:writerCode',
name: 'WriterManuscriptCheck', // name: 'WriterManuscriptCheck',
meta: { // meta: {
locale: '稿件审核', // locale: '稿件审核',
requiresAuth: false, // requiresAuth: false,
requireLogin: false, // requireLogin: false,
hideFooter: true, // hideFooter: true,
roles: ['*'], // roles: ['*'],
hideInMenu: true, // hideInMenu: true,
activeMenu: 'WriterManuscriptCheckList', // activeMenu: 'WriterManuscriptCheckList',
}, // },
component: () => import('@/views/creative-generation-workshop/manuscript-writer/check/index.vue'), // component: () => import('@/views/creative-generation-workshop/manuscript-writer/check/index.vue'),
}, // },
], // ],
}, // },
]; // ];
export default COMPONENTS; // export default COMPONENTS;

View File

@ -0,0 +1,121 @@
import type { AppRouteRecordRaw } from '../types';
import { MENU_GROUP_IDS } from '@/router/constants';
import IconContentManuscript from '@/assets/svg/svg-contentManuscript.svg';
export const GROUP_WRITER_NAME = 'GroupWriterMaterialCenter';
const COMPONENTS: AppRouteRecordRaw[] = [
{
path: '/writer/material-center',
name: 'WriterMaterialCenter',
redirect: '/writer/material-center/finished-products',
meta: {
locale: '素材中心',
icon: IconContentManuscript,
requiresAuth: true,
requireLogin: true,
roles: ['*'],
group: GROUP_WRITER_NAME,
id: MENU_GROUP_IDS.CREATIVE_GENERATION_WORKSHOP_ID,
},
children: [
{
path: 'finished-products/:writerCode',
name: 'WriterMaterialCenterFinishedProducts',
meta: {
locale: '成品库',
requiresAuth: true,
requireLogin: true,
roles: ['*'],
group: GROUP_WRITER_NAME,
},
component: () => import('@/views/writer-material-center/index.vue'),
},
{
path: 'raw-material/:writerCode',
name: 'WriterMaterialCenterRawMaterial',
meta: {
locale: '原料库',
requiresAuth: true,
requireLogin: true,
roles: ['*'],
group: GROUP_WRITER_NAME,
},
component: () => import('@/views/writer-material-center/index.vue'),
},
{
path: 'upload/:writerCode',
name: 'WriterManuscriptUpload',
meta: {
locale: '稿件上传',
requiresAuth: false,
requireLogin: false,
hideFooter: true,
roles: ['*'],
hideInMenu: true,
activeMenu: 'WriterMaterialCenterFinishedProducts',
},
component: () => import('@/views/writer-material-center/components/finished-products/manuscript/upload/index.vue'),
},
{
path: 'edit/:writerCode/:id',
name: 'WriterManuscriptEdit',
meta: {
locale: '稿件编辑',
requiresAuth: false,
requireLogin: false,
hideFooter: true,
roles: ['*'],
hideInMenu: true,
activeMenu: 'WriterMaterialCenterFinishedProducts',
},
component: () => import('@/views/writer-material-center/components/finished-products/manuscript/edit/index.vue'),
},
{
path: 'detail/:writerCode/:id',
name: 'WriterManuscriptDetail',
meta: {
locale: '稿件详情',
requiresAuth: false,
requireLogin: false,
hideFooter: true,
roles: ['*'],
hideInMenu: true,
activeMenu: 'WriterMaterialCenterFinishedProducts',
},
component: () => import('@/views/writer-material-center/components/finished-products/manuscript/detail/index.vue'),
},
{
path: 'check-list/detail/:id/:writerCode',
name: 'WriterManuscriptCheckListDetail',
meta: {
locale: '内容稿件审核详情',
requiresAuth: false,
requireLogin: false,
hideFooter: true,
hideInMenu: true,
roles: ['*'],
activeMenu: 'WriterMaterialCenterFinishedProducts',
},
component: () => import('@/views/writer-material-center/components/finished-products/manuscript/detail/index.vue'),
},
{
path: 'check/:writerCode',
name: 'WriterManuscriptCheck',
meta: {
locale: '稿件审核',
requiresAuth: false,
requireLogin: false,
hideFooter: true,
roles: ['*'],
hideInMenu: true,
activeMenu: 'WriterMaterialCenterFinishedProducts',
},
component: () => import('@/views/writer-material-center/components/finished-products/manuscript/check/index.vue'),
},
],
},
];
export default COMPONENTS;

View File

@ -57,7 +57,7 @@ const COMPONENTS: AppRouteRecordRaw[] = [
path: 'edit/:id', path: 'edit/:id',
name: 'ManuscriptEdit', name: 'ManuscriptEdit',
meta: { meta: {
locale: '账号详情', locale: '稿件编辑',
requiresAuth: true, requiresAuth: true,
requireLogin: true, requireLogin: true,
hideFooter: true, hideFooter: true,

View File

@ -18,5 +18,6 @@ declare module 'vue-router' {
isAgentRoute?: boolean; isAgentRoute?: boolean;
requireLogin?: boolean; // 是否需要登陆才能访问 requireLogin?: boolean; // 是否需要登陆才能访问
independent?: boolean; // 独立于layout的路由 independent?: boolean; // 独立于layout的路由
group?: string; // 路由分组
} }
} }

View File

@ -148,7 +148,7 @@ import icon2 from '@/assets/img/creative-generation-workshop/icon-photo.png';
import icon3 from '@/assets/img/creative-generation-workshop/icon-video.png'; import icon3 from '@/assets/img/creative-generation-workshop/icon-video.png';
import icon4 from '@/assets/img/error-img.png'; import icon4 from '@/assets/img/error-img.png';
const emits = defineEmits(['edit', 'sorterChange', 'delete', 'select', 'selectAll']); const emits = defineEmits([ 'sorterChange', 'delete', 'select', 'selectAll']);
const router = useRouter(); const router = useRouter();
const props = defineProps({ const props = defineProps({

View File

@ -40,7 +40,6 @@
:audit_status="query.audit_status" :audit_status="query.audit_status"
@sorterChange="handleSorterChange" @sorterChange="handleSorterChange"
@delete="handleDelete" @delete="handleDelete"
@edit="handleEdit"
@select="handleSelect" @select="handleSelect"
@selectAll="handleSelectAll" @selectAll="handleSelectAll"
/> />
@ -175,23 +174,15 @@ const handleDelete = (item) => {
const { id, title } = item; const { id, title } = item;
deleteManuscriptModalRef.value?.open({ id, name: `${title}` }); deleteManuscriptModalRef.value?.open({ id, name: `${title}` });
}; };
const handleEdit = (item) => {
// addManuscriptModalRef.value?.open(item.id);
};
watch( watch(
() => props.audit_status, () => props.audit_status,
(newVal) => { (newVal) => {
console.log('newVal', newVal)
handleTabClick(newVal); handleTabClick(newVal);
}, },
{ immediate: false }, { immediate: true, deep: true },
); );
onMounted(() => {
tableColumns.value = TABLE_COLUMNS1;
getData();
});
provide('update', getData); provide('update', getData);
</script> </script>

View File

@ -97,7 +97,7 @@ export default {
const { code, data } = await getWriterLinksGenerate(); const { code, data } = await getWriterLinksGenerate();
if (code === 200) { if (code === 200) {
const url = router.resolve({ const url = router.resolve({
path: `/writer/manuscript/list/${data.code}`, path: `/writer/material-center/finished-products/${data.code}`,
}).href; }).href;
form.value.writerLink = generateFullUrl(url); form.value.writerLink = generateFullUrl(url);
} }

View File

@ -0,0 +1,26 @@
export enum AuditStatus {
All = '0',
Pending = '1',
Auditing = '2',
Passed = '3',
}
export const TABS_LIST = [
{
label: '全部',
value: AuditStatus.All,
},
{
label: '待审核',
value: AuditStatus.Pending,
},
{
label: '审核中',
value: AuditStatus.Auditing,
},
{
label: '已通过',
value: AuditStatus.Passed,
},
];

View File

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

View File

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

View File

@ -0,0 +1,53 @@
<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 { deleteWorkWriter } from '@/api/all/generationWorkshop-writer.ts';
import icon1 from '@/assets/img/media-account/icon-warn-1.png';
const update = inject('update');
const route = useRoute();
const visible = ref(false);
const projectId = ref(null);
const projectName = ref('');
const isBatch = computed(() => Array.isArray(projectId.value));
const writerCode = computed(() => route.params.writerCode);
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 deleteWorkWriter(writerCode.value, projectId.value);
if (code === 200) {
AMessage.success('删除成功');
update();
onClose();
}
}
defineExpose({ open });
</script>

View File

@ -0,0 +1,202 @@
<template>
<a-table
ref="tableRef"
:data="dataSource"
row-key="id"
column-resizable
:pagination="false"
:scroll="{ x: '100%' }"
class="flex-1 manuscript-table w-100%"
bordered
:row-selection="rowSelection"
:selected-row-keys="selectedRowKeys"
@sorter-change="handleSorterChange"
@select="(selectedKeys, rowKeyValue, record) => emits('select', selectedKeys, rowKeyValue, record)"
@select-all="(check) => emits('selectAll', check)"
>
<template #empty>
<NoData text="暂无稿件" />
</template>
<template #columns>
<a-table-column
v-for="column in tableColumns"
:key="column.dataIndex"
:data-index="column.dataIndex"
:fixed="column.fixed"
:width="column.width"
:min-width="column.minWidth"
:sortable="column.sortable"
:align="column.align"
ellipsis
tooltip
>
<template #title>
<div class="flex items-center">
<span class="cts mr-4px">{{ column.title }}</span>
<a-tooltip v-if="column.tooltip" :content="column.tooltip" position="top">
<icon-question-circle class="tooltip-icon color-#737478" size="16" />
</a-tooltip>
</div>
</template>
<template v-if="column.dataIndex === 'customer_opinion'" #cell="{ record }">
<p
class="h-28px px-8px flex items-center rounded-2px w-fit"
:style="{ background: getCustomerOpinionInfo(record.customer_opinion)?.bg }"
>
<span class="cts" :class="getCustomerOpinionInfo(record.customer_opinion)?.color">{{
getCustomerOpinionInfo(record.customer_opinion)?.label ?? '-'
}}</span>
</p>
</template>
<template v-else-if="column.dataIndex === 'platform'" #cell="{ record }">
<template v-if="!PLATFORMS.find((item) => item.value === record.platform)"> - </template>
<img
v-else
width="24"
height="24"
class="rounded-4px"
:src="PLATFORMS.find((item) => item.value === record.platform)?.icon"
/>
</template>
<template v-else-if="column.dataIndex === 'compliance_level'" #cell="{ record }">
<span class="cts num !color-#6D4CFE">{{
record.ai_review?.compliance_level ? `${record.ai_review?.compliance_level}%` : '-'
}}</span>
</template>
<template v-else-if="column.dataIndex === 'title'" #cell="{ record }">
<TextOverTips :context="record.title" :line="3" class="title" @click="onDetail(record)" />
</template>
<template v-else-if="column.dataIndex === 'type'" #cell="{ record }">
<div class="flex items-center">
<img
:src="record.type === EnumManuscriptType.Image ? icon2 : icon3"
width="16"
height="16"
class="mr-4px"
/>
<span class="cts" :class="record.type === EnumManuscriptType.Image ? '!color-#25C883' : '!color-#6D4CFE'">{{
record.type === EnumManuscriptType.Image ? '图文' : '视频'
}}</span>
</div>
</template>
<template
#cell="{ record }"
v-else-if="
['created_at', 'last_modified_at', 'audit_started_at', 'audit_passed_at'].includes(column.dataIndex)
"
>
<span class="cts num">{{ exactFormatTime(record[column.dataIndex]) }}</span>
</template>
<template v-else-if="column.dataIndex === 'cover'" #cell="{ record }">
<HoverImagePreview :src="record.cover">
<a-image :width="64" :height="64" :src="record.cover" class="!rounded-6px" fit="cover">
<template #error>
<img :src="icon4" class="w-full h-full" />
</template>
</a-image>
</HoverImagePreview>
</template>
<template v-else-if="column.dataIndex === 'operation'" #cell="{ record }">
<div class="flex items-center">
<img class="mr-8px cursor-pointer" :src="icon1" width="14" height="14" @click="onDelete(record)" />
<a-button type="outline" size="mini" @click="onCheck(record)" v-if="audit_status === AuditStatus.Pending"
>审核</a-button
>
<a-button
type="outline"
size="mini"
@click="onScan(record)"
v-else-if="audit_status === AuditStatus.Auditing"
>查看</a-button
>
<a-button type="outline" size="mini" @click="onDetail(record)" v-else>详情</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 { EnumManuscriptType } from '@/views/writer-material-center/components/finished-products/manuscript/list/constants';
import { patchWorkAuditsAuditWriter } from '@/api/all/generationWorkshop-writer.ts';
import {
CUSTOMER_OPINION,
PLATFORMS,
} from '@/views/writer-material-center/components/finished-products/manuscript/check-list/constants';
import { AuditStatus } from '@/views/writer-material-center/components/finished-products/constants';
import { slsWithCatch } from '@/utils/stroage.ts';
import TextOverTips from '@/components/text-over-tips';
import HoverImagePreview from '@/components/hover-image-preview';
import icon1 from '@/assets/img/media-account/icon-delete.png';
import icon2 from '@/assets/img/creative-generation-workshop/icon-photo.png';
import icon3 from '@/assets/img/creative-generation-workshop/icon-video.png';
import icon4 from '@/assets/img/error-img.png';
const emits = defineEmits(['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 route = useRoute();
const tableRef = ref(null);
const writerCode = computed(() => route.params.writerCode);
const handleSorterChange = (column, order) => {
emits('sorterChange', column, order === 'ascend' ? 'asc' : 'desc');
};
const onDelete = (item) => {
emits('delete', item);
};
const onCheck = (item) => {
patchWorkAuditsAuditWriter(item.id, writerCode.value);
slsWithCatch('writerManuscriptCheckIds', [item.id]);
router.push({ name: 'WriterManuscriptCheck', params: { writerCode: writerCode.value } });
};
const onScan = (item) => {
slsWithCatch('writerManuscriptCheckIds', [item.id]);
router.push({ name: 'WriterManuscriptCheck', params: { writerCode: writerCode.value } });
};
const onDetail = (item) => {
router.push(
`/writer/material-center/check-list/detail/${item.id}/${writerCode.value}?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,246 @@
import { AuditStatus } from '@/views/writer-material-center/components/finished-products/constants';
export const TABLE_COLUMNS1 = [
{
title: '序号',
dataIndex: 'uid',
width: 120,
fixed: 'left',
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '图片/视频',
dataIndex: 'cover',
width: 120,
},
{
title: '内容稿件标题',
dataIndex: 'title',
width: 300,
},
// {
// title: '客户意见',
// dataIndex: 'customer_opinion',
// width: 120,
// },
{
title: '稿件类型',
dataIndex: 'type',
width: 120,
},
{
title: '上传时间',
dataIndex: 'created_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '最后修改时间',
dataIndex: 'last_modified_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '操作',
dataIndex: 'operation',
width: 120,
fixed: 'right',
},
];
export const TABLE_COLUMNS2 = [
{
title: '序号',
dataIndex: 'uid',
width: 120,
fixed: 'left',
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '图片/视频',
dataIndex: 'cover',
width: 120,
},
{
title: '内容稿件标题',
dataIndex: 'title',
width: 300,
},
// {
// title: '客户意见',
// dataIndex: 'customer_opinion',
// width: 120,
// },
{
title: '审核平台',
dataIndex: 'platform',
width: 120,
},
{
title: '合规程度',
dataIndex: 'compliance_level',
suffix: '%',
width: 120,
},
{
title: '稿件类型',
dataIndex: 'type',
width: 120,
},
{
title: '审核时间',
dataIndex: 'audit_started_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '最后修改时间',
dataIndex: 'last_modified_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
// {
// title: '修改人员',
// dataIndex: 'last_modifier',
// width: 180,
// },
{
title: '操作',
dataIndex: 'operation',
width: 120,
fixed: 'right',
},
];
export const TABLE_COLUMNS3 = [
{
title: '序号',
dataIndex: 'uid',
width: 120,
fixed: 'left',
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '图片/视频',
dataIndex: 'cover',
width: 120,
},
{
title: '内容稿件标题',
dataIndex: 'title',
width: 300,
},
// {
// title: '客户意见',
// dataIndex: 'customer_opinion',
// width: 120,
// },
{
title: '审核平台',
dataIndex: 'platform',
width: 120,
},
{
title: '稿件类型',
dataIndex: 'type',
width: 120,
},
{
title: '通过时间',
dataIndex: 'audit_passed_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '最后修改时间',
dataIndex: 'last_modified_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
// {
// title: '修改人员',
// dataIndex: 'last_modifier',
// width: 180,
// },
{
title: '操作',
dataIndex: 'operation',
width: 120,
fixed: 'right',
},
];
export const AUDIT_STATUS_LIST = [
{
label: '待审核',
value: AuditStatus.Pending,
tableColumns: TABLE_COLUMNS1,
},
{
label: '审核中',
value: AuditStatus.Auditing,
tableColumns: TABLE_COLUMNS2,
},
{
label: '已通过',
value: AuditStatus.Passed,
tableColumns: TABLE_COLUMNS3,
},
];
export const INITIAL_QUERY = {
audit_status: AuditStatus.Pending,
title: '',
created_at: [],
audit_started_at: [],
audit_platform: '',
sort_column: undefined,
sort_order: undefined,
};
import icon1 from '@/assets/img/platform/icon-dy.png';
import icon2 from '@/assets/img/platform/icon-xhs.png';
export const PLATFORMS = [
{
label: '小红书',
value: 1,
icon: icon2,
},
{
label: '抖音',
value: 2,
icon: icon1,
},
];
export const CUSTOMER_OPINION = [
{
label: '待确认',
value: 0,
bg: '#F2F3F5',
color: 'color-#3C4043',
},
{
label: '已确认',
value: 1,
bg: '#F0EDFF',
color: '!color-#6D4CFE',
},
];

View File

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

View File

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

View File

@ -0,0 +1,63 @@
<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 route = useRoute();
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: 'WriterMaterialCenterFinishedProducts',
params: {
writerCode: route.params.writerCode,
},
});
} 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,86 @@
<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, onUnmounted } from 'vue';
import icon1 from '@/assets/img/media-account/icon-feedback-success.png';
const router = useRouter();
const route = useRoute();
const visible = ref(false);
const workIds = ref([]);
let autoCloseTimer = null;
const onClose = () => {
if (autoCloseTimer) {
clearTimeout(autoCloseTimer);
autoCloseTimer = null;
}
if (workIds.value.length === 1) {
router.push({
name: 'WriterMaterialCenterFinishedProducts',
params: {
writerCode: route.params.writerCode,
},
});
}
workIds.value = [];
visible.value = false;
};
const open = (ids) => {
workIds.value = cloneDeep(ids);
visible.value = true;
if (autoCloseTimer) {
clearTimeout(autoCloseTimer);
autoCloseTimer = null;
}
autoCloseTimer = setTimeout(() => {
onClose();
}, 3000);
};
onUnmounted(() => {
if (autoCloseTimer) {
clearTimeout(autoCloseTimer);
autoCloseTimer = null;
}
});
defineExpose({ open });
</script>
<style lang="scss">
.upload-success11-modal {
.arco-modal-header {
border-bottom: none;
}
.md {
font-family: $font-family-medium;
}
.ld {
font-family: $font-family-regular;
}
.arco-modal-footer {
border-top: none;
justify-content: center;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,269 @@
<script lang="jsx">
import { Button, Message as AMessage, Spin } from '@arco-design/web-vue';
import CancelCheckModal from './cancel-check-modal.vue';
import CheckSuccessModal from './check-success-modal.vue';
import HeaderCard from './components/header-card';
import ContentCard from './components/content-card';
import CheckListDrawer from './components/check-list-drawer';
import { slsWithCatch, rlsWithCatch, glsWithCatch } from '@/utils/stroage.ts';
import useGetAiReviewResult from '@/hooks/useGetAiReviewResult.ts';
import { useSidebarStore } from '@/stores/modules/side-bar';
import {
getWorkAuditsBatchDetailWriter,
putWorkAuditsUpdateWriter,
postWorkAuditsAiReviewWriter,
getWorkAuditsAiReviewResultWriter,
putWorkAuditsAuditPassWriter,
} from '@/api/all/generationWorkshop-writer.ts';
export default {
setup(props, { emit, expose }) {
const router = useRouter();
const route = useRoute();
const sidebarStore = useSidebarStore();
const workIds = ref([]);
const isSaved = ref(false);
const dataSource = ref([]);
const remoteDataSource = ref([]);
const cancelCheckModalRef = ref(null);
const checkSuccessModalRef = ref(null);
const submitLoading = ref(false);
const contentCardRef = ref(null);
const checkListDrawerRef = ref(null);
const getDataLoading = ref(false);
const selectCardInfo = ref({});
const selectedImageInfo = ref(null);
const writerCode = computed(() => route.params.writerCode);
const collapsed = computed(() => {
return sidebarStore.menuCollapse;
});
const { handleStartCheck, handleAgainCheck, ticket, checkLoading, resetAiReviewInfo } = useGetAiReviewResult({
cardInfo: selectCardInfo,
startAiReviewFn: postWorkAuditsAiReviewWriter,
getAiReviewResultFn: getWorkAuditsAiReviewResultWriter,
updateAiReview(ai_review) {
selectCardInfo.value.ai_review = ai_review;
},
});
const onBack = () => {
router.push({
name: 'WriterMaterialCenterFinishedProducts',
params: {
writerCode: route.params.writerCode,
},
});
};
const onChangeCard = (item) => {
contentCardRef.value.reset();
isSaved.value = false;
submitLoading.value = false;
getDataLoading.value = false;
checkLoading.value = false;
resetAiReviewInfo();
const { files = [], ai_review } = item;
selectCardInfo.value = cloneDeep(item);
selectedImageInfo.value = cloneDeep(files?.[0] ?? {});
if (isEmpty(ai_review)) {
handleStartCheck();
}
};
const onCardClick = async (item) => {
const isModified = await isSelectCardModified();
if (isModified) {
cancelCheckModalRef.value?.open('toggle', item);
} else {
onChangeCard(item);
}
};
const onSelectImage = (item) => {
selectedImageInfo.value = cloneDeep(item);
};
const getWorkAudits = async () => {
try {
getDataLoading.value = true;
const { code, data } = await getWorkAuditsBatchDetailWriter(writerCode.value, { ids: workIds.value });
if (code === 200) {
const _data = (data ?? []).map((item) => ({
...item,
platform: item.platform === 0 ? 1 : item.platform,
}));
dataSource.value = _data;
remoteDataSource.value = cloneDeep(_data);
const _firstCard = _data?.[0] ?? {};
const { id, ai_review } = _firstCard;
selectCardInfo.value = cloneDeep(_firstCard);
selectedImageInfo.value = cloneDeep(_firstCard.files?.[0] ?? {});
if (isEmpty(ai_review)) {
handleStartCheck();
}
}
} finally {
getDataLoading.value = false;
}
};
const isSelectCardModified = () => {
return new Promise((resolve) => {
const _item = remoteDataSource.value.find((item) => item.id === selectCardInfo.value.id);
resolve(!isEqual(selectCardInfo.value, _item) && !isSaved.value);
});
};
const onPlatformChange = (platform) => {
selectCardInfo.value.platform = platform;
};
const onExit = async () => {
const isModified = await isSelectCardModified();
if (isModified) {
cancelCheckModalRef.value?.open();
} else {
onBack();
}
};
const onSave = async () => {
if (!selectCardInfo.value.title) {
AMessage.warning('标题不能为空');
}
contentCardRef.value?.validate().then(async () => {
const { code, data } = await putWorkAuditsUpdateWriter(writerCode.value, 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('writerManuscriptCheckIds', 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 putWorkAuditsAuditPassWriter(writerCode.value, 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('writerManuscriptCheckIds')?.split(',') ?? [];
getWorkAudits();
});
onUnmounted(() => {
rlsWithCatch('writerManuscriptCheckIds');
});
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 icon" />
<span class="cts !color-#211F24">审核列表</span>
</div>
)}
<div class="flex-1 flex flex-col overflow-hidden bg-#fff rounded-8px ">
<HeaderCard
dataSource={dataSource.value}
selectCardInfo={selectCardInfo.value}
onCardClick={onCardClick}
onPlatformChange={onPlatformChange}
/>
<section class="flex-1 overflow-hidden">
<ContentCard
ref={contentCardRef}
v-model={selectCardInfo.value}
selectCardInfo={selectCardInfo.value}
onFilesChange={onFilesChange}
selectedImageInfo={selectedImageInfo.value}
onSelectImage={onSelectImage}
checkLoading={checkLoading.value}
getDataLoading={getDataLoading.value}
onAgainCheck={onAgainCheck}
onStartCheck={handleStartCheck}
/>
</section>
</div>
</div>
<footer
class={`flex justify-end items-center px-16px py-16px w-full bg-#F6F5FC footer-row ${
collapsed.value ? 'collapsed' : ''
}`}
>
{renderFooterRow()}
</footer>
<CancelCheckModal ref={cancelCheckModalRef} onSelectCard={onChangeCard} />
<CheckSuccessModal ref={checkSuccessModalRef} />
<CheckListDrawer ref={checkListDrawerRef} onCardClick={onCardClick} />
</>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

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

View File

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

View File

@ -0,0 +1,376 @@
<script lang="jsx">
import axios from 'axios';
import { Form, FormItem, Input, Textarea, Upload, Message as AMessage, Button } from '@arco-design/web-vue';
import CommonSelect from '@/components/common-select';
import { VueDraggable } from 'vue-draggable-plus';
import TextOverTips from '@/components/text-over-tips';
import ImgBox from './img-box';
import { formatFileSize, getVideoInfo, formatDuration, formatUploadSpeed } from '@/utils/tools';
import { EnumManuscriptType } from '@/views/writer-material-center/components/finished-products/manuscript/list/constants.ts';
import { getVideoPreSignedUrlWriter, getImagePreSignedUrlWriter } from '@/api/all/generationWorkshop-writer';
// 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 route = useRoute();
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 writerCode = computed(() => route.params.writerCode);
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 getVideoPreSignedUrlWriter(writerCode.value, { 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 getImagePreSignedUrlWriter(writerCode.value, { suffix: getFileExtension(name) });
const { file_name, upload_url, file_url } = response?.data;
const blob = new Blob([file], { type });
await axios.put(upload_url, blob, {
headers: { 'Content-Type': type },
});
formData.value.files.push({ url: file_url, name, size });
onChange();
};
const handleDeleteFile = (index) => {
formData.value.files.splice(index, 1);
onChange();
};
const validate = () => {
return new Promise((resolve, reject) => {
formRef.value?.validate((errors) => {
if (errors) {
reject(formData.value);
} else {
resolve();
}
});
});
};
const resetForm = () => {
formData.value = {};
formRef.value?.resetFields?.();
formRef.value?.clearValidate?.();
};
const renderVideoUpload = () => {
return (
<Upload
ref={uploadRef}
action="/"
draggable
custom-request={uploadVideo}
accept=".mp4,.mov,.avi,.flv,.wmv"
show-file-list={false}
>
{{
'upload-button': () => {
if (formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.DEFAULT) {
return (
<div class="upload-box">
<icon-plus size="14" class="mb-16px color-#3C4043" />
<span class="cts !color-#211F24">上传视频</span>
</div>
);
} else {
return <Button type="text">替换视频</Button>;
}
},
}}
</Upload>
);
};
const renderVideo = () => {
const isUploading = formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.UPLOADING;
const isEnd = formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.END;
return (
<FormItem
field="files"
v-slots={{
label: () => (
<div class="flex items-center">
<span class="cts !color-#211F24 mr-8px">视频</span>
<span class="cts !color-#939499">截取视频第一帧为首图</span>
</div>
),
}}
>
{formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.DEFAULT ? (
renderVideoUpload()
) : (
<div class="flex items-center justify-between p-12px rounded-8px bg-#F7F8FA w-784px">
<div class="flex items-center mr-12px">
{isUploading ? (
<div class="w-80px h-80px flex items-center justify-center bg-#fff rounded-8px mr-16px">
<icon-loading size="24" class="color-#B1B2B5" />
</div>
) : (
<img src={formData.value.videoInfo.poster} class="w-80 h-80 object-cover mr-16px rounded-8px" />
)}
<div class="flex flex-col">
<TextOverTips
context={formData.value.videoInfo.name}
class="mb-4px cts !text-14px !lh-22px color-#211F24"
/>
{isEnd ? (
<p>
<span class="cts color-#939499 mr-24px">视频大小{formData.value.videoInfo.size}</span>
<span class="cts color-#939499">视频时长{formData.value.videoInfo.time}</span>
</p>
) : (
<div class="flex items-center">
<div class="flex items-center mr-24px w-100px">
<icon-loading size="16" class="color-#6D4CFE mr-8px" />
<span class="cts !color-#6D4CFE mr-4px">上传中</span>
<span class="cts !color-#6D4CFE ">{formData.value.videoInfo.percent}%</span>
</div>
<div class="flex items-center w-160px mr-24px">
<span class="cts color-#939499">上传速度{formData.value.videoInfo.uploadSpeed}</span>
</div>
<div class="flex items-center">
<span class="cts color-#939499">预估剩余时间{formData.value.videoInfo.estimatedTime}</span>
</div>
</div>
)}
</div>
</div>
<div>{renderVideoUpload()}</div>
</div>
)}
</FormItem>
);
};
const handleImagesChange = (files) => {
formData.value.files = cloneDeep(files);
onChange();
};
// 暴露方法
expose({
validate,
resetForm,
});
watch(
() => props.formData,
(val) => {
formData.value = val;
},
{ deep: true, immediate: true },
);
return () => (
<Form ref={formRef} model={formData.value} rules={props.rules} layout="vertical" auto-label-width>
<FormItem label="标题" field="title" required>
<Input
v-model={formData.value.title}
onInput={() => {
onChange();
emit('reValidate');
}}
placeholder="请输入标题"
size="large"
class="!w-500px"
maxLength={30}
show-word-limit
/>
</FormItem>
<FormItem label="作品描述" field="content">
<Textarea
v-model={formData.value.content}
onInput={onChange}
placeholder="请输入作品描述"
size="large"
class="h-300px !w-784px"
show-word-limit
max-length={1000}
auto-size={{ minRows: 7, maxRows: 12 }}
/>
</FormItem>
{isVideo.value ? (
renderVideo()
) : (
<ImgBox
files={formData.value.files}
onChange={handleImagesChange}
onDelete={handleDeleteFile}
onUpload={uploadImage}
/>
)}
{/* <FormItem label="所属项目" field="project_ids">
<CommonSelect
v-model={formData.value.project_ids}
onChange={() => emit('change')}
options={projects.value}
placeholder="请选择所属项目"
size="large"
class="!w-280px"
/>
</FormItem>*/}
</Form>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

@ -0,0 +1,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,379 @@
<script lang="jsx">
import {
Modal,
Form,
FormItem,
Input,
RadioGroup,
Radio,
Upload,
Button,
Message as AMessage,
Textarea,
} from '@arco-design/web-vue';
import {
getTemplateUrlWriter,
postWorksByLinkWriter,
postWorksByFileWriter,
} from '@/api/all/generationWorkshop-writer.ts';
import { getTemplateUrl } from '@/api/all/generationWorkshop';
import { slsWithCatch } from '@/utils/stroage.ts';
import TextOverTips from '@/components/text-over-tips';
import icon1 from '@/assets/img/media-account/icon-feedback-fail.png';
// 状态枚举
const TASK_STATUS = {
DEFAULT: 1,
LOADING: 2,
FAILED: 3,
SUCCESS: 4,
};
const UPLOAD_TYPE = {
LINK: 'link',
LOCAL: 'local',
};
// 初始表单数据
const INITIAL_FORM = {
link: '',
writerLink: '',
};
export default {
setup(props, { emit, expose }) {
const update = inject('update');
const router = useRouter();
const route = useRoute();
// 响应式状态
const visible = ref(false);
const formRef = ref(null);
const uploadType = ref(UPLOAD_TYPE.LOCAL);
const taskStatus = ref(TASK_STATUS.DEFAULT);
const form = ref(cloneDeep(INITIAL_FORM));
const works = ref([]);
const uploadingFiles = ref([]); // 上传中
const uploadSuccessFiles = ref([]); // 上传成功
// 生成自增 id基于当前列表中最大的 id
const getNextWorkId = () => {
const currentMax = works.value.reduce((max, item) => {
const numericId = Number(item?.id);
return Number.isFinite(numericId) ? Math.max(max, numericId) : max;
}, 0);
return currentMax + 1;
};
const isLink = computed(() => uploadType.value === UPLOAD_TYPE.LINK);
const isLocal = computed(() => uploadType.value === UPLOAD_TYPE.LOCAL);
const isDefault = computed(() => taskStatus.value === TASK_STATUS.DEFAULT);
const writerCode = computed(() => route.params.writerCode);
// 模态框标题
const getTitle = () => {
const titleMap = {
[TASK_STATUS.DEFAULT]: '上传内容稿件',
[TASK_STATUS.LOADING]: isLink.value ? '链接上传' : isLocal.value ? '本地批量上传' : '上传内容稿件',
[TASK_STATUS.FAILED]: isLink.value ? '链接上传' : isLocal.value ? '本地批量上传' : '上传内容稿件',
[TASK_STATUS.SUCCESS]: '上传内容稿件列表',
};
return titleMap[taskStatus.value];
};
// 重置状态
const reset = () => {
formRef.value?.resetFields?.();
uploadType.value = UPLOAD_TYPE.LINK;
taskStatus.value = TASK_STATUS.DEFAULT;
form.value = cloneDeep(INITIAL_FORM);
works.value = [];
uploadingFiles.value = [];
uploadSuccessFiles.value = [];
};
const open = () => {
visible.value = true;
};
const onClose = () => {
reset();
visible.value = false;
};
// 防抖提交
const debouncedSubmit = debounce(async () => {
formRef.value?.validate(async (errors) => {
if (!errors) {
taskStatus.value = TASK_STATUS.LOADING;
const { link } = form.value;
const { code, data } = await postWorksByLinkWriter(writerCode.value, { link });
if (code === 200) {
taskStatus.value = TASK_STATUS.SUCCESS;
works.value = data ? [data] : [];
}
}
});
}, 300);
// 提交处理
const onSubmit = () => {
debouncedSubmit();
};
// 取消上传
const onCancelUpload = () => {
taskStatus.value = TASK_STATUS.DEFAULT;
AMessage.info('已取消上传');
};
// 文件上传处理
const handleUpload = async (option) => {
taskStatus.value = TASK_STATUS.LOADING;
const {
fileItem: { file },
} = option;
const formData = new FormData();
formData.append('file', file);
uploadingFiles.value.push(file);
const { code, data } = await postWorksByFileWriter(formData, {
timeout: 0,
headers: {
'Content-Type': 'multipart/form-data',
'writer-code': writerCode.value,
},
});
if (code === 200) {
if (data) {
const id = data.id ?? getNextWorkId();
const _data = { ...data, id };
works.value.push(_data);
uploadSuccessFiles.value.push(_data);
}
if (uploadingFiles.value.length === uploadSuccessFiles.value.length) {
taskStatus.value = TASK_STATUS.SUCCESS;
}
}
};
// 跳转编辑
const goUpload = () => {
slsWithCatch('writerWaitUploadWorks', JSON.stringify(works.value));
router.push({
name: 'WriterManuscriptUpload',
params: {
writerCode: writerCode.value,
},
});
onClose();
};
// 删除项目
const onDelete = (index) => {
works.value.splice(index, 1);
if (!works.value.length) {
taskStatus.value = TASK_STATUS.DEFAULT;
}
// AMessage.success('删除成功');
};
// 上传方式切换
const onUploadTypeChange = (val) => {
uploadType.value = val;
formRef.value?.clearValidate?.();
};
// 下载模板
const handleDownloadTemplate = async () => {
const { code, data } = await getTemplateUrlWriter(writerCode.value);
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 renderLocalForm = () => (
<FormItem label="内容稿件">
<div class="flex flex-col w-full">
<Upload
action="/"
draggable
multiple
customRequest={handleUpload}
accept=".xlsx,.xls,.docx,.doc,.mp4,.mov,.avi,.flv,.wmv,.m4v"
show-file-list={false}
>
{{
'upload-button': () => (
<div class="upload-box">
<icon-plus size="14" class="mb-16px" />
<span class="text mb-4px">点击或拖拽文件到此处上传</span>
<span class="tip">支持文档文本+, 视频批量上传</span>
</div>
),
}}
</Upload>
<div class="flex items-center cursor-pointer mt-8px" onClick={handleDownloadTemplate}>
<icon-download size="14" class="mr-4px !color-#6D4CFE" />
<span class="cts color-#6D4CFE">下载示例文档</span>
</div>
</div>
</FormItem>
);
// 渲染加载状态
const renderLoadingState = () => (
<div class="flex flex-col items-center justify-center rounded-8px bg-#F7F8FA h-208px">
<icon-loading size="48" class="!color-#6D4CFE mb-16px" />
<p class="tip !text-#768893">上传过程耗时可能较长请耐心等待</p>
<p class="tip !text-#768893">刷新页面将会终止本次数据的上传请谨慎操作</p>
</div>
);
// 渲染失败状态
const renderFailedState = () => (
<div class="flex flex-col items-center justify-center rounded-8px bg-#F7F8FA h-208px">
<img src={icon1} class="w-80px h-80px mb-16px" />
<p class="text mb-4px">上传失败</p>
<p class="tip !text-#768893">可能是网络不稳定导致请检查网络后重试</p>
</div>
);
// 渲染成功状态
const renderSuccessState = () => (
<div class="flex flex-col py-12px max-h-540px rounded-8px bg-#F7F8FA">
<span class="tip mb-8px px-12px fs-14px !text-left"> {works.value.length} 个内容稿件</span>
<div class="flex-1 overflow-y-auto overflow-x-hidden px-12px">
{works.value.map((item, index) => (
<div key={item.id} class="rounded-8px bg-#fff px-8px py-8px flex justify-between items-center mt-8px">
<div class="flex-1 overflow-hidden flex items-center mr-12px">
<img src={item.cover} width="32" height="32" class="rounded-3px mr-8px" />
<TextOverTips class="text !text-left" context={item.title} />
</div>
<icon-delete
size="16px"
class="color-#737478 cursor-pointer hover:!color-#211F24"
onClick={() => onDelete(index)}
/>
</div>
))}
</div>
</div>
);
// 渲染表单内容
const renderFormContent = () => {
const contentMap = {
[TASK_STATUS.DEFAULT]: () => {
const formMap = {
[UPLOAD_TYPE.LINK]: renderLinkForm,
[UPLOAD_TYPE.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}>
确认
</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} 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>
</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,222 @@
<script lang="jsx">
import { Button, Message as AMessage, Spin } from '@arco-design/web-vue';
import { useRouter, useRoute } from 'vue-router';
import { AuditStatus } from '@/views/writer-material-center/components/finished-products/constants';
import { getWorksDetailWriter } from '@/api/all/generationWorkshop-writer.ts';
import { EnumManuscriptType } from '@/views/writer-material-center/components/finished-products/manuscript/list/constants.ts';
import { convertVideoUrlToCoverUrl, exactFormatTime } from '@/utils/tools.ts';
import { slsWithCatch } from '@/utils/stroage.ts';
import { useSidebarStore } from '@/stores/modules/side-bar';
const DEFAULT_SOURCE_INFO = {
title: '成品库',
routeName: 'WriterMaterialCenterFinishedProducts',
};
const SOURCE_MAP = new Map([['check', { title: '成品库', routeName: 'WriterMaterialCenterFinishedProducts' }]]);
export default {
setup(props, { emit, expose }) {
const router = useRouter();
const route = useRoute();
const sidebarStore = useSidebarStore();
const workId = ref(route.params.id);
const { source, audit_status } = route.query;
// 视频播放相关状态
const isPlaying = ref(false);
const videoRef = ref(null);
const videoUrl = ref('');
const coverImageUrl = ref('');
const isVideoLoaded = ref(false);
const dataSource = ref({});
const images = ref([]);
const loading = ref(false);
const isVideo = computed(() => dataSource.value.type === EnumManuscriptType.Video);
const sourceInfo = computed(() => SOURCE_MAP.get(source) ?? DEFAULT_SOURCE_INFO);
const writerCode = computed(() => route.params.writerCode);
const collapsed = computed(() => {
return sidebarStore.menuCollapse;
});
const onBack = () => {
router.push({
name: sourceInfo.value.routeName,
params: {
writerCode: writerCode.value,
},
});
};
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 getWorksDetailWriter(writerCode.value, 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" />
</div>
);
}
};
const togglePlay = () => {
if (!videoRef.value) return;
if (isPlaying.value) {
videoRef.value.pause();
} else {
if (!isVideoLoaded.value) {
videoRef.value.src = videoUrl.value;
isVideoLoaded.value = true;
}
videoRef.value.play();
}
isPlaying.value = !isPlaying.value;
};
const onVideoEnded = () => {
isPlaying.value = false;
};
const renderFooterRow = () => {
const _fn = () => {
slsWithCatch('writerManuscriptCheckIds', [workId.value]);
router.push({
name: 'WriterManuscriptCheck',
params: {
writerCode: writerCode.value,
},
});
};
return (
<>
<Button size="medium" type="outline" class="mr-12px" onClick={onBack}>
退出
</Button>
<Button
size="medium"
type="outline"
class="mr-12px"
onClick={() => {
router.push({
name: 'WriterManuscriptEdit',
params: {
writerCode: writerCode.value,
id: workId.value,
},
});
}}
>
编辑
</Button>
{audit_status !== AuditStatus.Passed && (
<Button type="primary" size="medium" onClick={_fn}>
去审核
</Button>
)}
</>
);
};
onMounted(() => {
workId && getData();
});
onBeforeUnmount(() => {
if (videoRef.value) {
videoRef.value.pause();
videoRef.value = null;
}
});
return () => (
<Spin loading={loading.value} class="manuscript-detail-wrap" size={50}>
<div class="h-full w-full flex flex-col">
<div class="flex items-center mb-8px">
<span class="cts color-#4E5969 cursor-pointer" onClick={onBack}>
{sourceInfo.value.title}
</span>
<icon-oblique-line size="12" class="color-#C9CDD4 mx-4px" />
<span class="cts bold !color-#1D2129">内容稿件详情</span>
</div>
<div class="flex-1 bg-#fff rounded-8px py-32px">
<div class="w-684px mx-auto flex flex-col items-center">
<div class="flex justify-start flex-col w-full">
<p class="mb-8px cts bold !text-28px !lh-40px !color-#211F24">{dataSource.value.title}</p>
<p class="cts !text-12px !color-#737478 mb-32px w-full text-left">
{exactFormatTime(dataSource.value.update_time)}修改
</p>
</div>
{renderMainImg()}
<div class="w-full">
<p class="cts !color-#211F24 whitespace-pre-line">{dataSource.value.content}</p>
</div>
{/* 仅图片类型显示图片列表 */}
{!isVideo.value && (
<div class="desc-img-wrap mt-40px">
{images.value.map((item, index) => (
<div class="desc-img-box" key={index}>
<img src={item.url} class="w-100% h-100% object-contain" />
</div>
))}
</div>
)}
</div>
</div>
</div>
<footer
class={`flex justify-end items-center px-16px py-16px w-full bg-#F6F5FC footer-row ${
collapsed.value ? 'collapsed' : ''
}`}
>
{renderFooterRow()}
</footer>
</Spin>
);
},
};
</script>
<style lang="scss" scoped>
@import './style.scss';
</style>

View File

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

View File

@ -0,0 +1,35 @@
<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 route = useRoute();
const visible = ref(false);
const onClose = () => {
visible.value = false;
};
const onConfirm = () => {
onClose();
router.push({ name: 'WriterMaterialCenterFinishedProducts', params: { writerCode: route.params.writerCode } });
};
const open = () => {
visible.value = true;
};
defineExpose({ open });
</script>

View File

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

View File

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

View File

@ -0,0 +1,149 @@
<template>
<div class="common-filter-wrap">
<div class="filter-row">
<div class="filter-row-item">
<span class="label">内容稿件标题</span>
<a-input
v-model="query.title"
class="!w-240px"
placeholder="请输入内容稿件标题"
size="medium"
allow-clear
@change="handleSearch"
>
<template #prefix>
<icon-search />
</template>
</a-input>
</div>
<!-- <div class="filter-row-item">
<span class="label">所属项目</span>
<CommonSelect
placeholder="请选择所属项目"
v-model="query.project_id"
:options="projects"
class="!w-166px"
@change="handleSearch"
/>
</div> -->
<div class="filter-row-item">
<span class="label">序号</span>
<a-space size="medium">
<a-input
v-model="query.uid"
class="!w-160px"
placeholder="请输入序号"
size="medium"
allow-clear
@change="handleSearch"
>
<template #prefix>
<icon-search />
</template>
</a-input>
</a-space>
</div>
<div class="filter-row-item">
<span class="label">审核状态</span>
<CommonSelect
placeholder="全部"
:options="CHECK_STATUS"
v-model="query.audit_status"
class="!w-160px"
:multiple="false"
@change="handleSearch"
/>
</div>
<div class="filter-row-item">
<span class="label">上传时间</span>
<a-range-picker
v-model="created_at"
size="medium"
allow-clear
format="YYYY-MM-DD"
class="!w-280px"
@change="onDateChange"
/>
</div>
<div class="filter-row-item">
<a-button type="outline" class="mr-12px" size="medium" @click="handleSearch">
<template #icon>
<icon-search />
</template>
<template #default>搜索</template>
</a-button>
<a-button size="medium" @click="handleReset">
<template #icon>
<icon-refresh />
</template>
<template #default>重置</template>
</a-button>
</div>
</div>
</div>
</template>
<script setup>
import { defineEmits, defineProps } from 'vue';
import { CHECK_STATUS } from '@/views/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: 'created_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
// {
// title: '上传人员',
// dataIndex: 'uploader',
// width: 180,
// },
{
title: '最后修改时间',
dataIndex: 'last_modified_at',
width: 180,
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
// {
// title: '最后修改人员',
// dataIndex: 'last_modifier',
// width: 180,
// },
{
title: '操作',
dataIndex: 'operation',
width: 180,
fixed: 'right'
},
];

View File

@ -0,0 +1,53 @@
<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 { deleteWorkWriter } from '@/api/all/generationWorkshop-writer.ts';
import icon1 from '@/assets/img/media-account/icon-warn-1.png';
const update = inject('update');
const route = useRoute();
const visible = ref(false);
const projectId = ref(null);
const projectName = ref('');
const isBatch = computed(() => Array.isArray(projectId.value));
const writerCode = computed(() => route.params.writerCode);
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 deleteWorkWriter(writerCode.value, projectId.value);
if (code === 200) {
AMessage.success('删除成功');
update();
onClose();
}
}
defineExpose({ open });
</script>

View File

@ -0,0 +1,156 @@
<template>
<a-table
ref="tableRef"
:data="dataSource"
row-key="id"
column-resizable
:pagination="false"
:scroll="{ x: '100%' }"
class="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="['created_at', 'last_modified_at'].includes(column.dataIndex)" #cell="{ record }">
{{ exactFormatTime(record[column.dataIndex]) }}
</template>
<template v-else-if="column.dataIndex === 'cover'" #cell="{ record }">
<HoverImagePreview :src="record.cover">
<a-image :width="64" :height="64" :src="record.cover" class="!rounded-6px" fit="cover">
<template #error>
<img :src="icon4" class="w-full h-full" />
</template>
</a-image>
</HoverImagePreview>
</template>
<template v-else-if="column.dataIndex === 'operation'" #cell="{ record }">
<div class="flex items-center">
<img class="mr-8px cursor-pointer" :src="icon1" width="14" height="14" @click="onDelete(record)" />
<a-button type="outline" size="mini" class="mr-8px" @click="onEdit(record)">编辑</a-button>
<a-button type="outline" size="mini" @click="onDetail(record)">详情</a-button>
</div>
</template>
<template v-else #cell="{ record }">
{{ formatTableField(column, record, true) }}
</template>
</a-table-column>
</template>
</a-table>
</template>
<script setup>
import { ref } from 'vue';
import { formatTableField, exactFormatTime } from '@/utils/tools';
import { TABLE_COLUMNS } from './constants';
import { CHECK_STATUS, EnumManuscriptType } from '@/views/writer-material-center/components/finished-products/manuscript/list/constants';
import TextOverTips from '@/components/text-over-tips';
import HoverImagePreview from '@/components/hover-image-preview';
import icon1 from '@/assets/img/media-account/icon-delete.png';
import icon2 from '@/assets/img/creative-generation-workshop/icon-photo.png';
import icon3 from '@/assets/img/creative-generation-workshop/icon-video.png';
import icon4 from '@/assets/img/error-img.png';
const emits = defineEmits(['edit', 'sorterChange', 'delete']);
const router = useRouter();
const props = defineProps({
dataSource: {
type: Array,
default: () => [],
},
});
const tableRef = ref(null);
const route = useRoute();
const handleSorterChange = (column, order) => {
emits('sorterChange', column, order === 'ascend' ? 'asc' : 'desc');
};
const onDelete = (item) => {
emits('delete', item);
};
const onEdit = (item) => {
router.push({
name: 'WriterManuscriptEdit',
params: {
writerCode: route.params.writerCode,
id: item.id,
},
});
};
const onDetail = (item) => {
router.push({
name: 'WriterManuscriptDetail',
params: {
writerCode: route.params.writerCode,
id: 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,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,96 @@
<template>
<div class="manuscript-list-wrap">
<div class="filter-wrap bg-#fff rounded-b-8px mb-16px">
<FilterBlock v-model:query="query" @search="handleSearch" @reset="handleReset" />
</div>
<div class="table-wrap bg-#fff rounded-8px px-24px py-24px flex flex-col">
<ManuscriptTable :dataSource="dataSource" @sorterChange="handleSorterChange" @delete="handleDelete" />
<div v-if="pageInfo.total > 0" class="pagination-row">
<a-pagination
:total="pageInfo.total"
size="mini"
show-total
show-jumper
show-page-size
:current="pageInfo.page"
:page-size="pageInfo.page_size"
@change="onPageChange"
@page-size-change="onPageSizeChange"
/>
</div>
</div>
<DeleteManuscriptModal ref="deleteManuscriptModalRef" />
</div>
</template>
<script lang="jsx" setup>
import { defineComponent } from 'vue';
import { Button } from '@arco-design/web-vue';
import FilterBlock from './components/filter-block';
import ManuscriptTable from './components/manuscript-table';
import DeleteManuscriptModal from './components/manuscript-table/delete-manuscript-modal.vue';
import { useTableSelectionWithPagination } from '@/hooks/useTableSelectionWithPagination';
import { getWorksPageWriter } from '@/api/all/generationWorkshop-writer.ts';
import {
INITIAL_QUERY,
EnumCheckStatus,
} from '@/views/writer-material-center/components/finished-products/manuscript/list/constants.ts';
const { dataSource, pageInfo, onPageChange, onPageSizeChange, resetPageInfo } = useTableSelectionWithPagination({
onPageChange: () => {
getData();
},
onPageSizeChange: () => {
getData();
},
});
const route = useRoute();
const query = ref(cloneDeep(INITIAL_QUERY));
const addManuscriptModalRef = ref(null);
const deleteManuscriptModalRef = ref(null);
const getData = async () => {
const { page, page_size } = pageInfo.value;
const { code, data } = await getWorksPageWriter(route.params.writerCode, {
...query.value,
page,
page_size,
});
if (code === 200) {
dataSource.value = data?.data ?? [];
pageInfo.value.total = data.total;
}
};
const handleSearch = () => {
reload();
};
const reload = () => {
pageInfo.value.page = 1;
getData();
};
const handleReset = () => {
resetPageInfo();
query.value = cloneDeep(INITIAL_QUERY);
reload();
};
const handleSorterChange = (column, order) => {
query.value.sort_column = column;
query.value.sort_order = order;
reload();
};
const handleDelete = (item) => {
const { id, title } = item;
deleteManuscriptModalRef.value?.open({ id, name: `${title}` });
};
onMounted(() => {
getData();
});
provide('update', getData);
</script>
<style scoped lang="scss">
@import './style.scss';
</style>

View File

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

View File

@ -0,0 +1,39 @@
<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 route = useRoute();
const visible = ref(false);
const num = ref('');
const onClose = () => {
num.value = '';
visible.value = false;
};
const onConfirm = () => {
onClose();
router.push({ name: 'WriterMaterialCenterFinishedProducts', params: { writerCode: route.params.writerCode } });
};
const open = (manusNum) => {
num.value = manusNum;
visible.value = true;
};
defineExpose({ open });
</script>

View File

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

View File

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

View File

@ -0,0 +1,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 route = useRoute();
const visible = ref(false);
const workIds = ref([]);
const onClose = () => {
workIds.value = [];
visible.value = false;
};
const onBack = () => {
onClose();
router.push({ name: 'WriterMaterialCenterFinishedProducts', params: { writerCode: route.params.writerCode } });
};
const onConfirm = () => {
visible.value = false;
slsWithCatch('writerManuscriptCheckIds', workIds.value);
router.push({ name: 'WriterManuscriptCheck', params: { writerCode: route.params.writerCode } });
};
const open = (_workIds) => {
workIds.value = _workIds;
visible.value = true;
};
defineExpose({ open });
</script>
<style lang="scss">
.upload-success11-modal {
.arco-modal-header {
border-bottom: none;
}
.md {
font-family: $font-family-medium;
}
.ld {
font-family: $font-family-regular;
}
.arco-modal-footer {
border-top: none;
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,37 @@
.finished-products-wrap {
:deep(.ant-tabs) {
.ant-tabs-nav {
margin-bottom: 0;
padding: 0 24px;
.ant-tabs-nav-wrap {
height: 56px;
.ant-tabs-nav-list {
.ant-tabs-tab {
.ant-tabs-tab-btn {
color: var(--Text-2, #55585f);
font-family: $font-family-regular;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 24px;
}
&.ant-tabs-tab-active {
.ant-tabs-tab-btn {
color: var(--Brand-6, #6d4cfe);
font-weight: 500;
font-family: $font-family-medium;
text-shadow: none;
}
}
}
.ant-tabs-ink-bar {
background: #6D4CFE;
}
}
}
}
.ant-tabs-content-holder {
display: none;
}
}
}

View File

@ -0,0 +1,16 @@
<script lang="tsx">
export default defineComponent({
setup(_, { attrs, slots, expose }) {
return () => (
<div class="raw-material-wrap h-full flex flex-col">
原材料库
</div>
);
},
});
</script>
<style scoped lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,3 @@
.raw-material-wrap {
}

View File

@ -0,0 +1,54 @@
<script lang="tsx">
import FinishedProducts from './components/finished-products/index.vue';
import RawMaterial from './components/raw-material/index.vue';
const TABS = [
{
label: '成品库',
key: '1',
routeName: 'WriterMaterialCenterFinishedProducts',
},
// {
// label: '原料库',
// key: '2',
// routeName: 'WriterMaterialCenterRawMaterial',
// },
];
export default defineComponent({
setup(_, { attrs, slots, expose }) {
const route = useRoute();
const router = useRouter();
const activeKey = ref('1');
onMounted(() => {
activeKey.value = TABS.find((item) => item.routeName === route.name)?.key;
});
return () => (
<div class="material-center-wrap h-full flex flex-col">
<header class="py-16px px-24px rounded-8px bg-#fff flex items-center mb-16px">
{TABS.map((item) => (
<p
key={item.key}
class={`font-family-medium color-#737478 font-400 text-16px lh-24px cursor-pointer mr-32px ${
item.key === activeKey.value ? '!color-#6D4CFE font-500' : ''
}`}
onClick={() => {
activeKey.value = item.key;
router.push({ name: item.routeName });
}}
>
{item.label}
</p>
))}
</header>
{activeKey.value === '1' ? <FinishedProducts /> : <RawMaterial />}
</div>
);
},
});
</script>
<style scoped lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,2 @@
.material-center-wrap {
}