Files
douyin/app/src/pages/cok/CookieTable.vue
T

1277 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import { getBase64 } from '@/utils/file';
import { FormInstance } from 'ant-design-vue';
import { computed, reactive, ref, onMounted, onBeforeUnmount, UnwrapRef, watch, nextTick } from 'vue';
import dayjs from 'dayjs';
import { Dayjs } from 'dayjs';
import {
EditFilled,
DeleteFilled,
SearchOutlined,
PlusOutlined,
ExclamationCircleOutlined,
StopOutlined,
ClockCircleOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue';
import { useApiStore } from '@/store';
import { message } from 'ant-design-vue';
import { StarOutlined, StarFilled, StarTwoTone } from '@ant-design/icons-vue';
const storageType = ref(0);
const isRemoteStorage = computed(() => storageType.value === 1 || storageType.value === 2);
const columns = computed(() => [
{ title: 'Cookie名称', dataIndex: 'userName', width: 180 },
{ title: 'Cookie状态', dataIndex: 'statusMsg', width: 120 },
{ title: '收藏路径', dataIndex: isRemoteStorage.value ? 'webDavCollectPath' : 'savePath', width: 240 },
{ title: '喜欢路径', dataIndex: isRemoteStorage.value ? 'webDavFavoritePath' : 'favSavePath', width: 240 },
{ title: '博主路径', dataIndex: isRemoteStorage.value ? 'webDavFollowPath' : 'upSavePath', width: 240 },
{ title: '状态', dataIndex: 'status', width: 180 },
{ title: '操作', dataIndex: 'edit', width: 200 },
]);
const isMobile = ref(window.innerWidth <= 768);
const updateViewport = () => (isMobile.value = window.innerWidth <= 768);
const drawerWidth = computed(() => (isMobile.value ? '100%' : '800px'));
interface UpSecUserIdItem {
uper?: string;
uid?: string;
syncAll: boolean;
}
type DataItem = {
id?: string;
userName?: string;
cookies?: string;
savePath?: string;
favSavePath?: string;
secUserId?: string;
status?: number;
statusMsg?: string;
statusCode?: number;
_isNew?: boolean;
upSecUserIdsJson?: UpSecUserIdItem[];
upSecUserIds?: string;
upSavePath?: string;
useCollectFolder?: boolean;
downMix?: boolean;
downSeries?: boolean;
mixPath?: string;
seriesPath?: string;
downCollect?: boolean;
downFavorite?: boolean;
downFollowd?: boolean;
webDavCollectPath?: string;
webDavFavoritePath?: string;
webDavFollowPath?: string;
webDavMixPath?: string;
webDavSeriesPath?: string;
sourceCooldownUntil?: string;
sourceRequiresAuthorization?: boolean;
sourceProbePending?: boolean;
lastSourceStatusCode?: number;
lastSourceError?: string;
};
const loading = ref(false);
const datas: UnwrapRef<DataItem[]> = reactive([]);
const pagination = ref({
current: 1,
defaultPageSize: 10,
total: 0,
showTotal: () => `共 ${0} 条`,
});
interface QuaryParam {
pageIndex: number;
pageSize: number;
}
const quaryData: UnwrapRef<QuaryParam> = reactive({
pageIndex: 0,
pageSize: 20,
});
const GetRecords = () => {
loading.value = true;
quaryData.pageIndex = pagination.value.current;
quaryData.pageSize = pagination.value.defaultPageSize;
useApiStore()
.CookiePageList(quaryData)
.then((res) => {
loading.value = false;
if (res.code === 0) {
dataSource.value = res.data.data;
pagination.value.current = res.data.pageIndex;
pagination.value.defaultPageSize = res.data.pageSize;
pagination.value.total = res.data.total;
pagination.value.showTotal = () => `共 ${res.data.total} 条`;
}
});
};
function addNew() {
showModal.value = true;
form._isNew = true;
}
const showModal = ref(false);
const newCookie = (cookie?: DataItem) => {
if (!cookie) {
cookie = { _isNew: true };
}
cookie.userName = undefined;
cookie.cookies = undefined;
cookie.savePath = undefined;
cookie.favSavePath = undefined;
cookie.secUserId = undefined;
cookie.status = 0;
cookie.id = '0';
cookie.upSecUserIdsJson = undefined;
cookie.upSavePath = undefined;
cookie.useCollectFolder = false;
cookie.downMix = false;
cookie.downSeries = false;
cookie.mixPath = undefined;
cookie.seriesPath = undefined;
cookie.downCollect = false;
cookie.downFavorite = false;
cookie.downFollowd = false;
cookie.webDavCollectPath = undefined;
cookie.webDavFavoritePath = undefined;
cookie.webDavFollowPath = undefined;
cookie.webDavMixPath = undefined;
cookie.webDavSeriesPath = undefined;
return cookie;
};
const copyObject = (target: any, source?: any) => {
if (!source) {
return target;
}
Object.keys(target).forEach((key) => (target[key] = source[key]));
};
const form = reactive<DataItem>(newCookie());
function reset() {
return newCookie(form);
}
function cancel() {
showModal.value = false;
reset();
}
const formModel = ref<FormInstance>();
const formLoading = ref(false);
function submit() {
formLoading.value = true;
formModel.value
?.validateFields()
.then((resData: DataItem) => {
if (form._isNew) {
} else {
copyObject(editRecord.value, resData);
}
useApiStore()
.UpdateConfig(resData)
.then((res) => {
loading.value = false;
if (res.code === 0) {
showModal.value = false;
message.success('保存成功,同步任务将在5-10秒后重新启动...');
reset();
GetRecords();
} else {
message.error('保存失败' + res.message);
}
});
})
.catch((e) => {
console.error(e);
})
.finally(() => {
formLoading.value = false;
});
}
const editRecord = ref<DataItem>();
import { Modal } from 'ant-design-vue';
const deleted = (id: string) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这条记录吗?此操作不可撤销。',
okText: '确认',
cancelText: '取消',
onOk: () => {
useApiStore()
.deleteCookie(id)
.then((res) => {
loading.value = false;
if (res.code === 0) {
showModal.value = false;
reset();
GetRecords();
}
});
},
onCancel: () => {
console.log('已取消删除');
},
});
};
function edit(record: DataItem) {
cookieId.value = record.id;
editRecord.value = record;
console.log(record);
copyObject(form, record);
showModal.value = true;
}
const switchSyncStatus = (record: DataItem) => {
const statusText = record.status === 1 ? '开启' : '停止';
const title = `确认${statusText}同步`;
const content = `确定要${statusText}${record.userName || '该'}】Cookie的同步任务吗?`;
Modal.confirm({
title,
content,
okText: '确认',
cancelText: '取消',
onOk: () => {
loading.value = true;
useApiStore()
.SwitchCookieStatus({
id: record.id,
status: record.status,
})
.then((res) => {
loading.value = false;
if (res.code === 0) {
message.success(`${statusText}同步成功`);
GetRecords();
} else {
message.error(`${statusText}同步失败:${res.message || '未知错误'}`);
record.status = record.status === 1 ? 0 : 1;
}
})
.catch((err) => {
loading.value = false;
console.error('切换同步状态失败:', err);
message.error('切换同步状态失败,请稍后重试');
record.status = record.status === 1 ? 0 : 1;
});
},
onCancel: () => {
console.log(`已取消${statusText}同步`);
record.status = record.status === 1 ? 0 : 1;
},
});
};
const StatusDict: Record<number, string> = {
0: '同步已停止',
1: '同步已开启',
};
const dataSource = ref(datas);
const addRow = () => {
if (!form.upSecUserIdsJson) {
form.upSecUserIdsJson = [];
}
form.upSecUserIdsJson.push({ uper: '', uid: '', syncAll: false });
};
const removeRow = (index: number) => {
if (form.upSecUserIdsJson) {
form.upSecUserIdsJson.splice(index, 1);
}
};
const rowCount = 10;
const handlePageChange = (page: number, pageSize: number) => {
pagination.value.current = page;
pagination.value.defaultPageSize = pageSize;
GetRecords();
};
onMounted(async () => {
window.addEventListener('resize', updateViewport);
try {
const res = await useApiStore().StorageConfig();
if (res.code === 0) storageType.value = Number(res.data.storageType ?? 0);
} catch {
message.warning('读取存储模式失败,路径表单暂按本地存储显示');
}
GetRecords();
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateViewport);
});
const showDrawer = ref(false);
type DrawerType = 'collect' | 'mix' | 'series';
const cookieId = ref('');
const cateType = ref(5);
const drawerType = ref<DrawerType>('collect');
const drawerScrollRef = ref<HTMLDivElement | null>(null);
const drawerDataList = ref<DrawerItem[]>([]);
const drawerPagination = reactive({
current: 1,
pageSize: 10,
total: 0,
loading: false,
hasMore: true,
});
interface DrawerItem {
id: string;
name: string;
saveFolder: string;
sync: boolean;
coverUrl: string;
cookieId: string;
xId: string;
total: number;
}
const openCollectFolderSetModal = () => {
drawerType.value = 'collect';
cateType.value = 5;
openCommonDrawer();
};
const openMixDownSetModal = () => {
drawerType.value = 'mix';
cateType.value = 6;
openCommonDrawer();
};
const openSeriesDownSetModal = () => {
drawerType.value = 'series';
cateType.value = 7;
openCommonDrawer();
};
const openCommonDrawer = () => {
drawerDataList.value = [];
drawerPagination.current = 1;
drawerPagination.total = 0;
drawerPagination.hasMore = true;
showDrawer.value = true;
nextTick()
.then(() => {
loadDrawerData();
bindDrawerScrollEvent();
})
.catch(() => {});
};
const bindDrawerScrollEvent = () => {
const scrollContainer = drawerScrollRef.value;
if (!scrollContainer) {
setTimeout(() => {
bindDrawerScrollEvent();
}, 100);
return;
}
scrollContainer.removeEventListener('scroll', handleDrawerScroll);
scrollContainer.addEventListener('scroll', handleDrawerScroll);
setTimeout(() => {
handleDrawerScroll();
}, 200);
};
const debounce = (func: Function, delay = 100) => {
let timeoutId: any;
return (...args: any[]) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
};
const handleDrawerScroll = debounce(() => {
const scrollContainer = drawerScrollRef.value;
if (!scrollContainer) return;
if (drawerPagination.loading || !drawerPagination.hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const isBottom = scrollTop + clientHeight + 50 >= scrollHeight;
if (isBottom) {
console.log('✅ 触底加载触发');
drawerPagination.current += 1;
loadDrawerData();
}
}, 100);
const loadDrawerData = () => {
if (drawerPagination.loading || !drawerPagination.hasMore) {
console.log('🚫 阻止重复加载');
return;
}
drawerPagination.loading = true;
useApiStore()
.CatePageList({
cookieId: cookieId.value,
cateType: cateType.value,
pageIndex: drawerPagination.current,
pageSize: drawerPagination.pageSize,
})
.then((res) => {
if (res.code === 0) {
const newData = res.data.data || [];
drawerDataList.value = [...drawerDataList.value, ...newData];
drawerPagination.total = res.data.total || 0;
drawerPagination.hasMore = drawerDataList.value.length < drawerPagination.total;
setTimeout(() => {
handleDrawerScroll();
}, 100);
} else {
message.error(res.message || '加载失败');
}
})
.catch(() => {
message.error('网络异常,加载失败');
})
.finally(() => {
drawerPagination.loading = false;
});
};
const getDrawerTypeName = () => {
switch (drawerType.value) {
case 'collect':
return '收藏夹';
case 'mix':
return '合集';
case 'series':
return '短剧';
default:
return '';
}
};
const toggleDrawerItemSync = (item: DrawerItem, index: number) => {
if (drawerDataList.value[index]) {
drawerDataList.value[index].sync = !item.sync;
console.log(`切换${getDrawerTypeName()}${item.name}】的同步状态为:${!item.sync}`);
}
};
const closeDrawer = () => {
showDrawer.value = false;
const scrollContainer = drawerScrollRef.value;
if (scrollContainer) {
scrollContainer.removeEventListener('scroll', handleDrawerScroll);
}
};
const saveDrawerData = () => {
if (drawerDataList.value.length === 0) {
message.info('暂无需要保存的配置数据');
return;
}
drawerPagination.loading = true;
useApiStore()
.BatchSaveCate(drawerDataList.value)
.then((res) => {
if (res.code === 0) {
showDrawer.value = false;
message.success('保存成功');
} else {
message.error(res.message);
}
})
.finally(() => {
drawerPagination.loading = false;
});
};
const switchdownCollect = (e: any) => {
if (!e) form.useCollectFolder = e;
};
</script>
<template>
<a-modal :title="form._isNew ? '新增授权' : '编辑授权'" v-model:visible="showModal" @ok="submit" @cancel="cancel" width="100%" wrap-class-name="full-modal">
<a-form ref="formModel" :model="form" :labelCol="isMobile ? { span: 24 } : { span: 3 }" :wrapperCol="isMobile ? { span: 24 } : { span: 20 }">
<a-form-item label="Cookie名称" required name="userName">
<a-input v-model:value="form.userName" />
</a-form-item>
<a-form-item label="id" required name="id" v-show="false">
<a-input v-model:value="form.id" />
</a-form-item>
<a-form-item label="Cookie值" name="cookies">
<a-textarea v-model:value="form.cookies" :rows="rowCount" />
</a-form-item>
<a-form-item label="我的secUserId" name="secUserId">
<div style="display: flex; align-items: center; gap: 6px;">
<a-input v-model:value="form.secUserId" style="flex: 1;" placeholder="" />
<a-tooltip title="如果要同步“我喜欢”的视频和关注列表时,必填!!!">
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px;" />
</a-tooltip>
</div>
</a-form-item>
<a-form-item label="下载收藏视频" name="downCollect">
<div class="sync-option-row">
<div class="form-item-div">
<a-switch v-model:checked="form.downCollect" @change="switchdownCollect" :checked-value="true" :un-checked-value="false" size="default" />
<a-form-item-rest v-if="form.downCollect">
<a-form-item :name="isRemoteStorage ? 'webDavCollectPath' : 'savePath'" noStyle>
<a-input v-if="isRemoteStorage" v-model:value="form.webDavCollectPath" placeholder="OpenList 相对路径,留空自动生成" class="form-item-div-input" />
<a-input v-else v-model:value="form.savePath" placeholder="请输入容器路径" class="form-item-div-input" />
</a-form-item>
</a-form-item-rest>
</div>
<a-alert :message="isRemoteStorage ? '开启后写入目标基础目录下的这个 OpenList 相对路径;留空保存时会自动生成。' : '开启后自动下载默认收藏夹视频,记得填写映射路径(容器内部路径)。'" type="info" size="small" class="path-alert" />
</div>
</a-form-item>
<a-form-item v-if="isRemoteStorage || (form.savePath && form.savePath.length>0)" label="自定义收藏夹" name="useCollectFolder">
<div class="sync-option-row">
<div class="form-item-div">
<a-switch v-model:checked="form.useCollectFolder" :checked-value="true" :un-checked-value="false" size="default" />
<a-form-item-rest v-if="form.useCollectFolder">
<a-input v-if="isRemoteStorage" v-model:value="form.webDavCollectPath" :disabled="form.useCollectFolder&&form.downCollect" placeholder="与收藏路径一致" class="form-item-div-input" />
<a-input v-else v-model:value="form.savePath" :disabled="form.useCollectFolder&&form.downCollect" placeholder="" class="form-item-div-input" />
<a-button @click="openCollectFolderSetModal" shape="circle" type="dashed" style="margin-left:5px;" v-if="form.useCollectFolder">
<star-outlined />
</a-button>
</a-form-item-rest>
</div>
<a-alert message="开启后按收藏夹分类同步,存储根路径与默认收藏路径一致。" :type="form.useCollectFolder?'error':'info'" size="small" class="path-alert" />
</div>
</a-form-item>
<a-form-item label="下载喜欢视频" name="downFavorite">
<div class="sync-option-row">
<div class="form-item-div">
<a-switch v-model:checked="form.downFavorite" :checked-value="true" :un-checked-value="false" size="default" />
<a-form-item-rest v-if="form.downFavorite">
<a-form-item :name="isRemoteStorage ? 'webDavFavoritePath' : 'favSavePath'" noStyle>
<a-input v-if="isRemoteStorage" v-model:value="form.webDavFavoritePath" placeholder="OpenList 相对路径,留空自动生成" class="form-item-div-input" />
<a-input v-else v-model:value="form.favSavePath" placeholder="请输入容器路径" class="form-item-div-input" />
</a-form-item>
<a-button shape="circle" @click="()=>{message.success('别点了,这只是为了好看的😄')}" type="dashed" style="margin-left:5px;">
<like-outlined />
</a-button>
</a-form-item-rest>
</div>
<a-alert :message="isRemoteStorage ? '喜欢的视频会写入目标基础目录下的这个 OpenList 相对路径。' : '开启后自动下载喜欢(点赞)的视频,记得填写映射路径(容器内部路径)。'" type="info" size="small" class="path-alert" />
</div>
</a-form-item>
<a-form-item label="下载关注视频" name="downFollowd">
<div class="sync-option-row">
<div class="form-item-div">
<a-switch v-model:checked="form.downFollowd" :checked-value="true" :un-checked-value="false" size="default" />
<a-form-item-rest v-if="form.downFollowd">
<a-form-item :name="isRemoteStorage ? 'webDavFollowPath' : 'upSavePath'" noStyle>
<a-input v-if="isRemoteStorage" v-model:value="form.webDavFollowPath" placeholder="OpenList 相对路径,留空自动生成" class="form-item-div-input" />
<a-input v-else v-model:value="form.upSavePath" placeholder="请输入容器路径" class="form-item-div-input" />
</a-form-item>
<a-button shape="circle" @click="()=>{message.success('别点了,这只是为了好看的😄')}" type="dashed" style="margin-left:5px;">
<heart-outlined />
</a-button>
</a-form-item-rest>
</div>
<a-alert :message="isRemoteStorage ? '关注博主的视频会写入目标基础目录下的这个 OpenList 相对路径。' : '开启后自动下载关注的博主视频,记得填写映射路径(容器内部路径)。'" type="info" size="small" class="path-alert" />
</div>
</a-form-item>
<a-form-item label="下载合集视频" name="downMix">
<div class="sync-option-row">
<div class="form-item-div">
<a-switch v-model:checked="form.downMix" :checked-value="true" :un-checked-value="false" size="default" />
<a-form-item-rest v-if="form.downMix">
<a-form-item :name="isRemoteStorage ? 'webDavMixPath' : 'mixPath'" noStyle>
<a-input v-if="isRemoteStorage" v-model:value="form.webDavMixPath" class="form-item-div-input" placeholder="OpenList 相对路径,留空自动生成" />
<a-input v-else v-model:value="form.mixPath" class="form-item-div-input" placeholder="默认使用收藏夹路径" />
</a-form-item>
<a-button @click="openMixDownSetModal" shape="circle" type="dashed" style="margin-left:5px;">
<gift-outlined />
</a-button>
</a-form-item-rest>
</div>
<a-alert :message="isRemoteStorage ? '收藏的合集会同步到该 OpenList 相对路径;付费视频下载后可能无法播放。' : '开启后自动下载收藏的合集视频;不填路径默认使用收藏目录,付费视频下载后可能无法播放。'" :type="form.downMix?'error':'info'" size="small" class="path-alert" />
</div>
</a-form-item>
<a-form-item label="下载短剧视频" name="downSeries">
<div class="sync-option-row">
<div class="form-item-div">
<a-switch v-model:checked="form.downSeries" :checked-value="true" :un-checked-value="false" size="default" />
<a-form-item-rest v-if="form.downSeries">
<a-form-item :name="isRemoteStorage ? 'webDavSeriesPath' : 'seriesPath'" noStyle>
<a-input v-if="isRemoteStorage" v-model:value="form.webDavSeriesPath" placeholder="OpenList 相对路径,留空自动生成" class="form-item-div-input" />
<a-input v-else v-model:value="form.seriesPath" placeholder="默认使用收藏夹路径" class="form-item-div-input" />
</a-form-item>
<a-button @click="openSeriesDownSetModal" shape="circle" type="dashed" style="margin-left:5px;">
<fire-outlined />
</a-button>
</a-form-item-rest>
</div>
<a-alert :message="isRemoteStorage ? '收藏的短剧会同步到该 OpenList 相对路径;付费视频下载后可能无法播放。' : '开启后自动下载收藏的短剧视频;不填路径默认使用收藏目录,付费视频下载后可能无法播放。'" :type="form.downSeries?'error':'info'" size="small" class="path-alert" />
</div>
</a-form-item>
<a-form-item label="任务同步状态" name="status">
<div style="display: flex; align-items: center; gap: 8px;">
<a-switch v-model:checked="form.status" :checked-value="1" :un-checked-value="0" size="default" />
<span>{{ form.status === 1 ? '' : '' }}</span>
</div>
</a-form-item>
</a-form>
</a-modal>
<a-drawer :title="getDrawerTypeName()+'配置'" v-model:visible="showDrawer" placement="right" :width="drawerWidth" :z-index="10010" :mask-z-index="10009" @close="closeDrawer" class="common-drawer">
<template #extra>
<a-button type="primary" :loading="drawerPagination.loading" @click="saveDrawerData" class="drawer-save-btn">
<template #icon>
<SaveOutlined />
</template>
保存
</a-button>
</template>
<div ref="drawerScrollRef" class="drawer-scroll-container">
<div v-if="drawerPagination.loading && drawerDataList.length === 0" class="drawer-loading">
<Spin size="large" />
</div>
<a-card v-else :bordered="false" class="drawer-card-container grid-container">
<a-card-grid v-for="(item, index) in drawerDataList" :key="item.id" class="drawer-card-grid">
<div class="grid-cover vertical-cover" v-if="drawerType!='collect'">
<a-image :preview="false" :src="item.coverUrl" fit="cover" />
</div>
<div class="grid-item horizontal-item name">
<label class="drawer-label">名称</label>
<span>{{ item.name || '未命名' }}</span>
</div>
<div class="grid-item horizontal-item save-folder">
<label class="drawer-label">保存</label>
<a-input v-model:value="item.saveFolder" size="small" placeholder="默认用名称作文件夹" class="save-path-input" />
</div>
<div class="grid-item horizontal-item name">
<label class="drawer-label">集数</label>
<span>{{ item.total || '0' }}</span>
</div>
<div class="grid-item horizontal-item sync-switch">
<label class="drawer-label">同步</label>
<a-switch :checked="item.sync" @change="() => toggleDrawerItemSync(item, index)" size="small" />
</div>
</a-card-grid>
</a-card>
<div v-if="!drawerPagination.hasMore && drawerDataList.length > 0" class="no-more-data">
</div>
<div v-if="drawerPagination.loading && drawerDataList.length > 0" class="loading-more">
<Spin size="small" />
<span>加载中...</span>
</div>
</div>
</a-drawer>
<div class="cookie-toolbar">
<a-button @click="GetRecords()" :loading="loading">
<template #icon><SearchOutlined /></template>
刷新
</a-button>
<a-button type="primary" @click="addNew" :loading="formLoading">
<template #icon><PlusOutlined /></template>
新增授权
</a-button>
</div>
<a-table v-if="!isMobile" v-bind="$attrs" :columns="columns" :dataSource="dataSource" :pagination="false" :scroll="{ x: isRemoteStorage ? 1400 : 1200 }">
<template #bodyCell="{ column, text, record }">
<template v-if="column.dataIndex === 'statusMsg'">
<a-tooltip v-if="record.sourceRequiresAuthorization" :title="record.lastSourceError || '请编辑并保存新的 Cookie'">
<a-tag color="error">需要重新授权</a-tag>
</a-tooltip>
<a-tooltip v-else-if="record.sourceCooldownUntil || record.sourceProbePending" :title="record.lastSourceError || '等待自动探测'">
<a-tag color="warning">{{ record.sourceCooldownUntil ? `冷却至 ${dayjs(record.sourceCooldownUntil).format('HH:mm:ss')}` : '等待来源探测' }}</a-tag>
</a-tooltip>
<a-tag v-else :color="record.statusCode === 0 ? 'success' : 'default'">{{ record.statusMsg || '未知' }}</a-tag>
</template>
<template v-else-if="column.dataIndex === 'status'">
<div style="display: flex; align-items: center; gap: 8px;">
<a-switch v-model:checked="record.status" :checked-value="1" :un-checked-value="0" size="small" :disabled="loading" @change="() => switchSyncStatus(record)" />
<span :style="{
fontSize: '12px',
color: record.status === 1 ? '#52c41a' : '#ff4d4f'
}">
{{ StatusDict[record.status] }}
</span>
</div>
</template>
<template v-else-if="column.dataIndex === 'edit'">
<a-button :disabled="showModal || loading" type="link" @click="edit(record)">
<template #icon>
<EditFilled />
</template>
编辑
</a-button>
<a-button :disabled="loading" type="link" @click="deleted(record.id)" danger>
<template #icon>
<DeleteOutlined />
</template>
删除
</a-button>
</template>
<div v-else class="text-subtext">
{{ text }}
</div>
</template>
</a-table>
<a-spin v-else :spinning="loading">
<a-empty v-if="!loading && dataSource.length === 0" description="暂无抖音授权" />
<div class="mobile-cookie-list">
<article v-for="record in dataSource" :key="record.id" class="mobile-cookie-card">
<div class="mobile-cookie-header">
<div>
<h3>{{ record.userName || '未命名授权' }}</h3>
<span :class="record.status === 1 ? 'status-on' : 'status-off'">{{ record.statusMsg || StatusDict[record.status || 0] }}</span>
<a-tag v-if="record.sourceRequiresAuthorization" color="error">需要重新授权</a-tag>
<a-tag v-else-if="record.sourceCooldownUntil || record.sourceProbePending" color="warning">
{{ record.sourceCooldownUntil ? `冷却至 ${dayjs(record.sourceCooldownUntil).format('HH:mm:ss')}` : '等待来源探测' }}
</a-tag>
</div>
<a-switch v-model:checked="record.status" :checked-value="1" :un-checked-value="0" size="small" :disabled="loading" @change="() => switchSyncStatus(record)" />
</div>
<dl class="mobile-cookie-paths">
<template v-if="isRemoteStorage">
<div><dt>收藏</dt><dd>{{ record.webDavCollectPath || '未设置' }}</dd></div>
<div><dt>喜欢</dt><dd>{{ record.webDavFavoritePath || '未设置' }}</dd></div>
<div><dt>关注</dt><dd>{{ record.webDavFollowPath || '未设置' }}</dd></div>
<div><dt>合集</dt><dd>{{ record.webDavMixPath || '未设置' }}</dd></div>
<div><dt>短剧</dt><dd>{{ record.webDavSeriesPath || '未设置' }}</dd></div>
</template>
<template v-else>
<div><dt>收藏</dt><dd>{{ record.savePath || '未设置' }}</dd></div>
<div><dt>喜欢</dt><dd>{{ record.favSavePath || '未设置' }}</dd></div>
<div><dt>关注</dt><dd>{{ record.upSavePath || '未设置' }}</dd></div>
</template>
</dl>
<div class="mobile-cookie-actions">
<a-button :disabled="showModal || loading" @click="edit(record)"><EditFilled />编辑</a-button>
<a-button :disabled="loading || !record.id" danger @click="record.id && deleted(record.id)"><DeleteOutlined />删除</a-button>
</div>
</article>
</div>
</a-spin>
<a-pagination
v-if="pagination.total > 0"
class="cookie-pagination"
:simple="isMobile"
:current="pagination.current"
:page-size="pagination.defaultPageSize"
:total="pagination.total"
:show-size-changer="false"
@change="handlePageChange"
/>
</template>
<style scoped lang="less">
.ant-form-item {
margin-bottom: 10px;
}
.form-item-div {
width: 430px;
display: flex;
align-items: center;
}
.form-item-div-input {
width: 310px;
margin-left: 10px;
}
.sync-option-row {
display: flex;
align-items: center;
gap: 12px;
}
.path-alert {
flex: 1;
margin-bottom: 0;
}
.cookie-toolbar {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 8px 12px 12px;
}
.cookie-pagination {
display: flex;
justify-content: flex-end;
margin: 16px 12px 4px;
}
.mobile-cookie-list {
display: grid;
gap: 12px;
padding: 0 8px;
}
.mobile-cookie-card {
padding: 14px;
border: 1px solid #e8e8e8;
border-radius: 12px;
background: #fff;
box-shadow: 0 3px 14px rgba(15, 23, 42, 0.06);
}
.mobile-cookie-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.mobile-cookie-header h3 {
margin: 0 0 3px;
color: #1f2937;
font-size: 16px;
}
.mobile-cookie-header .status-on {
color: #52c41a;
}
.mobile-cookie-header .status-off {
color: #ff4d4f;
}
.mobile-cookie-paths {
margin: 12px 0;
}
.mobile-cookie-paths > div {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 8px;
padding: 3px 0;
font-size: 12px;
}
.mobile-cookie-paths dt {
color: #8c8c8c;
}
.mobile-cookie-paths dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #334155;
}
.mobile-cookie-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
:deep(.ant-input-textarea-input) {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(150, 150, 150, 0.2) transparent;
}
:deep(.ant-input-textarea-input)::-webkit-scrollbar {
width: 6px;
height: 6px;
}
:deep(.ant-input-textarea-input)::-webkit-scrollbar-track {
background: transparent;
}
:deep(.ant-input-textarea-input)::-webkit-scrollbar-thumb {
background: rgba(150, 150, 150, 0.2);
border-radius: 3px;
}
:deep(.ant-input-textarea-input)::-webkit-scrollbar-thumb:hover {
background: rgba(150, 150, 150, 0.4);
}
:deep(.ant-input-textarea-input)::-webkit-scrollbar-corner {
background: transparent;
}
:deep(.ant-input-disabled) {
background-color: #f5f5f5 !important;
color: #666 !important;
}
.alert-wrapper {
flex: 1;
margin-bottom: 0 !important;
}
.drawer-card-container.grid-container {
:deep(.ant-card-body) {
padding: 16px;
margin: 0;
}
display: flex;
flex-wrap: wrap;
gap: 20px;
box-sizing: border-box;
justify-content: flex-start;
min-height: 200px;
padding-left: 5px;
}
.drawer-card-container.grid-container:has(:only-child) :deep(.drawer-card-grid) {
width: calc(33.333% - 10.333px) !important;
min-width: 200px;
}
:deep(.drawer-card-grid) {
width: calc(33.333% - 10.333px) !important;
min-width: 200px;
margin: 5px !important;
border-radius: 12px !important;
padding: 16px !important;
box-sizing: border-box;
border: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
gap: 12px;
}
:deep(.drawer-card-grid .grid-cover.vertical-cover) {
width: 100% !important;
padding: 4px;
box-sizing: border-box;
border-radius: 8px;
overflow: hidden;
}
:deep(.drawer-card-grid .vertical-cover .ant-image) {
width: 100% !important;
height: auto !important;
display: block;
}
:deep(.drawer-card-grid .vertical-cover .ant-image-img) {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
}
:deep(.drawer-card-grid .grid-item.horizontal-item) {
width: 100% !important;
display: flex;
align-items: center;
margin: 0 !important;
padding: 4px 0;
}
:deep(.drawer-label) {
flex-shrink: 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
}
:deep(.drawer-card-grid .horizontal-item span) {
flex: 1;
font-size: 12px;
color: rgba(0, 0, 0, 0.88);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.drawer-card-grid .horizontal-item .save-path-input) {
flex: 1;
width: 100% !important;
font-size: 12px;
}
:deep(.drawer-card-grid .sync-switch) {
justify-content: space-between;
}
@media (max-width: 768px) {
.cookie-toolbar {
position: sticky;
z-index: 20;
top: 0;
padding: 10px;
margin-bottom: 10px;
border: 1px solid var(--mobile-border, #e5e7eb);
border-radius: 16px;
background: var(--mobile-card, #fff);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.05);
}
.cookie-toolbar .ant-btn {
flex: 1;
}
.cookie-pagination {
justify-content: center;
}
.sync-option-row {
align-items: stretch;
flex-direction: column;
gap: 8px;
}
.form-item-div {
width: 100%;
}
.form-item-div-input {
width: 100%;
max-width: none;
}
.mobile-cookie-list { padding: 0; }
.mobile-cookie-card {
border-color: var(--mobile-border, #e8e8e8);
border-radius: 16px;
background: var(--mobile-card, #fff);
}
.mobile-cookie-header h3 { color: var(--mobile-text, #1f2937); }
.mobile-cookie-paths dd { color: var(--mobile-text, #334155); }
:deep(.ant-modal-footer) {
position: sticky;
z-index: 5;
bottom: 0;
padding-bottom: max(10px, env(safe-area-inset-bottom));
background: var(--mobile-card, #fff);
}
:deep(.ant-form-item-label) {
padding-bottom: 4px;
text-align: left;
}
:deep(.ant-form-item-label > label) {
height: auto;
}
:deep(.drawer-card-grid) {
width: calc(50% - 10px) !important;
min-width: 180px;
}
.drawer-card-container.grid-container:has(:only-child) :deep(.drawer-card-grid) {
width: calc(50% - 10px) !important;
min-width: 180px;
}
}
@media (max-width: 480px) {
:deep(.drawer-card-grid) {
width: 100% !important;
min-width: 100%;
}
.drawer-card-container.grid-container:has(:only-child) :deep(.drawer-card-grid) {
width: 100% !important;
min-width: 100%;
}
}
html.dark-mode .drawer-card-container.grid-container .drawer-card-grid {
border-color: rgba(142, 140, 140, 0.1) !important;
background-color: #1a1a2e !important;
box-shadow: 0 6px 16px rgba(0, 20, 60, 0.4), 0 2px 6px rgba(100, 120, 255, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.05) !important;
transition: all 0.3s ease-in-out !important;
}
html.dark-mode .drawer-card-container.grid-container .drawer-card-grid:hover {
box-shadow: 0 8px 24px rgba(0, 30, 80, 0.5), 0 4px 12px rgba(120, 140, 255, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
transform: translateY(-2px) !important;
}
html.dark-mode .drawer-card-container.grid-container .drawer-card-grid .horizontal-item label {
color: #ffffff !important;
}
html.dark-mode .drawer-card-container.grid-container .drawer-card-grid .horizontal-item span {
color: rgba(255, 255, 255, 0.88) !important;
}
html.dark-mode .drawer-card-container.grid-container .drawer-card-grid .ant-input {
background-color: #2f2f2f !important;
border-color: #404040 !important;
color: rgba(255, 255, 255, 0.88) !important;
}
</style>
<style lang="less">
.full-modal {
.ant-modal {
max-width: 100%;
top: 0;
padding-bottom: 0;
margin: 0;
}
.ant-modal-content {
display: flex;
flex-direction: column;
height: calc(100vh);
}
.ant-modal-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
}
@media (max-width: 768px) {
.full-modal {
.ant-modal-header,
.ant-modal-footer {
padding-left: 16px;
padding-right: 16px;
}
.ant-modal-content {
height: 100dvh;
}
}
}
.ant-alert {
box-sizing: border-box;
margin: 0;
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1.5714285714;
list-style: none;
position: relative;
display: flex;
align-items: center;
border-radius: 8px;
}
.ant-alert-small {
padding: 8px 16px;
font-size: 12px;
border-radius: 4px;
}
.ant-alert-info {
background-color: #e6f4ff;
border: 1px solid #91caff;
}
.ant-alert-info .ant-alert-message {
color: #1677ff;
}
.ant-alert-success {
background-color: #daf1d3;
border: 1px solid #5bbc51;
}
.ant-alert-success .ant-alert-message {
color: #228b22;
}
.ant-alert-error {
background-color: #fff1f0;
border: 1px solid #ff4d4f;
}
.ant-alert-error .ant-alert-message {
color: #f5222d;
}
.ant-alert-warning {
background-color: #fffbe6;
border: 1px solid #ffe58f;
}
.ant-alert-warning .ant-alert-message {
color: #faad14;
}
.ant-alert-info .ant-alert-icon {
color: #1677ff;
}
.ant-alert-warning .ant-alert-icon {
color: #faad14;
}
.drawer-scroll-container {
width: 100%;
height: calc(100vh - 130px) !important;
min-height: 300px !important;
overflow-y: auto !important;
overflow-x: hidden !important;
padding: 0 8px;
box-sizing: border-box;
position: relative;
z-index: 1;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
html.dark-mode &::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
}
.drawer-loading {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
width: 100%;
}
.loading-more {
display: flex;
justify-content: center;
align-items: center;
padding: 16px 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
html.dark-mode & {
color: rgba(255, 255, 255, 0.6);
}
}
.no-more-data {
text-align: center;
padding: 16px 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
html.dark-mode & {
color: rgba(255, 255, 255, 0.45);
}
}
</style>