feat: add fnOS packaging, storage workflows and release pipeline
This commit is contained in:
+2
-2
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
/* 2. Vue 挂载后:#stepin-app 非空(包含 Vue 组件),覆盖为普通块级元素,解除约束 */
|
||||
#stepin-app:not(:has(.breath-ring)) {
|
||||
#stepin-app.app-mounted {
|
||||
/* 核心:取消 flex 布局,改为普通块级元素 */
|
||||
display: block !important;
|
||||
/* 覆盖原有 flex 相关属性,解除居中约束 */
|
||||
@@ -146,4 +146,4 @@
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+2
-1
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "stepin-template",
|
||||
"private": true,
|
||||
"version": "1.2.0-preview",
|
||||
"version": "0.2.25",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"api": "node service/index.js",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"test:mobile-contract": "node --test tests/mobile-contract.test.mjs",
|
||||
"preview": "vite preview",
|
||||
"deploy": "yarn build && gh-pages -d dist -b pages -r https://gitee.com/stepui/stepin-template.git",
|
||||
"deploy:github": "yarn build --mode github && gh-pages -d dist -b master -r git@github.com:stepui/stepui.github.io.git",
|
||||
|
||||
+189
-3
@@ -16,11 +16,13 @@
|
||||
</stepin-view>
|
||||
</ThemeProvider>
|
||||
<my-personal ref="personalRef" />
|
||||
<MobileTopBar v-if="showMobileNav" @profile="openPersonalSettings" />
|
||||
<MobileBottomNav v-if="showMobileNav" @profile="openPersonalSettings" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { reactive, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAccountStore, useMenuStore, useSettingStore, storeToRefs, useApiStore } from '@/store';
|
||||
import avatar from '@/assets/avatar.png';
|
||||
import { PageFooter, HeaderActions } from '@/components/layout';
|
||||
@@ -29,15 +31,34 @@ import { LoginModal } from '@/pages/login';
|
||||
import { MyPersonal } from '@/pages/personal';
|
||||
import { configTheme, themeList } from '@/theme';
|
||||
import { ThemeProvider } from 'stepin';
|
||||
import MobileBottomNav from '@/components/layout/MobileBottomNav.vue';
|
||||
import MobileTopBar from '@/components/layout/MobileTopBar.vue';
|
||||
import { useMobileViewport } from '@/utils/useMobileViewport';
|
||||
// logout,profile
|
||||
const { logout } = useAccountStore();
|
||||
const showPersonalDrawer = ref<boolean>(false);
|
||||
const personalRef = ref(null);
|
||||
const personalRef = ref<any>(null);
|
||||
const emailRef = ref(null);
|
||||
const open = ref(false);
|
||||
|
||||
const showSetting = ref(false);
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { isMobileViewport } = useMobileViewport();
|
||||
const showMobileNav = computed(() =>
|
||||
isMobileViewport.value && !['/login', '/init', '/mobile'].includes(route.path)
|
||||
);
|
||||
const openPersonalSettings = () => personalRef.value?.show(true);
|
||||
|
||||
watch(showMobileNav, (visible) => {
|
||||
document.body.classList.toggle('has-mobile-bottom-nav', visible);
|
||||
document.body.classList.toggle('mobile-app-shell', visible);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
open.value = false;
|
||||
showSetting.value = false;
|
||||
});
|
||||
|
||||
// useMenuStore().getMenuList();
|
||||
|
||||
@@ -88,6 +109,11 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.classList.remove('has-mobile-bottom-nav');
|
||||
document.body.classList.remove('mobile-app-shell');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@@ -132,6 +158,166 @@ body {
|
||||
height: 100vh;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
html,
|
||||
body,
|
||||
#stepin-app,
|
||||
.stepin-view {
|
||||
width: 100%;
|
||||
height: var(--app-viewport-height, 100dvh) !important;
|
||||
min-height: 0;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
body.mobile-app-shell {
|
||||
--mobile-surface: #f6f7fb;
|
||||
--mobile-card: #fff;
|
||||
--mobile-border: #e9eaf2;
|
||||
--mobile-text: #172033;
|
||||
--mobile-subtext: #667085;
|
||||
--mobile-primary: #722ed1;
|
||||
background: var(--mobile-surface);
|
||||
}
|
||||
|
||||
body.mobile-app-shell .stepin-layout {
|
||||
grid-template-columns: minmax(0, 1fr) !important;
|
||||
grid-template-rows: minmax(0, 1fr) !important;
|
||||
}
|
||||
|
||||
body.mobile-app-shell .stepin-layout-header,
|
||||
body.mobile-app-shell .stepin-layout-side,
|
||||
body.mobile-app-shell .stepin-tabs-view-head,
|
||||
body.mobile-app-shell .stepin-tabs-view-content-footer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.mobile-app-shell .stepin-layout-content {
|
||||
grid-row: 1 !important;
|
||||
grid-column: 1 !important;
|
||||
height: var(--app-viewport-height, 100dvh);
|
||||
padding-top: calc(54px + env(safe-area-inset-top));
|
||||
padding-bottom: calc(66px + env(safe-area-inset-bottom));
|
||||
background: var(--mobile-surface);
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body.mobile-app-shell .stepin-view-main,
|
||||
body.mobile-app-shell .stepin-tabs-view,
|
||||
body.mobile-app-shell .stepin-tabs-view-content {
|
||||
height: auto !important;
|
||||
min-height: 100%;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
body.mobile-app-shell .stepin-tabs-view-content-main {
|
||||
min-height: 100%;
|
||||
padding: 10px !important;
|
||||
border-radius: 0 !important;
|
||||
background: var(--mobile-surface) !important;
|
||||
}
|
||||
|
||||
body.mobile-app-shell button,
|
||||
body.mobile-app-shell a,
|
||||
body.mobile-app-shell .ant-btn,
|
||||
body.mobile-app-shell .ant-switch,
|
||||
body.mobile-app-shell .ant-checkbox-wrapper,
|
||||
body.mobile-app-shell .ant-radio-wrapper {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
body.mobile-app-shell .ant-btn:not(.ant-btn-circle) {
|
||||
min-height: 44px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
body.mobile-app-shell .ant-modal,
|
||||
body.mobile-app-shell .ant-drawer-content-wrapper {
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
|
||||
body.mobile-app-shell .ant-drawer-left .ant-drawer-content-wrapper,
|
||||
body.mobile-app-shell .ant-drawer-right .ant-drawer-content-wrapper {
|
||||
width: 100vw !important;
|
||||
}
|
||||
|
||||
body.mobile-app-shell .ant-modal {
|
||||
width: calc(100vw - 20px) !important;
|
||||
min-width: 0 !important;
|
||||
margin: 10px auto;
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
body.mobile-app-shell .ant-drawer-header {
|
||||
padding-top: max(16px, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
body.mobile-app-shell .ant-drawer-body {
|
||||
padding-bottom: max(20px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
body.mobile-app-shell .mobile-full-modal .ant-modal {
|
||||
top: 0 !important;
|
||||
width: 100vw !important;
|
||||
max-width: none !important;
|
||||
height: var(--app-viewport-height, 100dvh);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body.mobile-app-shell .mobile-full-modal .ant-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: var(--app-viewport-height, 100dvh);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
body.mobile-app-shell .mobile-full-modal .ant-modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: max(20px, env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
html.dark-mode body.mobile-app-shell {
|
||||
--mobile-surface: #11121d;
|
||||
--mobile-card: #1a1b2b;
|
||||
--mobile-border: #303247;
|
||||
--mobile-text: rgba(255, 255, 255, 0.9);
|
||||
--mobile-subtext: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
html.dark-mode body.mobile-app-shell .mobile-task-card,
|
||||
html.dark-mode body.mobile-app-shell .mobile-record-card,
|
||||
html.dark-mode body.mobile-app-shell .mobile-cookie-card,
|
||||
html.dark-mode body.mobile-app-shell .custom-card,
|
||||
html.dark-mode body.mobile-app-shell .query-container--mobile,
|
||||
html.dark-mode body.mobile-app-shell .search-tab-container {
|
||||
border-color: var(--mobile-border) !important;
|
||||
background: var(--mobile-card) !important;
|
||||
color: var(--mobile-text) !important;
|
||||
}
|
||||
|
||||
html.dark-mode body.mobile-app-shell .mobile-record-title,
|
||||
html.dark-mode body.mobile-app-shell .mobile-cookie-header h3,
|
||||
html.dark-mode body.mobile-app-shell .mobile-cookie-paths dd,
|
||||
html.dark-mode body.mobile-app-shell .card-name {
|
||||
color: var(--mobile-text) !important;
|
||||
}
|
||||
|
||||
html.dark-mode body.mobile-app-shell .filter-switch,
|
||||
html.dark-mode body.mobile-app-shell .mobile-filter-trigger,
|
||||
html.dark-mode body.mobile-app-shell .mobile-task-filter-trigger {
|
||||
border-color: var(--mobile-border) !important;
|
||||
background: #242538 !important;
|
||||
color: #c4b5fd !important;
|
||||
}
|
||||
}
|
||||
|
||||
.stepin-img-checkbox {
|
||||
@apply transition-transform;
|
||||
&:hover {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
class="failed-record-modal"
|
||||
title="删除全部迁移失败视频记录"
|
||||
ok-text="确认删除记录"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="removing"
|
||||
:ok-button-props="{ danger: true, disabled: loading || !preview?.canExecute }"
|
||||
destroy-on-close
|
||||
@ok="submit"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<div class="failed-record-content">
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
message="影响全部历史迁移任务"
|
||||
description="只删除匹配的视频数据库记录。旧本地文件、已上传的 WebDAV 文件和永久排除设置都不会被删除。"
|
||||
/>
|
||||
|
||||
<div v-if="preview" class="failed-record-stats">
|
||||
<div><span>失败条目</span><strong>{{ preview.failedItemCount }}</strong></div>
|
||||
<div><span>涉及视频</span><strong>{{ preview.distinctVideoCount }}</strong></div>
|
||||
<div class="danger"><span>将删除记录</span><strong>{{ preview.eligibleRecordCount }}</strong></div>
|
||||
<div><span>记录已不存在</span><strong>{{ preview.alreadyMissingCount }}</strong></div>
|
||||
<div><span>已变化,跳过</span><strong>{{ preview.changedRecordCount }}</strong></div>
|
||||
<div><span>永久排除,跳过</span><strong>{{ preview.permanentlyExcludedCount }}</strong></div>
|
||||
<div v-if="preview.invalidSnapshotCount"><span>快照损坏,跳过</span><strong>{{ preview.invalidSnapshotCount }}</strong></div>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-for="error in preview?.errors || []"
|
||||
:key="`error-${error}`"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
/>
|
||||
<a-alert
|
||||
v-for="warning in preview?.warnings || []"
|
||||
:key="`warning-${warning}`"
|
||||
type="info"
|
||||
show-icon
|
||||
:message="warning"
|
||||
/>
|
||||
<p class="failed-record-footnote">
|
||||
删除后不会立即创建下载任务。仍可被当前授权账号和同步范围扫描到的作品,会随着系统下一次正常同步重新下载到当前存储。
|
||||
</p>
|
||||
</div>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useApiStore } from '@/store';
|
||||
|
||||
interface FailedRecordPreview {
|
||||
canExecute: boolean;
|
||||
failedItemCount: number;
|
||||
distinctVideoCount: number;
|
||||
eligibleRecordCount: number;
|
||||
alreadyMissingCount: number;
|
||||
changedRecordCount: number;
|
||||
permanentlyExcludedCount: number;
|
||||
invalidSnapshotCount: number;
|
||||
confirmationToken?: string;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ (event: 'completed', result: any): void }>();
|
||||
const api = useApiStore();
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const removing = ref(false);
|
||||
const preview = ref<FailedRecordPreview | null>(null);
|
||||
|
||||
const requireSuccess = (response: any, fallback: string) => {
|
||||
if (!response || response.code !== 0) throw new Error(response?.message || fallback);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const loadPreview = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
preview.value = requireSuccess(await api.StorageMigrationFailedRecordsPreview(), '获取迁移失败记录摘要失败');
|
||||
} catch (error: any) {
|
||||
preview.value = null;
|
||||
message.error(error?.message || '获取迁移失败记录摘要失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
preview.value = null;
|
||||
await loadPreview();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!preview.value?.canExecute || !preview.value.confirmationToken) return;
|
||||
removing.value = true;
|
||||
try {
|
||||
const result = requireSuccess(
|
||||
await api.RemoveStorageMigrationFailedRecords(preview.value.confirmationToken),
|
||||
'删除迁移失败视频记录失败',
|
||||
);
|
||||
message.success(result?.message || '迁移失败视频记录已处理');
|
||||
visible.value = false;
|
||||
emit('completed', result);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '删除迁移失败视频记录失败');
|
||||
await loadPreview();
|
||||
} finally {
|
||||
removing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.failed-record-content {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.failed-record-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.failed-record-stats > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color, #e8e8e8);
|
||||
border-radius: 12px;
|
||||
background: var(--component-background, #fff);
|
||||
}
|
||||
|
||||
.failed-record-stats span {
|
||||
color: var(--text-color-secondary, #666);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.failed-record-stats strong {
|
||||
font-size: 21px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.failed-record-stats .danger strong {
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.failed-record-footnote {
|
||||
margin: 0;
|
||||
color: var(--text-color-secondary, #666);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.failed-record-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.failed-record-stats > div {
|
||||
padding: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.failed-record-modal {
|
||||
width: min(640px, calc(100vw - 24px)) !important;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.failed-record-modal .ant-modal-body {
|
||||
max-height: calc(100dvh - 190px);
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.failed-record-modal .ant-modal-footer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.failed-record-modal .ant-modal-footer .ant-btn {
|
||||
min-height: 42px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
:title="mode === 'confirm' ? '确认清理异常目录' : '扫描 OpenList 异常目录'"
|
||||
:width="680"
|
||||
:ok-text="mode === 'confirm' ? '清理已确认的空目录' : '创建扫描任务'"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="submitting"
|
||||
:ok-button-props="{ danger: mode === 'confirm', disabled: okDisabled }"
|
||||
wrap-class-name="directory-repair-modal"
|
||||
@ok="submit"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<template v-if="mode === 'preflight'">
|
||||
<p class="repair-intro">
|
||||
修复因目录大小写不一致产生的时间后缀目录。系统会先逐个检查,只把确认为空的目录交给你二次确认。
|
||||
</p>
|
||||
|
||||
<a-alert
|
||||
v-if="preflight?.errors?.length"
|
||||
type="error"
|
||||
show-icon
|
||||
message="当前不能创建扫描任务"
|
||||
:description="preflight.errors.join(';')"
|
||||
/>
|
||||
|
||||
<dl v-if="preflight" class="repair-facts">
|
||||
<div>
|
||||
<dt>请求目录</dt>
|
||||
<dd>{{ preflight.requestedPath || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>实际目录</dt>
|
||||
<dd>{{ preflight.canonicalPath || '尚未识别' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>候选目录</dt>
|
||||
<dd><strong>{{ preflight.candidateCount || 0 }}</strong> 个</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<a-alert
|
||||
v-if="preflight?.canStart"
|
||||
class="repair-notice"
|
||||
type="info"
|
||||
show-icon
|
||||
message="创建任务只会开始检查,不会立即删除"
|
||||
description="候选名称必须严格匹配时间后缀格式;规范目录、非空目录和不匹配目录都不会删除。扫描完成后还需要再次确认。"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="detail?.task">
|
||||
<p class="repair-intro">
|
||||
扫描已结束。下面只有“确认空目录”会进入清理;每个目录在删除前还会再次检查一次。
|
||||
</p>
|
||||
<dl class="repair-counts">
|
||||
<div><dt>候选</dt><dd>{{ detail.task.totalCount }}</dd></div>
|
||||
<div><dt>确认空目录</dt><dd class="safe-count">{{ detail.task.emptyCount }}</dd></div>
|
||||
<div><dt>非空保留</dt><dd>{{ detail.task.skippedCount }}</dd></div>
|
||||
<div><dt>检查失败</dt><dd class="failure-count">{{ detail.task.failedCount }}</dd></div>
|
||||
<div><dt>已不存在</dt><dd>{{ detail.task.missingCount }}</dd></div>
|
||||
</dl>
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
message="这是不可逆的目录删除操作"
|
||||
description="只删除复检后仍为空的时间后缀目录,不删除视频记录、规范目录或任何媒体文件。失败和非空目录会原样保留。"
|
||||
/>
|
||||
<label class="confirmation-field">
|
||||
<span>输入“清理空目录”以确认</span>
|
||||
<a-input v-model:value="confirmationText" autocomplete="off" placeholder="清理空目录" />
|
||||
</label>
|
||||
</template>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useApiStore } from '@/store';
|
||||
|
||||
const emit = defineEmits<{ (event: 'changed'): void }>();
|
||||
const api = useApiStore();
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const mode = ref<'preflight' | 'confirm'>('preflight');
|
||||
const preflight = ref<any>(null);
|
||||
const detail = ref<any>(null);
|
||||
const taskId = ref('');
|
||||
const confirmationText = ref('');
|
||||
|
||||
const requireSuccess = (response: any, fallback: string) => {
|
||||
if (!response || response.code !== 0) throw new Error(response?.message || fallback);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const okDisabled = computed(() => loading.value || submitting.value || (mode.value === 'preflight'
|
||||
? !preflight.value?.canStart
|
||||
: !detail.value?.canConfirmCleanup || confirmationText.value !== '清理空目录'));
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
mode.value = 'preflight';
|
||||
preflight.value = null;
|
||||
detail.value = null;
|
||||
confirmationText.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
preflight.value = requireSuccess(
|
||||
await api.OpenListDirectoryRepairPreflight('/collect/Kk'),
|
||||
'异常目录预检失败',
|
||||
);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '异常目录预检失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openConfirm = async (id: string) => {
|
||||
visible.value = true;
|
||||
mode.value = 'confirm';
|
||||
taskId.value = id;
|
||||
detail.value = null;
|
||||
confirmationText.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = requireSuccess(await api.OpenListDirectoryRepairDetail(id), '获取目录扫描结果失败');
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '获取目录扫描结果失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (okDisabled.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (mode.value === 'preflight') {
|
||||
const created = requireSuccess(await api.CreateOpenListDirectoryRepair({
|
||||
logicalPath: preflight.value.requestedPath,
|
||||
configurationFingerprint: preflight.value.configurationFingerprint,
|
||||
}), '创建目录扫描任务失败');
|
||||
message.success(`已创建扫描任务,共 ${created?.task?.totalCount ?? preflight.value.candidateCount} 个候选目录`);
|
||||
} else {
|
||||
requireSuccess(await api.ConfirmOpenListDirectoryRepair(
|
||||
taskId.value,
|
||||
detail.value.confirmationToken,
|
||||
), '提交目录清理失败');
|
||||
message.success('空目录清理任务已提交');
|
||||
}
|
||||
visible.value = false;
|
||||
emit('changed');
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '操作失败');
|
||||
if (mode.value === 'confirm') await openConfirm(taskId.value);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, openConfirm });
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.repair-intro { max-width: 68ch; margin: 0 0 18px; color: #475569; line-height: 1.7; }
|
||||
.repair-facts { display: grid; gap: 0; margin: 16px 0; border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; }
|
||||
.repair-facts > div { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; padding: 12px 14px; background: #fff; }
|
||||
.repair-facts > div + div { border-top: 1px solid #eef2f7; }
|
||||
.repair-facts dt { color: #64748b; }
|
||||
.repair-facts dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #0f172a; }
|
||||
.repair-notice { margin-top: 16px; }
|
||||
.repair-counts { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; margin: 16px 0; }
|
||||
.repair-counts > div { padding: 11px 8px; border-radius: 12px; background: #f8fafc; text-align: center; }
|
||||
.repair-counts dt { color: #64748b; font-size: 12px; }
|
||||
.repair-counts dd { margin: 3px 0 0; color: #0f172a; font-size: 20px; font-weight: 650; }
|
||||
.repair-counts .safe-count { color: #237804; }
|
||||
.repair-counts .failure-count { color: #b42318; }
|
||||
.confirmation-field { display: grid; gap: 7px; margin-top: 18px; color: #334155; font-weight: 600; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.repair-facts > div { grid-template-columns: 82px minmax(0, 1fr); padding: 11px 10px; }
|
||||
.repair-counts { grid-template-columns: repeat(2, 1fr); }
|
||||
.repair-counts > div:first-child { grid-column: 1 / -1; }
|
||||
:global(.directory-repair-modal .ant-modal) { width: calc(100% - 20px) !important; max-width: none; margin: 10px auto; top: 0; padding-bottom: 10px; }
|
||||
:global(.directory-repair-modal .ant-modal-body) { max-height: calc(100vh - 160px); overflow-y: auto; padding: 18px 16px; }
|
||||
:global(.directory-repair-modal .ant-modal-footer) { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
:global(.directory-repair-modal .ant-modal-footer .ant-btn) { min-height: 44px; margin: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<nav class="mobile-bottom-nav" aria-label="移动端主导航">
|
||||
<button
|
||||
v-for="item in primaryItems"
|
||||
:key="item.path"
|
||||
type="button"
|
||||
class="mobile-nav-item"
|
||||
:class="{ active: route.path === item.path || route.path.startsWith(`${item.path}/`) }"
|
||||
@click="go(item.path)"
|
||||
>
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
<button type="button" class="mobile-nav-item" :class="{ active: moreActive }" @click="moreVisible = true">
|
||||
<AppstoreOutlined />
|
||||
<span>更多</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<a-drawer
|
||||
v-model:visible="moreVisible"
|
||||
title="更多功能"
|
||||
placement="bottom"
|
||||
:height="420"
|
||||
:z-index="1300"
|
||||
destroy-on-close
|
||||
class="mobile-more-drawer"
|
||||
>
|
||||
<div class="mobile-more-grid">
|
||||
<button v-for="item in moreItems" :key="item.path" type="button" @click="go(item.path)">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
<button type="button" @click="openProfile">
|
||||
<UserOutlined />
|
||||
<span>个人设置</span>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
<button type="button" class="danger" @click="logoutNow">
|
||||
<LogoutOutlined />
|
||||
<span>退出登录</span>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAccountStore } from '@/store';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
DashboardOutlined,
|
||||
HeartOutlined,
|
||||
LogoutOutlined,
|
||||
ProfileOutlined,
|
||||
RightOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
SwapOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const emit = defineEmits<{ (event: 'profile'): void }>();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const moreVisible = ref(false);
|
||||
|
||||
const primaryItems = [
|
||||
{ path: '/dashboard', label: '看板', icon: DashboardOutlined },
|
||||
{ path: '/workplace', label: '记录', icon: SwapOutlined },
|
||||
{ path: '/tasks', label: '任务', icon: ProfileOutlined },
|
||||
{ path: '/follow', label: '关注', icon: HeartOutlined },
|
||||
];
|
||||
|
||||
const moreItems = [
|
||||
{ path: '/cok', label: '抖音授权', icon: SafetyCertificateOutlined },
|
||||
{ path: '/set', label: '系统配置与存储', icon: SettingOutlined },
|
||||
{ path: '/logs', label: '系统日志', icon: UnorderedListOutlined },
|
||||
];
|
||||
|
||||
const moreActive = computed(() => moreItems.some((item) => route.path === item.path || route.path.startsWith(`${item.path}/`)));
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
moreVisible.value = false;
|
||||
});
|
||||
|
||||
const go = (path: string) => {
|
||||
moreVisible.value = false;
|
||||
if (route.path !== path) router.push(path);
|
||||
};
|
||||
|
||||
const openProfile = () => {
|
||||
moreVisible.value = false;
|
||||
emit('profile');
|
||||
};
|
||||
|
||||
const logoutNow = async () => {
|
||||
moreVisible.value = false;
|
||||
await useAccountStore().logout();
|
||||
await router.push('/login');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.mobile-bottom-nav {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 900;
|
||||
display: none;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
min-height: calc(62px + env(safe-area-inset-bottom));
|
||||
padding: 5px 8px calc(5px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.1);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 -6px 24px rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.mobile-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
padding: 3px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mobile-nav-item .anticon {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.mobile-nav-item.active {
|
||||
color: #722ed1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-more-drawer .ant-drawer-content {
|
||||
border-radius: 22px 22px 0 0;
|
||||
}
|
||||
|
||||
.mobile-more-drawer .ant-drawer-body {
|
||||
padding: 4px 16px calc(18px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.mobile-more-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mobile-more-grid button {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 20px;
|
||||
align-items: center;
|
||||
min-height: 52px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-more-grid button > .anticon:first-child {
|
||||
color: #722ed1;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.mobile-more-grid button > .anticon:last-child {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mobile-more-grid button.danger,
|
||||
.mobile-more-grid button.danger > .anticon:first-child {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-bottom-nav {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
body.has-mobile-bottom-nav .stepin-tabs-view-content-main,
|
||||
body.has-mobile-bottom-nav .stepin-view-main {
|
||||
padding-bottom: calc(76px + env(safe-area-inset-bottom)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark-mode .mobile-bottom-nav {
|
||||
border-top-color: #303247;
|
||||
background: rgba(22, 22, 39, 0.96);
|
||||
}
|
||||
|
||||
html.dark-mode .mobile-more-grid button {
|
||||
border-bottom-color: #303247;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<header class="mobile-top-bar">
|
||||
<div class="mobile-top-bar__brand">
|
||||
<img src="/logo.png" alt="" aria-hidden="true" />
|
||||
<div>
|
||||
<span>抖小云</span>
|
||||
<strong>{{ title }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="mobile-top-bar__profile" aria-label="打开个人设置" @click="$emit('profile')">
|
||||
<UserOutlined />
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { UserOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
defineEmits<{ (event: 'profile'): void }>();
|
||||
const route = useRoute();
|
||||
const title = computed(() => String(route.meta.mobileTitle || route.name || '抖小云'));
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.mobile-top-bar {
|
||||
position: fixed;
|
||||
z-index: 900;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: calc(54px + env(safe-area-inset-top));
|
||||
padding: env(safe-area-inset-top) 14px 0;
|
||||
border-bottom: 1px solid rgba(99, 102, 241, 0.1);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 5px 18px rgba(15, 23, 42, 0.05);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.mobile-top-bar__brand { display: flex; align-items: center; min-width: 0; gap: 10px; }
|
||||
.mobile-top-bar__brand img { width: 34px; height: 34px; border-radius: 10px; }
|
||||
.mobile-top-bar__brand div { display: grid; min-width: 0; }
|
||||
.mobile-top-bar__brand span { color: #8b5cf6; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; }
|
||||
.mobile-top-bar__brand strong { overflow: hidden; color: #172033; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mobile-top-bar__profile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: #f4f1ff;
|
||||
color: #722ed1;
|
||||
font-size: 20px;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.mobile-top-bar__profile:active { background: #e9e1ff; transform: scale(0.96); }
|
||||
|
||||
@media (max-width: 768px) { .mobile-top-bar { display: flex; } }
|
||||
html.dark-mode .mobile-top-bar { border-bottom-color: rgba(255, 255, 255, 0.08); background: rgba(22, 22, 39, 0.95); }
|
||||
html.dark-mode .mobile-top-bar__brand strong { color: rgba(255, 255, 255, 0.9); }
|
||||
html.dark-mode .mobile-top-bar__profile { background: #2d2545; color: #c4b5fd; }
|
||||
</style>
|
||||
@@ -24,3 +24,4 @@ app.config.errorHandler = function (err) {
|
||||
console.error('未捕获的异常,', err);
|
||||
};
|
||||
app.mount('#stepin-app');
|
||||
document.getElementById('stepin-app')?.classList.add('app-mounted');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { getBase64 } from '@/utils/file';
|
||||
import { FormInstance } from 'ant-design-vue';
|
||||
import { reactive, ref, onMounted, UnwrapRef, watch, nextTick } from 'vue';
|
||||
import { computed, reactive, ref, onMounted, onBeforeUnmount, UnwrapRef, watch, nextTick } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import {
|
||||
@@ -18,20 +18,22 @@ import { useApiStore } from '@/store';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { StarOutlined, StarFilled, StarTwoTone } from '@ant-design/icons-vue';
|
||||
const columns = ref([]);
|
||||
columns.value = [
|
||||
{
|
||||
title: 'Cookie名称',
|
||||
dataIndex: 'userName',
|
||||
width: 180,
|
||||
},
|
||||
{ title: 'Cookie状态', dataIndex: 'statusMsg' },
|
||||
{ title: '收藏路径', dataIndex: 'savePath' },
|
||||
{ title: '喜欢路径', dataIndex: 'favSavePath' },
|
||||
{ title: '博主路径', dataIndex: 'upSavePath' },
|
||||
|
||||
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;
|
||||
@@ -47,6 +49,8 @@ type DataItem = {
|
||||
favSavePath?: string;
|
||||
secUserId?: string;
|
||||
status?: number;
|
||||
statusMsg?: string;
|
||||
statusCode?: number;
|
||||
_isNew?: boolean;
|
||||
upSecUserIdsJson?: UpSecUserIdItem[];
|
||||
upSecUserIds?: string;
|
||||
@@ -59,6 +63,16 @@ type DataItem = {
|
||||
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);
|
||||
@@ -125,6 +139,11 @@ const newCookie = (cookie?: DataItem) => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -258,7 +277,7 @@ const switchSyncStatus = (record: DataItem) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
const StatusDict = {
|
||||
const StatusDict: Record<number, string> = {
|
||||
0: '同步已停止',
|
||||
1: '同步已开启',
|
||||
};
|
||||
@@ -277,9 +296,25 @@ const removeRow = (index: number) => {
|
||||
}
|
||||
};
|
||||
const rowCount = 10;
|
||||
|
||||
onMounted(() => {
|
||||
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);
|
||||
@@ -475,8 +510,8 @@ const switchdownCollect = (e: any) => {
|
||||
</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="{ span: 3 }" :wrapperCol="{ span: 20 }">
|
||||
<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>
|
||||
@@ -497,99 +532,105 @@ const switchdownCollect = (e: any) => {
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="下载收藏视频" name="downCollect">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<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="savePath" noStyle>
|
||||
<a-input v-model:value="form.savePath" placeholder='请输入容器路径' class="form-item-div-input" />
|
||||
<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="开启后自动下载默认收藏夹视频,记得填写映射路径(容器内部路径)" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
|
||||
<a-alert :message="isRemoteStorage ? '开启后写入目标基础目录下的这个 OpenList 相对路径;留空保存时会自动生成。' : '开启后自动下载默认收藏夹视频,记得填写映射路径(容器内部路径)。'" type="info" size="small" class="path-alert" />
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="form.savePath && form.savePath.length>0" label="自定义收藏夹" name="useCollectFolder">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<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-model:value="form.savePath" :disabled="form.useCollectFolder&&form.downCollect" placeholder="" class="form-item-div-input" />
|
||||
<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" style="flex: 1; margin-bottom: 0;" />
|
||||
<a-alert message="开启后按收藏夹分类同步,存储根路径与默认收藏路径一致。" :type="form.useCollectFolder?'error':'info'" size="small" class="path-alert" />
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="下载喜欢视频" name="downFavorite">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<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="favSavePath" noStyle>
|
||||
<a-input v-model:value="form.favSavePath" placeholder='请输入容器路径' class="form-item-div-input" />
|
||||
<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="开启后自动下载喜欢(点赞)的视频,记得填写映射路径(容器内部路径)" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
|
||||
<a-alert :message="isRemoteStorage ? '喜欢的视频会写入目标基础目录下的这个 OpenList 相对路径。' : '开启后自动下载喜欢(点赞)的视频,记得填写映射路径(容器内部路径)。'" type="info" size="small" class="path-alert" />
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="下载关注视频" name="downFollowd">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<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="upSavePath" noStyle>
|
||||
<a-input v-model:value="form.upSavePath" placeholder='请输入容器路径' class="form-item-div-input" />
|
||||
<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="开启后自动下载关注的博主视频,记得填写映射路径(容器内部路径)" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
|
||||
<a-alert :message="isRemoteStorage ? '关注博主的视频会写入目标基础目录下的这个 OpenList 相对路径。' : '开启后自动下载关注的博主视频,记得填写映射路径(容器内部路径)。'" type="info" size="small" class="path-alert" />
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="下载合集视频" name="downMix">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<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="mixPath" noStyle>
|
||||
<a-input v-model:value="form.mixPath" class="form-item-div-input" placeholder='默认使用收藏夹路径' />
|
||||
<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="开启后自动下载收藏的合集视频(还需要开启合集同步开关,不填目录径默认存储到收藏目录,设置后记得docker里面加映射,注意:付费视频下载后无法播放)" :type="form.downMix?'error':'info'" size="small" style="flex: 1; margin-bottom: 0;" />
|
||||
<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 style="display: flex; align-items: center; gap: 12px;">
|
||||
<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="seriesPath" noStyle>
|
||||
<a-input v-model:value="form.seriesPath" placeholder='默认使用收藏夹路径' class="form-item-div-input" />
|
||||
<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="开启后自动下载收藏的短剧视频(还需要开启短剧同步开关,不填目录径默认存储到收藏目录,设置后记得docker里面加映射,注意:付费视频下载后无法播放)" :type="form.downSeries?'error':'info'" size="small" style="flex: 1; margin-bottom: 0;" />
|
||||
<a-alert :message="isRemoteStorage ? '收藏的短剧会同步到该 OpenList 相对路径;付费视频下载后可能无法播放。' : '开启后自动下载收藏的短剧视频;不填路径默认使用收藏目录,付费视频下载后可能无法播放。'" :type="form.downSeries?'error':'info'" size="small" class="path-alert" />
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
@@ -602,7 +643,7 @@ const switchdownCollect = (e: any) => {
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-drawer :title="getDrawerTypeName()+'配置'" v-model:visible="showDrawer" placement="right" width="800px" :z-index="10010" :mask-z-index="10009" @close="closeDrawer" class="common-drawer">
|
||||
<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>
|
||||
@@ -654,26 +695,29 @@ const switchdownCollect = (e: any) => {
|
||||
</div>
|
||||
</a-drawer>
|
||||
|
||||
<a-table v-bind="$attrs" :columns="columns" :dataSource="dataSource" :pagination="false">
|
||||
<template #title>
|
||||
<div class="flex justify-end pr-4">
|
||||
<a-button type="primary" @click="GetRecords()" :loading="formLoading" class="mr-2">
|
||||
<template #icon>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
查询
|
||||
</a-button>
|
||||
<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-button type="primary" @click="addNew" :loading="formLoading">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
新增
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<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 === 'status'">
|
||||
<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="{
|
||||
@@ -704,6 +748,54 @@ const switchdownCollect = (e: any) => {
|
||||
</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">
|
||||
@@ -711,12 +803,100 @@ const switchdownCollect = (e: any) => {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.form-item-div {
|
||||
width: 300px;
|
||||
width: 430px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.form-item-div-input {
|
||||
width: 180px;
|
||||
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;
|
||||
@@ -835,6 +1015,68 @@ const switchdownCollect = (e: any) => {
|
||||
}
|
||||
|
||||
@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;
|
||||
@@ -901,6 +1143,22 @@ html.dark-mode .drawer-card-container.grid-container .drawer-card-grid .ant-inpu
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1015,4 +1273,4 @@ html.dark-mode .drawer-card-container.grid-container .drawer-card-grid .ant-inpu
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+948
-101
File diff suppressed because it is too large
Load Diff
@@ -157,23 +157,9 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
function onLoginSuccess() {
|
||||
if (isMobileBrowser()) router.push('/mobile');
|
||||
else router.push('/dashboard');
|
||||
router.push('/dashboard');
|
||||
}
|
||||
|
||||
const isMobileBrowser = (): boolean => {
|
||||
if (typeof navigator === 'undefined' || typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const mobileUA = /android|iphone|ipod|blackberry|windows phone|iemobile|opera mini/i.test(userAgent);
|
||||
const isTablet = /ipad|tablet|playbook|kindle|android 3\.|android 4\.[0-3]/.test(userAgent);
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
const isMobileScreen = window.innerWidth <= 768 && window.innerHeight <= 1024;
|
||||
|
||||
return (mobileUA && !isTablet && isTouchDevice) || (isMobileScreen && isTouchDevice);
|
||||
};
|
||||
|
||||
function onLoginFail(reason: string, fields: any) {
|
||||
console.log('登录失败:', reason, fields);
|
||||
message.error(reason || '登录失败,请重试', 5);
|
||||
@@ -182,12 +168,15 @@ function onLoginFail(reason: string, fields: any) {
|
||||
|
||||
<style scoped lang="less">
|
||||
.login {
|
||||
height: 100vh;
|
||||
min-height: -webkit-fill-available;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: max(20px, env(safe-area-inset-top)) 0 max(20px, env(safe-area-inset-bottom));
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
@@ -199,6 +188,7 @@ function onLoginFail(reason: string, fields: any) {
|
||||
top: 15%;
|
||||
left: 5%;
|
||||
filter: blur(60px);
|
||||
pointer-events: none;
|
||||
@media (min-width: 768px) {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
@@ -218,6 +208,7 @@ function onLoginFail(reason: string, fields: any) {
|
||||
bottom: 5%;
|
||||
right: 5%;
|
||||
filter: blur(40px);
|
||||
pointer-events: none;
|
||||
@media (min-width: 768px) {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
@@ -228,10 +219,15 @@ function onLoginFail(reason: string, fields: any) {
|
||||
}
|
||||
}
|
||||
|
||||
.login > :deep(.login-box) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@supports (bottom: env(safe-area-inset-bottom)) {
|
||||
.login {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: max(20px, env(safe-area-inset-bottom));
|
||||
padding-top: max(20px, env(safe-area-inset-top));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -320,4 +316,4 @@ function onLoginFail(reason: string, fields: any) {
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -63,10 +63,11 @@
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<a-button htmlType="submit" class="h-[48px] md:h-[52px] w-full rounded-lg transition-all duration-300 hover:bg-primary/90 bg-primary border-primary text-white text-base font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5 active:translate-y-0" type="primary" :loading="loading" style="touch-action: manipulation; -webkit-tap-highlight-color: transparent;">
|
||||
<a-button html-type="submit" class="login-submit h-[48px] md:h-[52px] w-full rounded-lg transition-all duration-300 hover:bg-primary/90 bg-primary border-primary text-white text-base font-medium shadow-md" type="primary" :loading="loading" :disabled="loading" aria-label="登录">
|
||||
<span v-if="!loading">登录</span>
|
||||
<span v-else>登录中...</span>
|
||||
</a-button>
|
||||
<div class="login-submit-status" aria-live="polite">{{ loading ? '正在登录,请稍候' : '' }}</div>
|
||||
</a-form>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
@@ -230,6 +231,8 @@ body {
|
||||
.login-box {
|
||||
box-sizing: border-box;
|
||||
touch-action: manipulation; /* 优化触摸性能 */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* 移动端全屏,PC端保留原max-w-md(默认28rem/448px)+ 90vw限制 */
|
||||
}
|
||||
|
||||
@@ -241,11 +244,13 @@ body {
|
||||
.login-box {
|
||||
max-width: 100vw !important; /* 移动端全屏 */
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
padding: 20px 16px !important;
|
||||
padding-top: 20vh !important; /* 移动端垂直居中 */
|
||||
padding-top: clamp(48px, 14vh, 112px) !important;
|
||||
padding-bottom: max(24px, env(safe-area-inset-bottom)) !important;
|
||||
}
|
||||
.third-title {
|
||||
font-size: 1.25rem !important;
|
||||
@@ -306,6 +311,29 @@ body {
|
||||
border-color: #4f46e5 !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.login-submit {
|
||||
min-height: 48px;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
.login-submit:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
.login-submit-status {
|
||||
min-height: 18px;
|
||||
padding-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.login-box,
|
||||
.login-box:hover {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
::v-deep(.ant-btn-primary:hover) {
|
||||
background-color: #4338ca !important;
|
||||
border-color: #4338ca !important;
|
||||
@@ -351,4 +379,4 @@ html {
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: hidden; /* 防止移动端横向滚动 */
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -164,10 +164,12 @@
|
||||
<a-input v-model:value="quaryData.followUserName" placeholder="搜索博主名称或抖音号" allow-clear @pressEnter="handleSearch" @search="handleSearch" class="follow-search-input" />
|
||||
</div>
|
||||
<div class="follow-actions">
|
||||
|
||||
<a-button type="primary" class="follow-btn sync-btn" @click="handleSyncAll" :disabled="isSyncDisabled">
|
||||
<a-button class="follow-btn sync-btn" @click="handleRefreshFollowList" :loading="isFollowListSyncing" :disabled="isFollowVideoSyncing" title="刷新关注列表" aria-label="刷新关注列表">
|
||||
<SyncOutlined />
|
||||
</a-button>
|
||||
<a-button type="primary" class="follow-btn sync-btn" @click="handleSyncFollowVideos" :loading="isFollowVideoSyncing" :disabled="isFollowListSyncing" title="同步关注视频" aria-label="同步关注视频">
|
||||
<VideoCameraOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -360,6 +362,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, onUnmounted, UnwrapRef, reactive, nextTick, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message, Spin, Empty, Tooltip, Modal, Form, FormInstance } from 'ant-design-vue';
|
||||
import { useApiStore } from '@/store';
|
||||
import {
|
||||
@@ -370,8 +373,11 @@ import {
|
||||
SaveOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// ========== 原有仪表盘类型定义 ==========
|
||||
interface Author {
|
||||
name: string;
|
||||
@@ -468,7 +474,8 @@ const followData = ref<FollowItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const noMoreData = ref(false);
|
||||
const hasMore = ref(true);
|
||||
const isSyncDisabled = ref(false);
|
||||
const isFollowListSyncing = ref(false);
|
||||
const isFollowVideoSyncing = ref(false);
|
||||
const isAddDisabled = ref(false);
|
||||
|
||||
// 搜索参数
|
||||
@@ -859,16 +866,15 @@ const uploadSyncStatus = (item: FollowItem) => {
|
||||
item.isSaving = false;
|
||||
});
|
||||
};
|
||||
// 批量同步
|
||||
const handleSyncAll = () => {
|
||||
if (isSyncDisabled.value) return;
|
||||
isSyncDisabled.value = true;
|
||||
loading.value = true;
|
||||
// 刷新关注博主列表,不下载视频。
|
||||
const handleRefreshFollowList = () => {
|
||||
if (isFollowListSyncing.value) return;
|
||||
isFollowListSyncing.value = true;
|
||||
useApiStore()
|
||||
.StartJobNow()
|
||||
.SyncFollow()
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
message.success('后台开始同步,请注意查收');
|
||||
message.success(res.data?.message || '关注列表更新任务已启动;这是刷新博主列表,不会下载视频');
|
||||
} else {
|
||||
message.error('同步失败:' + (res.message || '未知错误'));
|
||||
}
|
||||
@@ -878,8 +884,34 @@ const handleSyncAll = () => {
|
||||
message.error('同步失败,请重试');
|
||||
})
|
||||
.finally(() => {
|
||||
isSyncDisabled.value = false;
|
||||
loading.value = false;
|
||||
isFollowListSyncing.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
// 只启动关注博主作品下载任务。
|
||||
const handleSyncFollowVideos = () => {
|
||||
if (isFollowVideoSyncing.value) return;
|
||||
isFollowVideoSyncing.value = true;
|
||||
useApiStore()
|
||||
.StartTaskSync('dy_follows')
|
||||
.then((res) => {
|
||||
if (res.code !== 0) {
|
||||
message.error('启动关注视频同步失败:' + (res.message || '未知错误'));
|
||||
return;
|
||||
}
|
||||
Modal.success({
|
||||
title: '关注视频同步已启动',
|
||||
content: `已创建 ${res.data?.count || 1} 个任务,可在任务中心查看实时进度和失败原因。`,
|
||||
okText: '查看任务',
|
||||
onOk: () => router.push('/tasks'),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('启动关注视频同步失败:', err);
|
||||
message.error('启动关注视频同步失败,请重试');
|
||||
})
|
||||
.finally(() => {
|
||||
isFollowVideoSyncing.value = false;
|
||||
});
|
||||
};
|
||||
// 文本截断
|
||||
@@ -2285,4 +2317,4 @@ html.dark-mode :deep(.follow-filter-select .ant-select-item-selected) {
|
||||
background-color: #4caf50 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<a-form layout="inline" style="margin-top:5px;margin-bottom:5px;align-items: center;">
|
||||
<div class="logs-page">
|
||||
<a-form layout="inline" class="logs-toolbar">
|
||||
|
||||
<!-- 日期控制 -->
|
||||
<a-form-item>
|
||||
@@ -28,6 +29,7 @@
|
||||
<pre class="card-width-pre">{{ logs }}</pre>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -149,6 +151,14 @@ body {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
.logs-page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logs-toolbar {
|
||||
margin: 5px 0;
|
||||
align-items: center;
|
||||
}
|
||||
// pre样式:自适应高度,内容过多时内部滚动
|
||||
.card-width-pre {
|
||||
width: 100% !important;
|
||||
@@ -290,4 +300,59 @@ body {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.logs-page {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logs-toolbar {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) auto 44px;
|
||||
gap: 8px;
|
||||
margin: 0 !important;
|
||||
padding: 10px !important;
|
||||
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);
|
||||
}
|
||||
|
||||
.logs-toolbar :deep(.ant-form-item) {
|
||||
min-width: 0;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.logs-toolbar :deep(.ant-form-item:last-child) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.date-control-group {
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.current-date-text { min-width: 72px !important; font-size: 12px !important; }
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container :deep(.ant-card) {
|
||||
border-color: var(--mobile-border, #e5e7eb);
|
||||
border-radius: 16px;
|
||||
background: var(--mobile-card, #fff);
|
||||
}
|
||||
|
||||
.card-width-pre {
|
||||
min-height: 55vh;
|
||||
max-height: none;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+1115
-7
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,689 @@
|
||||
<template>
|
||||
<div class="task-center-page">
|
||||
<a-card :bordered="false" class="task-hero">
|
||||
<div class="task-hero__content">
|
||||
<div>
|
||||
<h2>任务中心</h2>
|
||||
<p>查看同步、重新下载、恢复、迁移和存储维护的真实结果。进度按条目阶段和数量展示。</p>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<a-button :loading="startingSync" @click="startSync"><SyncOutlined />立即同步</a-button>
|
||||
<a-button
|
||||
class="storage-probe-action"
|
||||
:type="summary.storageHealth?.status === 1 ? 'primary' : 'default'"
|
||||
:danger="summary.storageHealth?.status === 1"
|
||||
:loading="probingStorage"
|
||||
@click="probeStorage"
|
||||
>
|
||||
<CloudSyncOutlined />
|
||||
{{ summary.storageHealth?.status === 1 ? '重新检测存储' : '检测存储' }}
|
||||
</a-button>
|
||||
<a-button @click="directoryRepairModal?.open()"><ToolOutlined />修复异常目录</a-button>
|
||||
<a-button class="failed-record-action" danger @click="failedRemovalModal?.open()"><DeleteOutlined />处理迁移失败记录</a-button>
|
||||
<a-button :loading="loading" @click="refreshAll"><ReloadOutlined />刷新</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="summary.storageHealth?.status === 1"
|
||||
type="error"
|
||||
show-icon
|
||||
class="storage-alert"
|
||||
message="媒体存储已暂停"
|
||||
:description="summary.storageHealth.lastError || '连续三次存储失败,新的下载任务已停止写入。'"
|
||||
/>
|
||||
<a-alert
|
||||
v-for="source in blockedSources"
|
||||
:key="source.cookieId"
|
||||
:type="source.requiresAuthorization ? 'error' : 'warning'"
|
||||
show-icon
|
||||
class="storage-alert"
|
||||
:message="source.requiresAuthorization ? `${source.cookieName || '抖音账号'}需要重新授权` : `${source.cookieName || '抖音账号'}的媒体来源正在冷却`"
|
||||
:description="sourceDescription(source)"
|
||||
>
|
||||
<template #action>
|
||||
<router-link to="/cok"><a-button size="small" :danger="source.requiresAuthorization">前往授权</a-button></router-link>
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-card>
|
||||
|
||||
<section class="summary-grid" aria-label="任务摘要">
|
||||
<a-card size="small"><a-statistic title="排队" :value="summary.queued" /></a-card>
|
||||
<a-card size="small"><a-statistic title="运行中" :value="summary.running" /></a-card>
|
||||
<a-card size="small"><a-statistic title="等待存储" :value="summary.waitingForStorage" /></a-card>
|
||||
<a-card size="small"><a-statistic title="等待来源" :value="summary.waitingForSource" /></a-card>
|
||||
<a-card size="small"><a-statistic title="失败/部分失败" :value="summary.failed" /></a-card>
|
||||
<a-card size="small"><a-statistic title="已完成" :value="summary.completed" /></a-card>
|
||||
<a-card size="small" class="storage-health-card">
|
||||
<div class="storage-health-card__title">存储状态</div>
|
||||
<a-tag :color="summary.storageHealth?.status === 1 ? 'error' : 'success'">
|
||||
{{ summary.storageHealth?.status === 1 ? '不可用' : '正常' }}
|
||||
</a-tag>
|
||||
<span v-if="summary.storageHealth?.consecutiveFailures" class="storage-health-card__failures">
|
||||
连续失败 {{ summary.storageHealth.consecutiveFailures }} 次
|
||||
</span>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<a-card :bordered="false" class="task-list-card">
|
||||
<button v-if="viewportWidth <= 768" type="button" class="mobile-task-filter-trigger" @click="mobileFiltersOpen = !mobileFiltersOpen">
|
||||
<FilterOutlined />{{ mobileFiltersOpen ? '收起筛选' : '筛选任务' }}
|
||||
</button>
|
||||
<div v-show="viewportWidth > 768 || mobileFiltersOpen" class="task-filters">
|
||||
<a-select v-model:value="filters.type" allow-clear placeholder="全部任务类型" :options="taskTypeOptions" />
|
||||
<a-select v-model:value="filters.status" allow-clear placeholder="全部状态" :options="taskStatusOptions" />
|
||||
<a-input-search v-model:value="filters.keyword" allow-clear placeholder="任务名称或错误原因" @search="applyFilters" />
|
||||
<a-button type="primary" @click="applyFilters">查询</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
class="desktop-task-table"
|
||||
row-key="id"
|
||||
:columns="taskColumns"
|
||||
:data-source="tasks"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
@row-click="openTask"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<button type="button" class="task-title-button" @click="openTask(record)">{{ record.title }}</button>
|
||||
<div class="task-id">{{ typeText(record.type) }} · {{ formatDate(record.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'counts'">
|
||||
<div class="task-counts-inline">
|
||||
<span class="success">{{ successCountLabel(record) }} {{ record.successCount }}</span>
|
||||
<span v-if="record.warningCount" class="warning">警告 {{ record.warningCount }}</span>
|
||||
<span v-if="record.failedCount" class="failure">失败 {{ record.failedCount }}</span>
|
||||
<span>等待 {{ record.pendingCount }}</span>
|
||||
<span v-if="record.skippedCount">{{ skippedCountLabel(record) }} {{ record.skippedCount }}</span>
|
||||
<span v-if="record.removedCount">{{ removedCountLabel(record) }} {{ record.removedCount }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'operation'">
|
||||
<a-space @click.stop>
|
||||
<a-button size="small" @click="openTask(record)">详情</a-button>
|
||||
<a-button v-if="canRetryTask(record)" size="small" @click="retryFailed(record)">重试失败项</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<div class="mobile-task-list">
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty v-if="!loading && tasks.length === 0" description="暂无任务" />
|
||||
<article v-for="task in tasks" :key="task.id" class="mobile-task-card" @click="openTask(task)">
|
||||
<header>
|
||||
<div>
|
||||
<strong>{{ task.title }}</strong>
|
||||
<span>{{ typeText(task.type) }} · {{ formatDate(task.createdAt) }}</span>
|
||||
</div>
|
||||
<a-tag :color="statusColor(task.status)">{{ statusText(task.status) }}</a-tag>
|
||||
</header>
|
||||
<div class="mobile-task-counts">
|
||||
<span>总数 {{ task.totalCount }}</span><span>{{ successCountLabel(task) }} {{ task.successCount }}</span>
|
||||
<span :class="{ failure: task.failedCount }">失败 {{ task.failedCount }}</span><span>等待 {{ task.pendingCount }}</span>
|
||||
<span v-if="task.removedCount">{{ removedCountLabel(task) }} {{ task.removedCount }}</span>
|
||||
</div>
|
||||
<div v-if="task.currentFile" class="current-file" :title="task.currentFile">当前:{{ task.currentFile }}</div>
|
||||
<a-button v-if="canRetryTask(task)" size="small" @click.stop="retryFailed(task)">重试失败项</a-button>
|
||||
</article>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<a-pagination
|
||||
v-if="taskTotal > filters.pageSize"
|
||||
class="task-pagination"
|
||||
:current="filters.pageIndex"
|
||||
:page-size="filters.pageSize"
|
||||
:total="taskTotal"
|
||||
show-less-items
|
||||
@change="changeTaskPage"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<a-drawer
|
||||
v-model:visible="detailVisible"
|
||||
class="task-detail-drawer"
|
||||
placement="right"
|
||||
:width="drawerWidth"
|
||||
:title="selectedTask?.title || '任务详情'"
|
||||
destroy-on-close
|
||||
>
|
||||
<template v-if="selectedTask">
|
||||
<div class="detail-head">
|
||||
<div>
|
||||
<a-tag :color="statusColor(selectedTask.status)">{{ statusText(selectedTask.status) }}</a-tag>
|
||||
<span>{{ typeText(selectedTask.type) }} · {{ formatDate(selectedTask.createdAt) }}</span>
|
||||
</div>
|
||||
<a-space wrap>
|
||||
<a-button v-if="canRetryTask(selectedTask)" size="small" @click="retryFailed(selectedTask)">重试全部失败项</a-button>
|
||||
<a-button
|
||||
v-for="action in taskActions(selectedTask)"
|
||||
:key="action"
|
||||
size="small"
|
||||
:danger="action === 'cancel' || action === 'cleanup' || action === 'confirm-cleanup'"
|
||||
@click="runTaskAction(selectedTask, action)"
|
||||
>{{ actionText(action) }}</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="detail-count-grid">
|
||||
<div><span>总数</span><strong>{{ selectedTask.totalCount }}</strong></div>
|
||||
<div><span>{{ successCountLabel(selectedTask) }}</span><strong>{{ selectedTask.successCount }}</strong></div>
|
||||
<div><span>警告</span><strong>{{ selectedTask.warningCount }}</strong></div>
|
||||
<div><span>失败</span><strong class="failure">{{ selectedTask.failedCount }}</strong></div>
|
||||
<div><span>等待</span><strong>{{ selectedTask.pendingCount }}</strong></div>
|
||||
<div><span>{{ skippedCountLabel(selectedTask) }}</span><strong>{{ selectedTask.skippedCount }}</strong></div>
|
||||
<div v-if="selectedTask.removedCount"><span>{{ removedCountLabel(selectedTask) }}</span><strong>{{ selectedTask.removedCount }}</strong></div>
|
||||
</div>
|
||||
<div v-if="selectedTask.currentFile" class="detail-current" :title="selectedTask.currentFile">当前文件:{{ selectedTask.currentFile }}</div>
|
||||
<a-alert v-if="selectedTask.errorMessage" type="warning" show-icon :message="selectedTask.errorMessage" />
|
||||
|
||||
<div class="item-filters">
|
||||
<a-select v-model:value="itemFilters.stage" allow-clear placeholder="全部阶段" :options="itemStageOptions" @change="resetItemFilters" />
|
||||
<a-select v-if="selectedTask.type !== 4" v-model:value="itemFilters.errorType" allow-clear placeholder="全部错误" :options="errorTypeOptions" @change="resetItemFilters" />
|
||||
<a-input-search v-model:value="itemFilters.keyword" allow-clear :placeholder="selectedTask.type === 4 ? '目录名称或错误原因' : '标题、博主或作品 ID'" @search="resetItemFilters" />
|
||||
<label v-if="selectedTask.type !== 4" class="skipped-switch"><a-switch v-model:checked="itemFilters.includeSkipped" size="small" @change="resetItemFilters" />显示跳过项</label>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="itemsLoading">
|
||||
<a-empty v-if="!itemsLoading && taskItems.length === 0" description="没有符合条件的条目" />
|
||||
<a-list v-else :data-source="taskItems" class="task-item-list">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<article class="task-item-card">
|
||||
<header>
|
||||
<div class="task-item-title">
|
||||
<a-tag :color="stageColor(item.stage)">{{ stageText(item.stage) }}</a-tag>
|
||||
<strong :title="item.videoTitle">{{ item.videoTitle || item.awemeId || item.videoId || '未命名条目' }}</strong>
|
||||
</div>
|
||||
<a-space class="task-item-actions" wrap>
|
||||
<a-button v-if="item.canRetry" size="small" @click="retryItem(item)">重试</a-button>
|
||||
<a-button
|
||||
v-if="item.canRetryCleanup"
|
||||
size="small"
|
||||
:loading="cleanupRetryingId === item.id"
|
||||
@click="retryCleanup(item)"
|
||||
>重试清理旧文件</a-button>
|
||||
<a-button v-if="item.canUnexclude && item.exclusionId" size="small" @click="openUnexclude([item.exclusionId])">取消排除</a-button>
|
||||
</a-space>
|
||||
</header>
|
||||
<div class="task-item-meta">
|
||||
<span v-if="item.author">博主:{{ item.author }}</span>
|
||||
<span v-if="item.cookieName">账号:{{ item.cookieName }}</span>
|
||||
<span>尝试:{{ item.attempts }}</span>
|
||||
<span v-if="item.awemeId">作品:{{ item.awemeId }}</span>
|
||||
<span v-if="item.sourceHost">来源:{{ item.sourceHost }}</span>
|
||||
<span v-if="item.httpStatusCode">HTTP:{{ item.httpStatusCode }}</span>
|
||||
<span v-if="item.retryAfter">重试:{{ formatDate(item.retryAfter) }}</span>
|
||||
</div>
|
||||
<div v-if="item.targetPath" class="current-file" :class="{ 'current-file--wrap': selectedTask.type === 4 }" :title="item.targetPath">目标:{{ item.targetPath }}</div>
|
||||
<a-alert v-if="item.errorMessage" type="error" show-icon :message="item.errorMessage" />
|
||||
<a-alert v-if="item.warningMessage" type="warning" show-icon :message="item.warningMessage" />
|
||||
<a-alert
|
||||
v-if="item.cleanupError"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="上次清理未完成"
|
||||
:description="item.cleanupError"
|
||||
/>
|
||||
</article>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-spin>
|
||||
<a-pagination
|
||||
v-if="itemTotal > itemFilters.pageSize"
|
||||
class="task-pagination"
|
||||
:current="itemFilters.pageIndex"
|
||||
:page-size="itemFilters.pageSize"
|
||||
:total="itemTotal"
|
||||
show-less-items
|
||||
@change="loadItems"
|
||||
/>
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<a-modal
|
||||
v-model:visible="unexcludeVisible"
|
||||
title="取消永久排除"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="unexcluding"
|
||||
@ok="submitUnexclude"
|
||||
>
|
||||
<a-alert type="info" show-icon message="取消排除不会恢复已经删除的旧文件。" class="unexclude-alert" />
|
||||
<a-radio-group v-model:value="unexcludeMode" class="unexclude-options">
|
||||
<a-radio value="only">
|
||||
<strong>仅取消排除</strong>
|
||||
<span>视频会在后续正常同步重新发现。</span>
|
||||
</a-radio>
|
||||
<a-radio value="restore">
|
||||
<strong>取消排除并创建恢复任务</strong>
|
||||
<span>立即尝试使用保存的资料和抖音回源下载;过旧记录资料不足时仍会保留为已取消排除。</span>
|
||||
</a-radio>
|
||||
</a-radio-group>
|
||||
</a-modal>
|
||||
<FailedMigrationRecordRemovalModal ref="failedRemovalModal" @completed="onFailedRecordsRemoved" />
|
||||
<OpenListDirectoryRepairModal ref="directoryRepairModal" @changed="onDirectoryRepairChanged" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { CloudSyncOutlined, DeleteOutlined, FilterOutlined, ReloadOutlined, SyncOutlined, ToolOutlined } from '@ant-design/icons-vue';
|
||||
import { useApiStore } from '@/store';
|
||||
import FailedMigrationRecordRemovalModal from '@/components/FailedMigrationRecordRemovalModal.vue';
|
||||
import OpenListDirectoryRepairModal from '@/components/OpenListDirectoryRepairModal.vue';
|
||||
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
type: number;
|
||||
status: number;
|
||||
title: string;
|
||||
totalCount: number;
|
||||
pendingCount: number;
|
||||
runningCount: number;
|
||||
successCount: number;
|
||||
warningCount: number;
|
||||
failedCount: number;
|
||||
skippedCount: number;
|
||||
removedCount: number;
|
||||
currentFile?: string;
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
availableActions?: string[];
|
||||
}
|
||||
|
||||
interface TaskItem {
|
||||
id: string;
|
||||
taskId: string;
|
||||
videoId?: string;
|
||||
awemeId?: string;
|
||||
videoTitle?: string;
|
||||
author?: string;
|
||||
cookieName?: string;
|
||||
targetPath?: string;
|
||||
stage: number;
|
||||
attempts: number;
|
||||
errorMessage?: string;
|
||||
warningMessage?: string;
|
||||
sourceHost?: string;
|
||||
httpStatusCode?: number;
|
||||
retryAfter?: string;
|
||||
exclusionId?: string;
|
||||
canRetry: boolean;
|
||||
canRetryCleanup: boolean;
|
||||
cleanupError?: string;
|
||||
canUnexclude: boolean;
|
||||
}
|
||||
|
||||
const api = useApiStore();
|
||||
const loading = ref(false);
|
||||
const itemsLoading = ref(false);
|
||||
const startingSync = ref(false);
|
||||
const probingStorage = ref(false);
|
||||
const cleanupRetryingId = ref('');
|
||||
const tasks = ref<TaskRow[]>([]);
|
||||
const taskTotal = ref(0);
|
||||
const selectedTask = ref<TaskRow | null>(null);
|
||||
const taskItems = ref<TaskItem[]>([]);
|
||||
const itemTotal = ref(0);
|
||||
const detailVisible = ref(false);
|
||||
const unexcludeVisible = ref(false);
|
||||
const unexcludeIds = ref<string[]>([]);
|
||||
const unexcludeMode = ref<'only' | 'restore'>('only');
|
||||
const unexcluding = ref(false);
|
||||
const failedRemovalModal = ref<InstanceType<typeof FailedMigrationRecordRemovalModal> | null>(null);
|
||||
const directoryRepairModal = ref<InstanceType<typeof OpenListDirectoryRepairModal> | null>(null);
|
||||
const viewportWidth = ref(window.innerWidth);
|
||||
const mobileFiltersOpen = ref(false);
|
||||
const drawerWidth = computed(() => viewportWidth.value <= 768 ? '100%' : 780);
|
||||
let pollTimer: number | undefined;
|
||||
let pollBusy = false;
|
||||
|
||||
const summary = reactive<any>({ queued: 0, running: 0, waitingForStorage: 0, waitingForSource: 0, failed: 0, completed: 0, storageHealth: null, sourceHealth: [] });
|
||||
const blockedSources = computed(() => (summary.sourceHealth || []).filter((source: any) =>
|
||||
source.requiresAuthorization || source.cooldownUntil || source.probePending));
|
||||
const filters = reactive<any>({ type: undefined, status: undefined, keyword: '', pageIndex: 1, pageSize: 15 });
|
||||
const itemFilters = reactive<any>({ stage: undefined, errorType: undefined, keyword: '', includeSkipped: false, pageIndex: 1, pageSize: 20 });
|
||||
|
||||
const taskTypeOptions = [
|
||||
{ value: 0, label: '同步' }, { value: 1, label: '重新下载' },
|
||||
{ value: 2, label: '取消排除恢复' }, { value: 3, label: '存储迁移' },
|
||||
{ value: 4, label: '存储维护' },
|
||||
];
|
||||
const taskStatusOptions = [
|
||||
{ value: 0, label: '排队' }, { value: 1, label: '运行中' }, { value: 2, label: '等待存储' },
|
||||
{ value: 3, label: '完成' }, { value: 4, label: '部分失败' }, { value: 5, label: '失败' },
|
||||
{ value: 6, label: '已中断' }, { value: 7, label: '暂停' }, { value: 8, label: '取消' },
|
||||
{ value: 9, label: '清理中' }, { value: 10, label: '已清理' }, { value: 11, label: '已回滚' },
|
||||
{ value: 12, label: '等待媒体来源' },
|
||||
{ value: 13, label: '扫描中' }, { value: 14, label: '等待确认清理' },
|
||||
];
|
||||
const itemStageOptions = [
|
||||
{ value: 0, label: '等待' }, { value: 1, label: '下载/上传' }, { value: 2, label: '校验' },
|
||||
{ value: 3, label: '提交' }, { value: 4, label: '成功' }, { value: 5, label: '成功但有警告' },
|
||||
{ value: 6, label: '失败' }, { value: 7, label: '跳过' }, { value: 8, label: '等待存储' },
|
||||
{ value: 9, label: '已清理' }, { value: 10, label: '已回滚' }, { value: 11, label: '已取消' },
|
||||
{ value: 12, label: '等待媒体来源' }, { value: 13, label: '记录已删除' },
|
||||
{ value: 14, label: '检查目录' }, { value: 15, label: '已确认空目录' }, { value: 16, label: '非空,已保留' },
|
||||
];
|
||||
const errorTypeOptions = [
|
||||
{ value: 1, label: '来源不可用' }, { value: 2, label: 'Cookie 失效' }, { value: 3, label: '存储不可用' },
|
||||
{ value: 4, label: '完整性校验失败' }, { value: 5, label: '数据库提交失败' }, { value: 6, label: '任务中断' },
|
||||
{ value: 7, label: '配置变化' }, { value: 8, label: '永久排除' }, { value: 9, label: '其他错误' },
|
||||
{ value: 10, label: '来源返回 403' }, { value: 11, label: '来源请求受限' },
|
||||
];
|
||||
const taskColumns = [
|
||||
{ title: '任务', key: 'title', dataIndex: 'title' },
|
||||
{ title: '状态', key: 'status', width: 110 },
|
||||
{ title: '条目统计', key: 'counts', width: 330 },
|
||||
{ title: '操作', key: 'operation', width: 190 },
|
||||
];
|
||||
|
||||
const typeText = (type: number) => taskTypeOptions.find(x => x.value === type)?.label || '未知任务';
|
||||
const statusText = (status: number) => taskStatusOptions.find(x => x.value === status)?.label || String(status);
|
||||
const stageText = (stage: number) => itemStageOptions.find(x => x.value === stage)?.label || String(stage);
|
||||
const statusColor = (status: number) => status === 3 || status === 10 || status === 11 ? 'success'
|
||||
: status === 4 || status === 7 || status === 14 ? 'warning' : status === 5 || status === 6 || status === 8 ? 'error'
|
||||
: status === 2 || status === 12 ? 'orange' : 'processing';
|
||||
const stageColor = (stage: number) => stage === 4 ? 'success' : stage === 5 ? 'warning'
|
||||
: stage === 6 ? 'error' : stage === 15 ? 'success' : stage === 7 || stage === 16 || (stage >= 9 && stage <= 13 && stage !== 12) ? 'default' : stage === 8 || stage === 12 ? 'orange' : 'processing';
|
||||
const formatDate = (value?: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-';
|
||||
const sourceDescription = (source: any) => {
|
||||
if (source.requiresAuthorization) return source.lastError || '冷却后的自动探测仍返回 403,请更新 Cookie。保存后会自动安排一次探测。';
|
||||
const retry = source.cooldownUntil ? `预计 ${formatDate(source.cooldownUntil)} 自动探测。` : '等待自动探测。';
|
||||
return `${source.lastError || '抖音媒体来源暂时不可用。'} ${retry}`;
|
||||
};
|
||||
const successCountLabel = (task: TaskRow) => task.type === 4 ? '安全项' : '成功';
|
||||
const skippedCountLabel = (task: TaskRow) => task.type === 4 ? '已保留' : '跳过';
|
||||
const removedCountLabel = (task: TaskRow) => task.type === 4 ? '已删空目录' : '已删记录';
|
||||
const canRetryTask = (task: TaskRow) => (task.availableActions || []).includes('retry-failed');
|
||||
const taskActions = (task: TaskRow) => task.type === 3 || task.type === 4
|
||||
? (task.availableActions || []).filter(x => x !== 'retry-failed') : [];
|
||||
const actionText = (action: string) => ({ pause: '暂停', resume: '恢复', cancel: '取消', cleanup: '清理旧文件', rollback: '整批回滚', archive: '隐藏历史', 'confirm-cleanup': '确认清理空目录' } as Record<string, string>)[action] || action;
|
||||
|
||||
const requireSuccess = (response: any, fallback: string) => {
|
||||
if (!response || response.code !== 0) throw new Error(response?.message || fallback);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const loadSummary = async () => Object.assign(summary, requireSuccess(await api.TaskSummary(), '获取任务摘要失败'));
|
||||
const loadTasks = async (showLoading = true) => {
|
||||
if (showLoading) loading.value = true;
|
||||
try {
|
||||
const data = requireSuccess(await api.TaskList(filters), '获取任务列表失败');
|
||||
tasks.value = data.items || [];
|
||||
taskTotal.value = data.totalCount || 0;
|
||||
if (selectedTask.value) {
|
||||
const latest = tasks.value.find(x => x.id === selectedTask.value?.id && x.type === selectedTask.value?.type);
|
||||
if (latest) selectedTask.value = latest;
|
||||
}
|
||||
syncPolling();
|
||||
} finally { if (showLoading) loading.value = false; }
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
try { await Promise.all([loadSummary(), loadTasks()]); }
|
||||
catch (error: any) { message.error(error?.message || '刷新失败'); }
|
||||
};
|
||||
const applyFilters = () => {
|
||||
filters.pageIndex = 1;
|
||||
if (viewportWidth.value <= 768) mobileFiltersOpen.value = false;
|
||||
loadTasks().catch(showError);
|
||||
};
|
||||
const changeTaskPage = (page: number) => { filters.pageIndex = page; loadTasks().catch(showError); };
|
||||
|
||||
const openTask = async (task: TaskRow) => {
|
||||
selectedTask.value = task;
|
||||
detailVisible.value = true;
|
||||
Object.assign(itemFilters, { stage: undefined, errorType: undefined, keyword: '', includeSkipped: false, pageIndex: 1 });
|
||||
try { await Promise.all([loadDetail(), loadItems(1)]); } catch (error: any) { showError(error); }
|
||||
};
|
||||
const loadDetail = async () => {
|
||||
if (!selectedTask.value) return;
|
||||
selectedTask.value = requireSuccess(await api.TaskDetail(selectedTask.value.type, selectedTask.value.id), '获取任务详情失败');
|
||||
};
|
||||
const loadItems = async (page = itemFilters.pageIndex) => {
|
||||
if (!selectedTask.value) return;
|
||||
itemFilters.pageIndex = page;
|
||||
itemsLoading.value = true;
|
||||
try {
|
||||
const data = requireSuccess(await api.TaskItems(selectedTask.value.type, selectedTask.value.id, itemFilters), '获取任务条目失败');
|
||||
taskItems.value = data.items || [];
|
||||
itemTotal.value = data.totalCount || 0;
|
||||
} finally { itemsLoading.value = false; }
|
||||
};
|
||||
const resetItemFilters = () => loadItems(1).catch(showError);
|
||||
|
||||
const startSync = async () => {
|
||||
startingSync.value = true;
|
||||
try {
|
||||
const data = requireSuccess(await api.StartTaskSync(), '启动同步失败');
|
||||
message.success(`已创建 ${data?.count || 0} 个同步任务`);
|
||||
await refreshAll();
|
||||
} catch (error: any) { showError(error); }
|
||||
finally { startingSync.value = false; }
|
||||
};
|
||||
const probeStorage = async () => {
|
||||
probingStorage.value = true;
|
||||
try {
|
||||
const data = requireSuccess(await api.ProbeTaskStorage(), '存储检测失败');
|
||||
message.success(data?.message || '存储检测通过');
|
||||
await refreshAll();
|
||||
} catch (error: any) { showError(error); }
|
||||
finally { probingStorage.value = false; }
|
||||
};
|
||||
const retryFailed = async (task: TaskRow) => {
|
||||
try {
|
||||
requireSuccess(await api.RetryTaskFailed(task.type, task.id), '提交重试失败');
|
||||
message.success('失败项已重新排队');
|
||||
await refreshAfterAction();
|
||||
} catch (error: any) { showError(error); }
|
||||
};
|
||||
const retryItem = async (item: TaskItem) => {
|
||||
if (!selectedTask.value) return;
|
||||
try {
|
||||
requireSuccess(await api.RetryTaskItem(selectedTask.value.type, selectedTask.value.id, item.id), '提交重试失败');
|
||||
message.success('条目已重新排队');
|
||||
await refreshAfterAction();
|
||||
} catch (error: any) { showError(error); }
|
||||
};
|
||||
const retryCleanup = (item: TaskItem) => {
|
||||
if (!selectedTask.value || cleanupRetryingId.value) return;
|
||||
const task = selectedTask.value;
|
||||
const execute = async () => {
|
||||
cleanupRetryingId.value = item.id;
|
||||
try {
|
||||
const data = requireSuccess(await api.RetryTaskItemCleanup(task.type, task.id, item.id), '重试清理失败');
|
||||
message.success(data?.message || '旧本地文件清理完成');
|
||||
await refreshAfterAction();
|
||||
} catch (error: any) { showError(error); }
|
||||
finally { cleanupRetryingId.value = ''; }
|
||||
};
|
||||
Modal.confirm({
|
||||
title: '确认重试清理旧文件?',
|
||||
content: '系统会再次验证数据库已指向 OpenList 且远端主媒体有效,只删除不再被本地记录引用的旧文件。不会重新下载视频。',
|
||||
okText: '验证并清理',
|
||||
cancelText: '取消',
|
||||
onOk: execute,
|
||||
});
|
||||
};
|
||||
const runTaskAction = (task: TaskRow, action: string) => {
|
||||
if (task.type === 4 && action === 'confirm-cleanup') {
|
||||
directoryRepairModal.value?.openConfirm(task.id);
|
||||
return;
|
||||
}
|
||||
const dangerous = action === 'cleanup' || action === 'rollback' || action === 'cancel' || action === 'archive';
|
||||
const execute = async () => {
|
||||
try {
|
||||
requireSuccess(await api.TaskAction(task.type, task.id, action), '任务操作失败');
|
||||
message.success(`${actionText(action)}操作已提交`);
|
||||
if (action === 'archive' && selectedTask.value?.id === task.id) {
|
||||
detailVisible.value = false;
|
||||
selectedTask.value = null;
|
||||
}
|
||||
await refreshAfterAction();
|
||||
} catch (error: any) { showError(error); }
|
||||
};
|
||||
if (!dangerous) return execute();
|
||||
const content = action === 'cleanup' ? '仅清理迁移成功项的旧本地文件;清理后不能回滚,失败项会保留。'
|
||||
: action === 'rollback' ? '将验证旧主媒体、恢复数据库并删除任务独占的远端文件。'
|
||||
: action === 'archive' ? '仅从任务中心隐藏该迁移历史,不删除视频记录、文件或迁移条目。'
|
||||
: task.type === 4 ? '取消后已检查和已保留的目录状态不会丢失,可以稍后恢复任务。'
|
||||
: '已成功的迁移项不会自动回滚。';
|
||||
Modal.confirm({ title: `确认${actionText(action)}?`, content, onOk: execute });
|
||||
};
|
||||
const refreshAfterAction = async () => {
|
||||
await Promise.all([loadSummary(), loadTasks(false)]);
|
||||
if (selectedTask.value) await Promise.all([loadDetail(), loadItems(itemFilters.pageIndex)]);
|
||||
};
|
||||
|
||||
const onFailedRecordsRemoved = async () => {
|
||||
await refreshAfterAction();
|
||||
};
|
||||
|
||||
const onDirectoryRepairChanged = async () => {
|
||||
await refreshAfterAction();
|
||||
};
|
||||
|
||||
const openUnexclude = (ids: string[]) => {
|
||||
unexcludeIds.value = ids;
|
||||
unexcludeMode.value = 'only';
|
||||
unexcludeVisible.value = true;
|
||||
};
|
||||
const submitUnexclude = async () => {
|
||||
unexcluding.value = true;
|
||||
try {
|
||||
const data = requireSuccess(await api.UnexcludeVideos(unexcludeIds.value, unexcludeMode.value === 'restore'), '取消排除失败');
|
||||
message.success(data.message || '已取消永久排除');
|
||||
unexcludeVisible.value = false;
|
||||
await refreshAfterAction();
|
||||
} catch (error: any) { showError(error); }
|
||||
finally { unexcluding.value = false; }
|
||||
};
|
||||
|
||||
const hasActiveTask = () => tasks.value.some(task => task.status === 0 || task.status === 1 || task.status === 9 || task.status === 12 || task.status === 13);
|
||||
const syncPolling = () => {
|
||||
if (hasActiveTask() && pollTimer === undefined) pollTimer = window.setInterval(pollActiveTasks, 3000);
|
||||
if (!hasActiveTask() && pollTimer !== undefined) { window.clearInterval(pollTimer); pollTimer = undefined; }
|
||||
};
|
||||
const pollActiveTasks = async () => {
|
||||
if (pollBusy || !hasActiveTask()) return;
|
||||
pollBusy = true;
|
||||
try { await refreshAfterAction(); } catch { /* foreground refresh reports errors; polling stays quiet */ }
|
||||
finally { pollBusy = false; }
|
||||
};
|
||||
const showError = (error: any) => message.error(error?.message || '操作失败');
|
||||
const onResize = () => { viewportWidth.value = window.innerWidth; };
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize);
|
||||
refreshAll();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
if (pollTimer !== undefined) window.clearInterval(pollTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.task-center-page { display: grid; gap: 16px; padding: 16px; }
|
||||
.task-hero { border-radius: 14px; }
|
||||
.task-hero__content { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
.task-hero h2 { margin: 0 0 6px; font-size: 24px; }
|
||||
.task-hero p { margin: 0; color: #64748b; }
|
||||
.task-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.storage-alert { margin-top: 16px; }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 12px; }
|
||||
.summary-grid :deep(.ant-card) { height: 100%; border-radius: 12px; }
|
||||
.storage-health-card__title { margin-bottom: 8px; color: rgba(0, 0, 0, 0.45); }
|
||||
.storage-health-card__failures { display: block; margin-top: 8px; color: #cf1322; font-size: 12px; }
|
||||
.task-list-card { border-radius: 14px; }
|
||||
.task-filters { display: grid; grid-template-columns: 180px 180px minmax(240px, 1fr) auto; gap: 10px; margin-bottom: 16px; }
|
||||
.task-title-button { padding: 0; border: 0; background: none; color: #1677ff; cursor: pointer; font-weight: 600; text-align: left; }
|
||||
.task-id { margin-top: 4px; color: #94a3b8; font-size: 12px; }
|
||||
.task-counts-inline { display: flex; flex-wrap: wrap; gap: 6px 12px; font-size: 13px; }
|
||||
.success { color: #389e0d; }.warning { color: #d48806; }.failure { color: #cf1322 !important; }
|
||||
.mobile-task-list { display: none; }
|
||||
.task-pagination { margin-top: 18px; text-align: right; }
|
||||
.detail-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||
.detail-count-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-bottom: 14px; }
|
||||
.detail-count-grid > div { display: flex; flex-direction: column; gap: 4px; padding: 10px; border-radius: 10px; background: #f8fafc; text-align: center; }
|
||||
.detail-count-grid span { color: #64748b; font-size: 12px; }.detail-count-grid strong { font-size: 18px; }
|
||||
.detail-current,.current-file { overflow: hidden; margin: 8px 0; color: #64748b; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.current-file--wrap { overflow: visible; overflow-wrap: anywhere; text-overflow: clip; white-space: normal; line-height: 1.55; }
|
||||
.item-filters { display: grid; grid-template-columns: 150px 160px minmax(210px, 1fr) auto; gap: 8px; align-items: center; margin: 20px 0 10px; }
|
||||
.skipped-switch { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.task-item-list :deep(.ant-list-item) { padding: 8px 0; }
|
||||
.task-item-card { width: 100%; padding: 12px; border: 1px solid #eef2f7; border-radius: 10px; }
|
||||
.task-item-card header { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.task-item-actions { flex: 0 0 auto; }
|
||||
.task-item-title { display: flex; align-items: center; min-width: 0; gap: 8px; }
|
||||
.task-item-title strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-item-meta { display: flex; flex-wrap: wrap; gap: 5px 16px; margin: 8px 0; color: #64748b; font-size: 12px; }
|
||||
.task-item-card :deep(.ant-alert) { margin-top: 8px; }
|
||||
.unexclude-alert { margin-bottom: 14px; }
|
||||
.unexclude-options { display: grid; gap: 14px; }
|
||||
.unexclude-options :deep(.ant-radio-wrapper) { align-items: flex-start; white-space: normal; }
|
||||
.unexclude-options strong,.unexclude-options span { display: block; }.unexclude-options span { margin-top: 3px; color: #64748b; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.summary-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.detail-count-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.task-center-page { padding: 0; gap: 10px; }
|
||||
.task-hero,
|
||||
.task-list-card { border-radius: 16px; background: var(--mobile-card, #fff); }
|
||||
.task-hero :deep(.ant-card-body),
|
||||
.task-list-card :deep(.ant-card-body) { padding: 14px; }
|
||||
.task-hero__content { align-items: flex-start; flex-direction: column; }
|
||||
.task-hero h2 { font-size: 20px; }
|
||||
.task-hero p { font-size: 12px; line-height: 1.6; }
|
||||
.task-actions { display: grid; grid-template-columns: 1fr 1fr; width: 100%; }
|
||||
.task-actions :deep(.ant-btn) { width: 100%; min-height: 44px; }
|
||||
.storage-probe-action { grid-column: 1 / -1; grid-row: 1; }
|
||||
.summary-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.summary-grid :deep(.ant-card-body) { padding: 12px; }
|
||||
.summary-grid :deep(.ant-statistic-title) { font-size: 11px; }
|
||||
.summary-grid :deep(.ant-statistic-content) { font-size: 22px; }
|
||||
.mobile-task-filter-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin-bottom: 10px;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--mobile-border, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
background: #f7f2ff;
|
||||
color: #722ed1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.task-filters { grid-template-columns: 1fr 1fr; }
|
||||
.task-filters :deep(.ant-input-search) { grid-column: 1 / -1; }
|
||||
.desktop-task-table { display: none; }.mobile-task-list { display: block; }
|
||||
.mobile-task-card { margin-bottom: 10px; padding: 14px; border: 1px solid var(--mobile-border, #eef2f7); border-radius: 14px; background: var(--mobile-card, #fff); box-shadow: 0 5px 18px rgba(15, 23, 42, 0.05); }
|
||||
.mobile-task-card header { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.mobile-task-card header div { display: grid; min-width: 0; gap: 3px; }
|
||||
.mobile-task-card header strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mobile-task-card header span { color: #94a3b8; font-size: 11px; }
|
||||
.mobile-task-counts { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin: 12px 0; color: #475569; font-size: 12px; }
|
||||
.detail-head { align-items: flex-start; flex-direction: column; }
|
||||
.item-filters { grid-template-columns: 1fr 1fr; }
|
||||
.item-filters :deep(.ant-input-search),.skipped-switch { grid-column: 1 / -1; }
|
||||
.task-item-card header { align-items: flex-start; flex-direction: column; }
|
||||
.task-item-actions { width: 100%; }
|
||||
.task-item-actions :deep(.ant-btn) { min-height: 44px; }
|
||||
.task-detail-drawer :deep(.ant-drawer-body) { padding: 12px; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="isMobileView" class="mobile-record-toolbar">
|
||||
<button type="button" class="mobile-filter-trigger" :class="{ active: mobileFiltersOpen }" @click="mobileFiltersOpen = !mobileFiltersOpen">
|
||||
<SearchOutlined />
|
||||
<span>{{ mobileFiltersOpen ? '收起筛选' : '筛选视频' }}</span>
|
||||
</button>
|
||||
<button type="button" class="mobile-filter-trigger" @click="handShowDeleteVideos">
|
||||
<DeleteOutlined />
|
||||
<span>永久排除</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 优化查询区域:调整布局,时间、博主名称、标题放一行,宽度自适应 -->
|
||||
<div class="query-container">
|
||||
<div v-show="!isMobileView || mobileFiltersOpen" class="query-container" :class="{ 'query-container--mobile': isMobileView }">
|
||||
<a-form layout="inline" :model="quaryData" class="query-form">
|
||||
|
||||
<!-- 第一行:时间选择器组 + 博主名称 + 标题(合并为一行,自适应宽度) -->
|
||||
@@ -16,7 +26,7 @@
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="博主" ref="author" name="author" class="form-item form-item-input">
|
||||
<a-input v-model:value="quaryData.author" class="query-input" placeholder="请输入博主名称" />
|
||||
<a-input v-model:value="quaryData.author" class="query-input" placeholder="请输入博主名称" @input="handleAuthorInput" />
|
||||
</a-form-item>
|
||||
<a-form-item label="标题" ref="title" name="title" class="form-item form-item-input">
|
||||
<a-input v-model:value="quaryData.title" class="query-input" placeholder="请输入标题" />
|
||||
@@ -77,31 +87,83 @@
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<div v-if="activeAuthorFilter" class="author-filter-banner">
|
||||
<div>
|
||||
<strong>正在查看「{{ activeAuthorLabel }}」的已同步视频</strong>
|
||||
<span>{{ quaryData.authorId ? '已按博主 UID 精确筛选' : '旧关注数据缺少 UID,当前按名称筛选' }}</span>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button size="small" @click="clearAuthorFilter">查看全部</a-button>
|
||||
<a-button size="small" type="primary" ghost @click="returnToFollows">返回关注列表</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 已删除视频-抽屉 -->
|
||||
|
||||
<a-drawer title="已删除视频" size="large" :visible="deleteVideoShow" @close="onDeleteVideoClose">
|
||||
<template #extra>
|
||||
</template>
|
||||
<a-list size="small" bordered :data-source="deleteVideos">
|
||||
<a-drawer title="永久排除的视频" size="large" :visible="deleteVideoShow" destroy-on-close @close="onDeleteVideoClose">
|
||||
<a-alert type="info" show-icon message="这里的视频不会被后续同步下载。取消排除不会恢复已经删除的旧文件。" class="deleted-video-alert" />
|
||||
<div class="deleted-video-toolbar">
|
||||
<a-input-search v-model:value="exclusionKeyword" allow-clear placeholder="搜索标题、博主或作品 ID" @search="() => getDeleteViedos(1)" />
|
||||
<a-checkbox
|
||||
:checked="allExclusionsSelected"
|
||||
:indeterminate="someExclusionsSelected"
|
||||
@change="toggleAllExclusions"
|
||||
>选择本页</a-checkbox>
|
||||
<a-button :disabled="selectedExclusionIds.length === 0" @click="openUnexclude(selectedExclusionIds)">
|
||||
批量取消排除({{ selectedExclusionIds.length }})
|
||||
</a-button>
|
||||
</div>
|
||||
<a-spin :spinning="deletedLoading">
|
||||
<a-empty v-if="!deletedLoading && deleteVideos.length === 0" description="暂无永久排除的视频" />
|
||||
<a-list v-else size="small" bordered :data-source="deleteVideos">
|
||||
<template #renderItem="{item, index}">
|
||||
<a-list-item>
|
||||
<!-- 新增文本容器,用于控制省略号 -->
|
||||
<div class="delete-video-title-container">
|
||||
<span class="delete-video-index">{{ index + 1 }}.</span>
|
||||
<span class="delete-video-title" :title="item.videoTitle || '无标题'">
|
||||
{{ item.videoTitle }}
|
||||
</span>
|
||||
<div class="deleted-video-row">
|
||||
<a-checkbox :checked="selectedExclusionIds.includes(item.id)" @change="(event) => toggleExclusion(item.id, event.target.checked)" />
|
||||
<div class="delete-video-title-container">
|
||||
<span class="delete-video-index">{{ (deletedPage - 1) * deletedPageSize + index + 1 }}.</span>
|
||||
<div class="deleted-video-text">
|
||||
<span class="delete-video-title" :title="item.videoTitle || '无标题'">{{ item.videoTitle || '无标题' }}</span>
|
||||
<small>{{ item.author || '未知博主' }} · 作品 {{ item.viedoId || '未知' }} · {{ formatDeletedDate(item.deleteTime) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<a-button size="small" @click="openUnexclude([item.id])">取消排除</a-button>
|
||||
</div>
|
||||
|
||||
<!-- <a-button type="text" size="small" class="copy-delete-video-btn" @click="(e) => copyVideoPath(item.videoSavePath)">
|
||||
<CopyOutlined /> 复制
|
||||
</a-button> -->
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-list>
|
||||
</a-spin>
|
||||
<a-pagination
|
||||
v-if="deletedTotal > deletedPageSize"
|
||||
class="deleted-video-pagination"
|
||||
:current="deletedPage"
|
||||
:page-size="deletedPageSize"
|
||||
:total="deletedTotal"
|
||||
show-less-items
|
||||
@change="getDeleteViedos"
|
||||
/>
|
||||
</a-drawer>
|
||||
<a-modal
|
||||
v-model:visible="unexcludeVisible"
|
||||
title="取消永久排除"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="unexcluding"
|
||||
@ok="submitUnexclude"
|
||||
>
|
||||
<a-radio-group v-model:value="unexcludeMode" class="record-unexclude-options">
|
||||
<a-radio value="only">
|
||||
<strong>仅取消排除</strong>
|
||||
<span>等待后续正常同步重新发现视频。</span>
|
||||
</a-radio>
|
||||
<a-radio value="restore">
|
||||
<strong>取消排除并创建恢复任务</strong>
|
||||
<span>立即尝试保存的下载地址和抖音回源;资料不足的旧记录仍会成功取消排除。</span>
|
||||
</a-radio>
|
||||
</a-radio-group>
|
||||
</a-modal>
|
||||
<!-- 视频播放弹窗 - 保持原有 -->
|
||||
<a-modal v-model:visible="isModalOpen" :width="900" :mask-closable="false" :footer="null" @cancel="handleCancel" :body-style="{ padding: '0', overflow: 'hidden', backgroundColor: '#fff' }" :style="{
|
||||
<a-modal v-model:visible="isModalOpen" wrap-class-name="mobile-full-modal" :width="900" :mask-closable="false" :footer="null" @cancel="handleCancel" :body-style="{ padding: '0', overflow: 'hidden', backgroundColor: '#fff' }" :style="{
|
||||
borderRadius: '8px',
|
||||
maxWidth: '85vw',
|
||||
maxHeight: '80vh',
|
||||
@@ -152,7 +214,7 @@
|
||||
</a-modal>
|
||||
|
||||
<!-- 表格 - 增加复选框和操作列 -->
|
||||
<a-table :columns="columns" :data-source="dataSource" bordered :pagination="pagination" @change="handleTableChange" :loading="loading" :row-selection="isBatchMode ? rowSelection : null" row-key="id" :sorter="true">
|
||||
<a-table v-if="!isMobileView" :columns="columns" :data-source="dataSource" bordered :pagination="pagination" @change="handleTableChange" :loading="loading" :row-selection="isBatchMode ? rowSelection : null" row-key="id" :sorter="true" :locale="{ emptyText: emptyDescription }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'videoTitle'">
|
||||
<a class="video-title-link" :title="record.videoTitle || '无标题'" @click="handleVideoClick(record)" @mouseenter="handleTitleMouseEnter" @mouseleave="handleTitleMouseLeave">
|
||||
@@ -177,17 +239,62 @@
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<div v-else class="mobile-record-list">
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty v-if="!loading && dataSource.length === 0" :description="emptyDescription" />
|
||||
<article v-for="record in dataSource" :key="record.id" class="mobile-record-card">
|
||||
<div class="mobile-record-card__header">
|
||||
<a-checkbox
|
||||
v-if="isBatchMode"
|
||||
:checked="selectedRowKeys.includes(record.id || '')"
|
||||
:disabled="isSyncing"
|
||||
@change="(event) => toggleMobileSelection(record.id, event.target.checked)"
|
||||
/>
|
||||
<button class="mobile-record-title" type="button" @click="handleVideoClick(record)">
|
||||
{{ record.videoTitle || '无标题' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mobile-record-tags">
|
||||
<a-tag color="blue">{{ record.viedoTypeStr || '未知类型' }}</a-tag>
|
||||
<a-tag v-if="record.viedoCate">{{ record.viedoCate }}</a-tag>
|
||||
<a-tag v-if="record.onlyImgOrOnlyMp3" color="orange">仅图片/音频</a-tag>
|
||||
</div>
|
||||
<dl class="mobile-record-meta">
|
||||
<div><dt>博主</dt><dd>{{ record.author || '未知' }}</dd></div>
|
||||
<div><dt>发布时间</dt><dd>{{ record.createTimeStr || '未知' }}</dd></div>
|
||||
<div><dt>同步时间</dt><dd>{{ record.syncTimeStr || '未知' }}</dd></div>
|
||||
<div><dt>存储路径</dt><dd class="path-value">{{ formatPathSeparator(record.videoSavePath) || '未记录' }}</dd></div>
|
||||
</dl>
|
||||
<div class="mobile-record-actions">
|
||||
<a-button size="small" @click="handleReDownload(record)" :disabled="isSyncing"><SyncOutlined />重新同步</a-button>
|
||||
<a-button size="small" @click="handleShare(record)" :disabled="!record.id || record.onlyImgOrOnlyMp3"><ShareAltOutlined />分享</a-button>
|
||||
<a-button size="small" danger @click="handleDelete(record)" :disabled="!record.id"><DeleteOutlined />删除</a-button>
|
||||
</div>
|
||||
</article>
|
||||
<a-pagination
|
||||
v-if="pagination.total > 0"
|
||||
class="mobile-record-pagination"
|
||||
simple
|
||||
:current="pagination.current"
|
||||
:page-size="pagination.defaultPageSize"
|
||||
:total="pagination.total"
|
||||
@change="handleMobilePageChange"
|
||||
/>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, onMounted, nextTick, watch, computed } from 'vue';
|
||||
import { reactive, ref, onMounted, onBeforeUnmount, nextTick, watch, computed } from 'vue';
|
||||
import { useApiStore } from '@/store';
|
||||
import type { UnwrapRef } from 'vue';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import locale from 'ant-design-vue/es/date-picker/locale/zh_CN';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import CryptoJS from 'crypto-js';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
SearchOutlined,
|
||||
SyncOutlined,
|
||||
@@ -197,6 +304,9 @@ import {
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 类型定义
|
||||
type RangeValue = [Dayjs, Dayjs];
|
||||
interface DataItem {
|
||||
@@ -212,6 +322,7 @@ interface DataItem {
|
||||
videoSavePath: string;
|
||||
createTimeStr?: string; // 发布时间
|
||||
isMergeVideo?: boolean;
|
||||
onlyImgOrOnlyMp3?: boolean;
|
||||
}
|
||||
|
||||
// 📌 新增:排序参数类型定义
|
||||
@@ -242,6 +353,15 @@ dayjs.locale('zh-cn');
|
||||
// 批量操作相关状态
|
||||
const isBatchMode = ref(false); // 批量操作开关状态
|
||||
const selectedRowKeys = ref<string[]>([]); // 选中的行ID集合
|
||||
const isMobileView = ref(window.innerWidth <= 768);
|
||||
const updateMobileView = () => (isMobileView.value = window.innerWidth <= 768);
|
||||
const mobileFiltersOpen = ref(false);
|
||||
const toggleMobileSelection = (id: string | undefined, checked: boolean) => {
|
||||
if (!id) return;
|
||||
selectedRowKeys.value = checked
|
||||
? Array.from(new Set([...selectedRowKeys.value, id]))
|
||||
: selectedRowKeys.value.filter((key) => key !== id);
|
||||
};
|
||||
// 📌 新增:排序状态管理
|
||||
const sortParams = ref<SortParam>({
|
||||
field: 'syncTime', // 默认排序字段(发布时间)
|
||||
@@ -395,6 +515,9 @@ watch(isBatchMode, (isOpen) => {
|
||||
const loading = ref(false);
|
||||
const showImageViedo = ref(true);
|
||||
const dataSource = ref<DataItem[]>([]); // 直接用 ref 数组存储表格数据,减少响应式嵌套
|
||||
const routeAuthorName = ref('');
|
||||
const cookiesReady = ref(false);
|
||||
let suppressAuthorRouteWatch = false;
|
||||
|
||||
// 查询参数
|
||||
const value1 = ref<RangeValue>();
|
||||
@@ -420,6 +543,50 @@ const quaryData: UnwrapRef<QuaryParam> = reactive({
|
||||
sortOrder: 'desc', // 📌 默认降序
|
||||
cookieId: '',
|
||||
});
|
||||
const activeAuthorFilter = computed(() => !!quaryData.authorId || !!routeAuthorName.value);
|
||||
const activeAuthorLabel = computed(() => routeAuthorName.value || quaryData.author || quaryData.authorId || '该博主');
|
||||
const emptyDescription = computed(() => activeAuthorFilter.value
|
||||
? `「${activeAuthorLabel.value}」暂无已同步视频`
|
||||
: '暂无同步记录');
|
||||
|
||||
const queryText = (value: unknown) => Array.isArray(value) ? String(value[0] || '') : String(value || '');
|
||||
const applyAuthorRoute = () => {
|
||||
const authorId = queryText(route.query.authorId).trim();
|
||||
const author = queryText(route.query.author).trim();
|
||||
quaryData.authorId = authorId;
|
||||
routeAuthorName.value = author;
|
||||
// 有 UID 时只把名称用于界面提示,避免博主改名后名称条件误伤精确查询。
|
||||
quaryData.author = authorId ? '' : author;
|
||||
pagination.value.current = 1;
|
||||
};
|
||||
|
||||
const removeAuthorQuery = async () => {
|
||||
const query = { ...route.query };
|
||||
delete query.authorId;
|
||||
delete query.author;
|
||||
await router.replace({ path: route.path, query });
|
||||
};
|
||||
|
||||
const handleAuthorInput = () => {
|
||||
if (!quaryData.authorId && !routeAuthorName.value) return;
|
||||
quaryData.authorId = '';
|
||||
routeAuthorName.value = '';
|
||||
suppressAuthorRouteWatch = true;
|
||||
void removeAuthorQuery().finally(() => { suppressAuthorRouteWatch = false; });
|
||||
};
|
||||
|
||||
const clearAuthorFilter = async () => {
|
||||
quaryData.author = '';
|
||||
quaryData.authorId = '';
|
||||
routeAuthorName.value = '';
|
||||
pagination.value.current = 1;
|
||||
suppressAuthorRouteWatch = true;
|
||||
try { await removeAuthorQuery(); }
|
||||
finally { suppressAuthorRouteWatch = false; }
|
||||
GetRecords();
|
||||
};
|
||||
|
||||
const returnToFollows = () => router.push('/follow');
|
||||
|
||||
// 分页配置
|
||||
const pagination = ref({
|
||||
@@ -500,6 +667,7 @@ const handleTitleMouseLeave = (e: Event) => {
|
||||
// -------------------------- 核心业务方法 --------------------------
|
||||
/** 查询表格数据 */
|
||||
const GetRecords = () => {
|
||||
if (isMobileView.value) mobileFiltersOpen.value = false;
|
||||
loading.value = true;
|
||||
quaryData.pageIndex = pagination.value.current;
|
||||
quaryData.pageSize = pagination.value.defaultPageSize;
|
||||
@@ -577,13 +745,21 @@ const handleTableChange = (paginationObj: any, filters: any, sorter: any) => {
|
||||
GetRecords();
|
||||
};
|
||||
|
||||
const handleMobilePageChange = (page: number, pageSize: number) => {
|
||||
pagination.value.current = page;
|
||||
pagination.value.defaultPageSize = pageSize;
|
||||
selectedRowKeys.value = [];
|
||||
GetRecords();
|
||||
};
|
||||
|
||||
const cookies = ref([]);
|
||||
const getCookies = () => {
|
||||
useApiStore()
|
||||
.CookiePageList({})
|
||||
.then((res) => {
|
||||
if (res.data.data.length > 0) {
|
||||
cookies.value = res.data.data.map((item) => {
|
||||
const records = res.data?.data || [];
|
||||
if (records.length > 0) {
|
||||
cookies.value = records.map((item) => {
|
||||
return {
|
||||
value: item['id'] ?? '',
|
||||
label: item['userName'] ?? '',
|
||||
@@ -593,10 +769,10 @@ const getCookies = () => {
|
||||
value: '', // 全部对应的 value 为空字符串
|
||||
label: '全部', // 显示的文本,可根据需求修改
|
||||
});
|
||||
|
||||
quaryData.cookieId = cookies.value[0].value;
|
||||
GetRecords();
|
||||
}
|
||||
quaryData.cookieId = '';
|
||||
cookiesReady.value = true;
|
||||
GetRecords();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -655,8 +831,8 @@ const onViedoTypeChanged = () => {
|
||||
// -------------------------- 视频播放相关方法 --------------------------
|
||||
/** 点击视频标题播放 */
|
||||
const handleVideoClick = (record: DataItem) => {
|
||||
if (record.isMergeVideo && record.videoSavePath.length == 0) {
|
||||
message.warning('图文视频配置:不下载视频,所有没有可播放的视频');
|
||||
if (record.onlyImgOrOnlyMp3 || (record.isMergeVideo && !record.videoSavePath?.length)) {
|
||||
message.warning('该记录只保存了图片或音频,没有可播放的视频');
|
||||
return;
|
||||
}
|
||||
// 保存当前视频信息
|
||||
@@ -825,19 +1001,71 @@ const handleBatchDelete = () => {
|
||||
const deleteVideoShow = ref(false);
|
||||
const handShowDeleteVideos = () => {
|
||||
deleteVideoShow.value = true;
|
||||
getDeleteViedos();
|
||||
getDeleteViedos(1);
|
||||
};
|
||||
|
||||
const deleteVideos = ref([]);
|
||||
const getDeleteViedos = () => {
|
||||
useApiStore()
|
||||
.GetDeleteViedos()
|
||||
.then((res) => {
|
||||
deleteVideos.value = res.data;
|
||||
});
|
||||
const deleteVideos = ref<any[]>([]);
|
||||
const deletedLoading = ref(false);
|
||||
const deletedPage = ref(1);
|
||||
const deletedPageSize = 20;
|
||||
const deletedTotal = ref(0);
|
||||
const exclusionKeyword = ref('');
|
||||
const selectedExclusionIds = ref<string[]>([]);
|
||||
const unexcludeVisible = ref(false);
|
||||
const unexcludeMode = ref<'only' | 'restore'>('only');
|
||||
const pendingUnexcludeIds = ref<string[]>([]);
|
||||
const unexcluding = ref(false);
|
||||
const allExclusionsSelected = computed(() => deleteVideos.value.length > 0 && deleteVideos.value.every(item => selectedExclusionIds.value.includes(item.id)));
|
||||
const someExclusionsSelected = computed(() => !allExclusionsSelected.value && deleteVideos.value.some(item => selectedExclusionIds.value.includes(item.id)));
|
||||
const getDeleteViedos = async (page = deletedPage.value) => {
|
||||
deletedPage.value = page;
|
||||
deletedLoading.value = true;
|
||||
try {
|
||||
const res = await useApiStore().VideoExclusions({ pageIndex: page, pageSize: deletedPageSize, keyword: exclusionKeyword.value });
|
||||
if (res.code !== 0) throw new Error(res.message || '获取永久排除列表失败');
|
||||
deleteVideos.value = res.data.items || [];
|
||||
deletedTotal.value = res.data.totalCount || 0;
|
||||
selectedExclusionIds.value = [];
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '获取永久排除列表失败');
|
||||
} finally {
|
||||
deletedLoading.value = false;
|
||||
}
|
||||
};
|
||||
const onDeleteVideoClose = (e) => {
|
||||
const toggleExclusion = (id: string, checked: boolean) => {
|
||||
selectedExclusionIds.value = checked
|
||||
? Array.from(new Set([...selectedExclusionIds.value, id]))
|
||||
: selectedExclusionIds.value.filter(item => item !== id);
|
||||
};
|
||||
const toggleAllExclusions = (event: any) => {
|
||||
const pageIds = deleteVideos.value.map(item => item.id);
|
||||
selectedExclusionIds.value = event.target.checked
|
||||
? Array.from(new Set([...selectedExclusionIds.value, ...pageIds]))
|
||||
: selectedExclusionIds.value.filter(id => !pageIds.includes(id));
|
||||
};
|
||||
const openUnexclude = (ids: string[]) => {
|
||||
pendingUnexcludeIds.value = [...ids];
|
||||
unexcludeMode.value = 'only';
|
||||
unexcludeVisible.value = true;
|
||||
};
|
||||
const submitUnexclude = async () => {
|
||||
unexcluding.value = true;
|
||||
try {
|
||||
const res = await useApiStore().UnexcludeVideos(pendingUnexcludeIds.value, unexcludeMode.value === 'restore');
|
||||
if (res.code !== 0) throw new Error(res.message || '取消排除失败');
|
||||
message.success(`${res.data.message || '已取消永久排除'}${res.data.taskId ? ' 可在任务中心查看进度。' : ''}`);
|
||||
unexcludeVisible.value = false;
|
||||
await getDeleteViedos(deletedPage.value);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '取消排除失败');
|
||||
} finally {
|
||||
unexcluding.value = false;
|
||||
}
|
||||
};
|
||||
const formatDeletedDate = (value?: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '未知时间';
|
||||
const onDeleteVideoClose = () => {
|
||||
deleteVideoShow.value = false;
|
||||
selectedExclusionIds.value = [];
|
||||
};
|
||||
|
||||
const reDownload = (param: object) => {
|
||||
@@ -1037,9 +1265,24 @@ const copyVideoPath = (path?: string) => {
|
||||
|
||||
// -------------------------- 页面初始化 --------------------------
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updateMobileView);
|
||||
applyAuthorRoute();
|
||||
// getConfig();
|
||||
getCookies();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [route.query.authorId, route.query.author],
|
||||
() => {
|
||||
if (suppressAuthorRouteWatch) return;
|
||||
applyAuthorRoute();
|
||||
if (cookiesReady.value) GetRecords();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateMobileView);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -1227,6 +1470,90 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-record-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.mobile-filter-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--mobile-border, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
background: var(--mobile-card, #fff);
|
||||
color: var(--mobile-text, #334155);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-filter-trigger.active {
|
||||
border-color: #b794f4;
|
||||
background: #f7f2ff;
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
.query-container--mobile {
|
||||
margin-bottom: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--mobile-border, #e5e7eb);
|
||||
border-radius: 14px;
|
||||
background: var(--mobile-card, #fff);
|
||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.query-container--mobile .form-main-row,
|
||||
.query-container--mobile .form-actions-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.query-container--mobile :deep(.ant-form-item),
|
||||
.query-container--mobile .form-item,
|
||||
.query-container--mobile .form-item-date,
|
||||
.query-container--mobile .form-item-input,
|
||||
.query-container--mobile .radio-group-item,
|
||||
.query-container--mobile .batch-operation-item,
|
||||
.query-container--mobile .button-group-item,
|
||||
.query-container--mobile .delete-btn-2-wrapper {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.query-container--mobile .range-picker,
|
||||
.query-container--mobile .query-input,
|
||||
.query-container--mobile :deep(.ant-select) {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.query-container--mobile .video-type-radio {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.query-container--mobile .video-type-radio :deep(.ant-radio-button-wrapper) {
|
||||
overflow: hidden;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.query-container--mobile .query-button,
|
||||
.query-container--mobile .delete-button-2,
|
||||
.query-container--mobile .button-group {
|
||||
width: 100%;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.form-item-date,
|
||||
.form-item-input {
|
||||
flex: 1 1 100%; /* 占满整行 */
|
||||
@@ -1521,6 +1848,65 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* 已删除视频 - 列表项布局优化 */
|
||||
.deleted-video-alert {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.deleted-video-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.deleted-video-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.deleted-video-text {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.deleted-video-text small {
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
color: #8c8c8c;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deleted-video-pagination {
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.record-unexclude-options {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.record-unexclude-options .ant-radio-wrapper {
|
||||
align-items: flex-start;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.record-unexclude-options strong,
|
||||
.record-unexclude-options span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.record-unexclude-options span {
|
||||
margin-top: 3px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
:deep(.ant-list-item) {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
@@ -1578,8 +1964,181 @@ onMounted(() => {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.mobile-record-list {
|
||||
padding: 0 10px 24px;
|
||||
}
|
||||
|
||||
.mobile-record-card {
|
||||
margin-bottom: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 16px;
|
||||
background: var(--mobile-card, #fff);
|
||||
box-shadow: 0 3px 14px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.mobile-record-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mobile-record-title {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--mobile-text, #172033);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-record-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mobile-record-meta {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.mobile-record-meta > div {
|
||||
display: grid;
|
||||
grid-template-columns: 68px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mobile-record-meta dt {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.mobile-record-meta dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.mobile-record-meta .path-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-record-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.mobile-record-actions { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.mobile-record-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.author-filter-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #91caff;
|
||||
border-radius: 8px;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.author-filter-banner > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.author-filter-banner span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 可选:适配移动端,优化小屏幕显示 */
|
||||
@media (max-width: 768px) {
|
||||
.author-filter-banner {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.author-filter-banner :deep(.ant-space) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.deleted-video-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.deleted-video-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.deleted-video-row > .ant-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.query-container {
|
||||
margin: 8px 0 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.form-main-row,
|
||||
.form-actions-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-item,
|
||||
.form-item-date,
|
||||
.form-item-input,
|
||||
.radio-group-item,
|
||||
.batch-operation-item,
|
||||
.button-group-item,
|
||||
.delete-btn-2-wrapper {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
margin: 0 0 8px !important;
|
||||
}
|
||||
|
||||
.range-picker,
|
||||
.query-input,
|
||||
.form-actions-row .ant-select {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.video-type-radio {
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
padding-bottom: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.video-type-radio .ant-radio-button-wrapper {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.query-button,
|
||||
.delete-button-2 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.delete-video-title-container {
|
||||
margin-right: 12px;
|
||||
}
|
||||
@@ -1606,4 +2165,23 @@ onMounted(() => {
|
||||
html.dark-mode .ant-table-column-sort {
|
||||
background: #161627;
|
||||
}
|
||||
</style>
|
||||
|
||||
html.dark-mode .mobile-record-card {
|
||||
border-color: #33354a;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
html.dark-mode .mobile-record-meta dd {
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
html.dark-mode .author-filter-banner {
|
||||
border-color: #15395b;
|
||||
background: #111d2c;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html.dark-mode .author-filter-banner span {
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="workplace grid grid-rows-none gap-4 mt-xxs">
|
||||
<div class="project-list grid grid-cols-24 gap-4">
|
||||
<records class="col-span-12 xlx:col-span-7 xxlx:col-span-8 drop-shadow-sm" />
|
||||
<records class="record-panel col-span-12 xlx:col-span-7 xxlx:col-span-8 drop-shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,4 +17,15 @@ useUnbounded();
|
||||
<style scoped lang="less">
|
||||
.workplace {
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.project-list {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.record-panel {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="workplace grid grid-rows-none gap-4 mt-xxs">
|
||||
<div class="project-list grid grid-cols-24 gap-4">
|
||||
<records class="col-span-12 xlx:col-span-7 xxlx:col-span-8 drop-shadow-sm" />
|
||||
<records class="record-panel col-span-12 xlx:col-span-7 xxlx:col-span-8 drop-shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,4 +17,14 @@ useUnbounded();
|
||||
<style scoped lang="less">
|
||||
.workplace {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.project-list {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.record-panel {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
<transition name="stats-fade" mode="out-in">
|
||||
<div v-if="currentTab === 'author'" key="author-view" class="stats-content">
|
||||
<div class="authors-grid">
|
||||
<div class="author-card" v-for="(author, index) in authors" :key="index" @dblclick="handleDeleteItem(author)">
|
||||
<div class="author-card" v-for="(author, index) in authors" :key="index">
|
||||
<div class="author-info-row">
|
||||
<div class="author-avatar">
|
||||
<img :src="author.icon" alt="作者头像" />
|
||||
@@ -137,6 +137,9 @@
|
||||
<div class="author-progress">
|
||||
<div class="progress-bar" :style="{ width: `${(author.count / totalVideos) * 100}%` }"></div>
|
||||
</div>
|
||||
<a-button class="author-card-action" type="text" danger aria-label="删除该博主的全部视频" @click="handleDeleteItem(author)">
|
||||
<DeleteOutlined />删除视频
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,7 +184,7 @@ import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
import { useApiStore } from '@/store';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import * as echarts from 'echarts';
|
||||
import { FullscreenOutlined } from '@ant-design/icons-vue'; // 确保导入图标
|
||||
import { DeleteOutlined, FullscreenOutlined } from '@ant-design/icons-vue'; // 确保导入图标
|
||||
|
||||
// 类型接口不变
|
||||
interface Author {
|
||||
@@ -715,6 +718,97 @@ const handleDeleteItem = (item: Author) => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.stats-dashboard {
|
||||
min-height: auto;
|
||||
padding: 0 0 16px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dashboard-container {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stats-left,
|
||||
.stats-right {
|
||||
padding: 14px !important;
|
||||
border-color: var(--mobile-border, #e0e0e0);
|
||||
border-radius: 16px;
|
||||
background: var(--mobile-card, #fff);
|
||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stat-subitems {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.subitem {
|
||||
min-width: 0;
|
||||
padding: 8px 6px;
|
||||
background: var(--mobile-card, #fff);
|
||||
}
|
||||
|
||||
.subitem-meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
min-height: 240px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.detailed-stats {
|
||||
padding: 14px 10px;
|
||||
border: 1px solid var(--mobile-border, #e5e7eb);
|
||||
border-radius: 16px;
|
||||
background: var(--mobile-card, #fff);
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
:deep(.ant-modal) {
|
||||
width: calc(100vw - 20px) !important;
|
||||
max-width: none;
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
.authors-grid,
|
||||
.categories-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.author-card,
|
||||
.category-card {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.main-card:hover,
|
||||
.author-card:hover,
|
||||
.category-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.main-card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
|
||||
@@ -865,6 +959,12 @@ const handleDeleteItem = (item: Author) => {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.author-card-action {
|
||||
align-self: flex-end;
|
||||
min-height: 32px !important;
|
||||
padding: 0 6px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
.author-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12);
|
||||
@@ -1104,4 +1204,4 @@ html.dark-mode .fullscreen-btn:hover {
|
||||
color: #42a5f5;
|
||||
border-color: #42a5f5;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+4
-142
@@ -1,10 +1,9 @@
|
||||
import { NavigationGuard, NavigationHookAfter } from 'vue-router';
|
||||
import http from '@/store/http';
|
||||
import { useAccountStore, useMenuStore, useApiStore } from '@/store';
|
||||
import { useAccountStore, useMenuStore } from '@/store';
|
||||
import { useAuthStore } from '@/plugins';
|
||||
import NProgress from 'nprogress';
|
||||
import 'nprogress/nprogress.css';
|
||||
import router from '@/router';
|
||||
|
||||
NProgress.configure({ showSpinner: false });
|
||||
|
||||
@@ -13,116 +12,16 @@ interface NaviGuard {
|
||||
after?: NavigationHookAfter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为移动端设备(UA + 屏幕宽度双检测)
|
||||
*/
|
||||
const isMobile = (): boolean => {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const mobileUaReg = /iphone|android|ipad|ipod|mobile|wap|symbian|windows ce|blackberry|webos|ucbrowser/i;
|
||||
const isSmallScreen = window.innerWidth < 768;
|
||||
return mobileUaReg.test(userAgent) || isSmallScreen;
|
||||
};
|
||||
|
||||
// 标记是否已跳转到移动端路由,防止无限循环
|
||||
let hasRedirectedToMobile = false;
|
||||
// 新增:标记是否已从/mobile跳转到/dashboard,防止无限循环
|
||||
let hasRedirectedToDashboard = false;
|
||||
// 优化后的移动端跳转守卫(登录优先)
|
||||
const MobileRedirectGuard: NavigationGuard = function (to, from, next) {
|
||||
// 1. 排除移动端路由和登录页,避免逻辑干扰
|
||||
const isMobileRoute = to.path === '/mobile';
|
||||
const isLoginRoute = to.path === '/login';
|
||||
// 新增:检测当前是否为非移动端设备
|
||||
const isNonMobile = !isMobile();
|
||||
|
||||
// 【新增核心逻辑】:当前路由是/mobile,但不是移动端设备,跳转到/dashboard
|
||||
if (isMobileRoute && isNonMobile) {
|
||||
if (!hasRedirectedToDashboard) {
|
||||
hasRedirectedToDashboard = true;
|
||||
// 重置移动端跳转标记,避免后续干扰
|
||||
hasRedirectedToMobile = false;
|
||||
next({ path: '/dashboard' });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMobileRoute) {
|
||||
hasRedirectedToMobile = true;
|
||||
// 重置dashboard跳转标记,避免后续干扰
|
||||
hasRedirectedToDashboard = false;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoginRoute) {
|
||||
// 登录页无需移动端跳转,直接放行
|
||||
hasRedirectedToMobile = false; // 重置标记,登录后可正常跳转移动端
|
||||
hasRedirectedToDashboard = false; // 重置dashboard跳转标记
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 移动端检测
|
||||
if (isMobile()) {
|
||||
const isAuthorized = http.checkAuthorization();
|
||||
if (!isAuthorized) {
|
||||
// 未登录:优先跳登录(保持原有优先级)
|
||||
next('/login');
|
||||
} else {
|
||||
// 已登录:跳移动端(防止重复跳转)
|
||||
if (!hasRedirectedToMobile) {
|
||||
hasRedirectedToMobile = true;
|
||||
hasRedirectedToDashboard = false; // 重置dashboard跳转标记
|
||||
next({ path: '/mobile' });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 非移动端:重置所有标记,不影响后续操作
|
||||
hasRedirectedToMobile = false;
|
||||
hasRedirectedToDashboard = false;
|
||||
next();
|
||||
}
|
||||
};
|
||||
const loginGuard: NavigationGuard = function (to, from, next) {
|
||||
if (!http.checkAuthorization() && !/^\/(init|login|home|mobile)?$/.test(to.fullPath)) {
|
||||
if (!http.checkAuthorization() && !/^\/(init|login|home)?$/.test(to.fullPath)) {
|
||||
console.log(to.fullPath)
|
||||
const account = useAccountStore();
|
||||
account.setLogged(false);
|
||||
// 重置dashboard跳转标记
|
||||
hasRedirectedToDashboard = false;
|
||||
next('/login');
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
const dynamicinitRoute = {
|
||||
path: '/',
|
||||
name: 'login',
|
||||
redirect: '/login',
|
||||
meta: {
|
||||
title: '登录',
|
||||
renderMenu: false,
|
||||
icon: 'CreditCardOutlined',
|
||||
},
|
||||
children: null,
|
||||
component: () => import('@/pages/login'),
|
||||
};
|
||||
|
||||
const InitGuard: NavigationGuard = function (to, from, next) {
|
||||
if (to.fullPath != '/login') {
|
||||
if (!router.hasRoute('login')) {
|
||||
router.addRoute(dynamicinitRoute);
|
||||
}
|
||||
next('/login');
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
// 进度条
|
||||
const ProgressGuard: NaviGuard = {
|
||||
before(to, from, next) {
|
||||
@@ -172,44 +71,7 @@ const NotFoundGuard: NaviGuard = {
|
||||
},
|
||||
};
|
||||
|
||||
// 优化后的页面刷新移动端检测(登录优先)
|
||||
window.addEventListener('load', () => {
|
||||
const currentPath = window.location.pathname;
|
||||
const isNonMobile = !isMobile();
|
||||
|
||||
// 【新增】:刷新后如果是/mobile路由且非移动端,跳转到/dashboard
|
||||
if (currentPath === '/mobile' && isNonMobile) {
|
||||
router.push('/dashboard').catch(err => {
|
||||
if (!err.message.includes('NavigationDuplicated')) {
|
||||
console.error('刷新时从/mobile跳转到/dashboard失败:', err);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMobile() && currentPath !== '/mobile') {
|
||||
const isAuthorized = http.checkAuthorization();
|
||||
if (!isAuthorized) {
|
||||
// 未登录:跳登录(避免重复跳转)
|
||||
if (currentPath !== '/login') {
|
||||
router.push('/login').catch(err => {
|
||||
if (!err.message.includes('NavigationDuplicated')) {
|
||||
console.error('刷新时跳转登录页失败:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 已登录:跳移动端
|
||||
router.push('/mobile').catch(err => {
|
||||
if (!err.message.includes('NavigationDuplicated')) {
|
||||
console.error('刷新时跳转移动端路由失败:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default {
|
||||
before: [ProgressGuard.before, MobileRedirectGuard, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
|
||||
before: [ProgressGuard.before, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
|
||||
after: [ProgressGuard.after],
|
||||
};
|
||||
};
|
||||
|
||||
+23
-17
@@ -15,16 +15,15 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/login'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'mobile',
|
||||
redirect: '/mobile',
|
||||
path: '/mobile',
|
||||
name: 'mobile-legacy',
|
||||
redirect: '/dashboard',
|
||||
meta: {
|
||||
title: '移动端首页',
|
||||
title: '移动端兼容入口',
|
||||
renderMenu: false,
|
||||
icon: 'CreditCardOutlined',
|
||||
},
|
||||
children: null,
|
||||
component: () => import('@/pages/mobile/MobileDashboard.vue'),
|
||||
},
|
||||
|
||||
{
|
||||
@@ -71,18 +70,6 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: () => import('@/pages/login'),
|
||||
},
|
||||
{
|
||||
path: '/mobile',
|
||||
name: 'mobile',
|
||||
meta: {
|
||||
icon: 'LoginOutlined',
|
||||
view: 'blank',
|
||||
target: '_blank',
|
||||
cacheable: false,
|
||||
},
|
||||
children: null,
|
||||
component: () => import('@/pages/mobile/MobileDashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: '/init',
|
||||
name: 'init',
|
||||
@@ -137,6 +124,7 @@ const routes: RouteRecordRaw[] = [
|
||||
name: '数据看板',
|
||||
meta: {
|
||||
icon: 'RadarChartOutlined',
|
||||
mobileTitle: '数据看板',
|
||||
view: 'self',
|
||||
target: '_self',
|
||||
renderMenu: true,
|
||||
@@ -149,6 +137,7 @@ const routes: RouteRecordRaw[] = [
|
||||
name: '同步记录',
|
||||
meta: {
|
||||
icon: 'SwapOutlined',
|
||||
mobileTitle: '同步记录',
|
||||
view: 'self',
|
||||
target: '_self',
|
||||
renderMenu: true,
|
||||
@@ -156,11 +145,25 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: () => import('@/pages/workplace/Workplace.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tasks',
|
||||
name: '任务中心',
|
||||
meta: {
|
||||
icon: 'ProfileOutlined',
|
||||
mobileTitle: '任务中心',
|
||||
view: 'self',
|
||||
target: '_self',
|
||||
renderMenu: true,
|
||||
cacheable: false,
|
||||
},
|
||||
component: () => import('@/pages/tasks/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/follow',
|
||||
name: '关注列表',
|
||||
meta: {
|
||||
icon: 'HeartOutlined',
|
||||
mobileTitle: '关注博主',
|
||||
view: 'self',
|
||||
target: '_self',
|
||||
renderMenu: true,
|
||||
@@ -173,6 +176,7 @@ const routes: RouteRecordRaw[] = [
|
||||
name: '抖音授权',
|
||||
meta: {
|
||||
icon: 'SafetyCertificateOutlined',
|
||||
mobileTitle: '抖音授权',
|
||||
view: 'self',
|
||||
target: '_self',
|
||||
renderMenu: true,
|
||||
@@ -186,6 +190,7 @@ const routes: RouteRecordRaw[] = [
|
||||
name: '系统配置',
|
||||
meta: {
|
||||
icon: 'SettingOutlined',
|
||||
mobileTitle: '系统设置',
|
||||
view: 'self',
|
||||
target: '_self',
|
||||
renderMenu: true,
|
||||
@@ -213,6 +218,7 @@ const routes: RouteRecordRaw[] = [
|
||||
name: '系统日志',
|
||||
meta: {
|
||||
icon: 'UnorderedListOutlined',
|
||||
mobileTitle: '系统日志',
|
||||
view: 'self',
|
||||
target: '_self',
|
||||
renderMenu: true,
|
||||
|
||||
@@ -62,6 +62,154 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
async function StorageConfig() {
|
||||
return http.request<any, Response<any>>('/api/storage/config', 'get');
|
||||
}
|
||||
|
||||
async function StorageInventory() {
|
||||
return http.request<any, Response<any>>('/api/storage/inventory', 'get');
|
||||
}
|
||||
|
||||
async function TestStorage(request: object) {
|
||||
return http.request<any, Response<any>>('/api/storage/test', 'post_json', request);
|
||||
}
|
||||
|
||||
async function OpenListDirectories(request: object) {
|
||||
return http.request<any, Response<any>>('/api/storage/openlist/directories', 'post_json', request);
|
||||
}
|
||||
|
||||
async function UpdateStorage(request: object) {
|
||||
return http.request<any, Response<any>>('/api/storage/config', 'put_json', request);
|
||||
}
|
||||
|
||||
async function EmailConfig() {
|
||||
return http.request<any, Response<any>>('/api/email/config', 'get');
|
||||
}
|
||||
|
||||
async function UpdateEmailConfig(request: object) {
|
||||
return http.request<any, Response<any>>('/api/email/config', 'put_json', request);
|
||||
}
|
||||
|
||||
async function TestEmailConfig(request: object) {
|
||||
return http.request<any, Response<any>>('/api/email/test', 'post_json', request);
|
||||
}
|
||||
|
||||
async function StorageMigrationPreflight() {
|
||||
return http.request<any, Response<any>>('/api/storage/migrations/preflight', 'post_json', { verifyCapabilities: true });
|
||||
}
|
||||
|
||||
async function CreateStorageMigration(request: object) {
|
||||
return http.request<any, Response<any>>('/api/storage/migrations', 'post_json', request);
|
||||
}
|
||||
|
||||
async function LatestStorageMigration() {
|
||||
return http.request<any, Response<any>>('/api/storage/migrations/latest', 'get');
|
||||
}
|
||||
|
||||
async function StorageMigrationDetail(id: string) {
|
||||
return http.request<any, Response<any>>(`/api/storage/migrations/${id}`, 'get');
|
||||
}
|
||||
|
||||
async function StorageMigrationItems(id: string, pageIndex: number, pageSize: number, stage?: number) {
|
||||
const filter = stage === undefined ? '' : `&stage=${stage}`;
|
||||
return http.request<any, Response<any>>(`/api/storage/migrations/${id}/items?pageIndex=${pageIndex}&pageSize=${pageSize}${filter}`, 'get');
|
||||
}
|
||||
|
||||
async function StorageMigrationAction(id: string, action: string) {
|
||||
return http.request<any, Response<any>>(`/api/storage/migrations/${id}/${action}`, 'post_json', {});
|
||||
}
|
||||
|
||||
async function StorageMigrationFailedRecordsPreview() {
|
||||
return http.request<any, Response<any>>('/api/storage/migrations/failed-records/preview', 'get');
|
||||
}
|
||||
|
||||
async function RemoveStorageMigrationFailedRecords(confirmationToken: string) {
|
||||
return http.request<any, Response<any>>('/api/storage/migrations/failed-records/remove', 'post_json', { confirmationToken });
|
||||
}
|
||||
|
||||
async function ArchiveStorageMigrationHistory() {
|
||||
return http.request<any, Response<any>>('/api/storage/migrations/history/archive', 'post_json', {});
|
||||
}
|
||||
|
||||
async function OpenListDirectoryRepairPreflight(logicalPath = '/collect/Kk') {
|
||||
return http.request<any, Response<any>>('/api/storage/openlist/directory-repairs/preflight', 'post_json', { logicalPath });
|
||||
}
|
||||
|
||||
async function CreateOpenListDirectoryRepair(request: object) {
|
||||
return http.request<any, Response<any>>('/api/storage/openlist/directory-repairs', 'post_json', request);
|
||||
}
|
||||
|
||||
async function OpenListDirectoryRepairDetail(id: string) {
|
||||
return http.request<any, Response<any>>(`/api/storage/openlist/directory-repairs/${id}`, 'get');
|
||||
}
|
||||
|
||||
async function ConfirmOpenListDirectoryRepair(id: string, confirmationToken: string) {
|
||||
return http.request<any, Response<any>>(`/api/storage/openlist/directory-repairs/${id}/confirm-cleanup`, 'post_json', { confirmationToken });
|
||||
}
|
||||
|
||||
async function TaskSummary() {
|
||||
return http.request<any, Response<any>>('/api/tasks/summary', 'get');
|
||||
}
|
||||
|
||||
async function TaskList(params: Record<string, any> = {}) {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') query.set(key, String(value));
|
||||
});
|
||||
return http.request<any, Response<any>>(`/api/tasks?${query.toString()}`, 'get');
|
||||
}
|
||||
|
||||
async function TaskDetail(type: number, id: string) {
|
||||
return http.request<any, Response<any>>(`/api/tasks/${type}/${id}`, 'get');
|
||||
}
|
||||
|
||||
async function TaskItems(type: number, id: string, params: Record<string, any> = {}) {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') query.set(key, String(value));
|
||||
});
|
||||
return http.request<any, Response<any>>(`/api/tasks/${type}/${id}/items?${query.toString()}`, 'get');
|
||||
}
|
||||
|
||||
async function RetryTaskFailed(type: number, id: string) {
|
||||
return http.request<any, Response<any>>(`/api/tasks/${type}/${id}/retry-failed`, 'post_json', {});
|
||||
}
|
||||
|
||||
async function RetryTaskItem(type: number, taskId: string, itemId: string) {
|
||||
return http.request<any, Response<any>>(`/api/tasks/${type}/${taskId}/items/${itemId}/retry`, 'post_json', {});
|
||||
}
|
||||
|
||||
async function RetryTaskItemCleanup(type: number, taskId: string, itemId: string) {
|
||||
return http.request<any, Response<any>>(`/api/tasks/${type}/${taskId}/items/${itemId}/retry-cleanup`, 'post_json', {});
|
||||
}
|
||||
|
||||
async function TaskAction(type: number, id: string, action: string) {
|
||||
return http.request<any, Response<any>>(`/api/tasks/${type}/${id}/actions/${action}`, 'post_json', {});
|
||||
}
|
||||
|
||||
async function ProbeTaskStorage() {
|
||||
return http.request<any, Response<any>>('/api/tasks/storage-health/probe', 'post_json', {});
|
||||
}
|
||||
|
||||
async function StartTaskSync(videoType?: string | number) {
|
||||
const query = videoType === undefined || videoType === null || videoType === ''
|
||||
? ''
|
||||
: `?videoType=${encodeURIComponent(String(videoType))}`;
|
||||
return http.request<any, Response<any>>(`/api/tasks/sync${query}`, 'post_json', {});
|
||||
}
|
||||
|
||||
async function VideoExclusions(params: Record<string, any> = {}) {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') query.set(key, String(value));
|
||||
});
|
||||
return http.request<any, Response<any>>(`/api/video/exclusions?${query.toString()}`, 'get');
|
||||
}
|
||||
|
||||
async function UnexcludeVideos(ids: string[], createDownloadTask: boolean) {
|
||||
return http.request<any, Response<any>>('/api/video/exclusions/unexclude', 'post_json', { ids, createDownloadTask });
|
||||
}
|
||||
//后台日志
|
||||
async function apiGetLogs(param: string) {
|
||||
return http.request<any, Response<any>>('/api/logs/GetLog/' + param, 'get').then(r => {
|
||||
@@ -200,6 +348,18 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
|
||||
});
|
||||
}
|
||||
async function UpdateFollowLiveMonitor(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/live-monitor', 'post_json', param).then(r => r);
|
||||
}
|
||||
async function RefreshFollowLiveStatus(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/live-status/refresh', 'post_json', param).then(r => r);
|
||||
}
|
||||
async function QueryFollowLiveStatus(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/live-status/query', 'post_json', param).then(r => r);
|
||||
}
|
||||
async function UpdateFollowLiveEmail(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/live-email', 'post_json', param).then(r => r);
|
||||
}
|
||||
//重新下载
|
||||
async function ReDownViedos(param: object) {
|
||||
return http.request<any, Response<any>>('/api/video/redown', 'post_json', param).then(r => {
|
||||
@@ -284,6 +444,14 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
|
||||
});
|
||||
}
|
||||
// 使用抖音资料页展示的抖音号查找博主,确认结果后再调用 AddFollow
|
||||
async function ResolveFollowByDouyinNo(param: { cookieId: string; douyinNo: string }) {
|
||||
return http.request<any, Response<any>>('/api/follow/resolve-by-douyin-no', 'post_json', param).then(r => {
|
||||
return r;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//删除非关注的博主
|
||||
async function DelFollow(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/delete', 'post_json', param).then(r => {
|
||||
@@ -387,6 +555,39 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
// }
|
||||
|
||||
return {
|
||||
StorageConfig,
|
||||
StorageInventory,
|
||||
TestStorage,
|
||||
OpenListDirectories,
|
||||
UpdateStorage,
|
||||
EmailConfig,
|
||||
UpdateEmailConfig,
|
||||
TestEmailConfig,
|
||||
StorageMigrationPreflight,
|
||||
CreateStorageMigration,
|
||||
LatestStorageMigration,
|
||||
StorageMigrationDetail,
|
||||
StorageMigrationItems,
|
||||
StorageMigrationAction,
|
||||
StorageMigrationFailedRecordsPreview,
|
||||
RemoveStorageMigrationFailedRecords,
|
||||
ArchiveStorageMigrationHistory,
|
||||
OpenListDirectoryRepairPreflight,
|
||||
CreateOpenListDirectoryRepair,
|
||||
OpenListDirectoryRepairDetail,
|
||||
ConfirmOpenListDirectoryRepair,
|
||||
TaskSummary,
|
||||
TaskList,
|
||||
TaskDetail,
|
||||
TaskItems,
|
||||
RetryTaskFailed,
|
||||
RetryTaskItem,
|
||||
RetryTaskItemCleanup,
|
||||
TaskAction,
|
||||
ProbeTaskStorage,
|
||||
StartTaskSync,
|
||||
VideoExclusions,
|
||||
UnexcludeVideos,
|
||||
VideoChart,
|
||||
BatchSaveCate,
|
||||
CatePageList,
|
||||
@@ -407,6 +608,7 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
GetDeleteViedos,
|
||||
DelFollow,
|
||||
AddFollow,
|
||||
ResolveFollowByDouyinNo,
|
||||
CheckTag,
|
||||
deleteCookie,
|
||||
UpdateConfig,
|
||||
@@ -424,6 +626,10 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
SyncFollow,
|
||||
OpenOrCloseSync,
|
||||
OpenOrCloseFullSync,
|
||||
UpdateFollowLiveMonitor,
|
||||
RefreshFollowLiveStatus,
|
||||
QueryFollowLiveStatus,
|
||||
UpdateFollowLiveEmail,
|
||||
ReDownViedos,
|
||||
DeleteVideo
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useMobileViewport() {
|
||||
const isMobileViewport = ref(false);
|
||||
|
||||
const updateViewport = () => {
|
||||
isMobileViewport.value = window.innerWidth <= MOBILE_BREAKPOINT;
|
||||
const height = window.visualViewport?.height || window.innerHeight;
|
||||
document.documentElement.style.setProperty('--app-viewport-height', `${Math.round(height)}px`);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateViewport();
|
||||
window.addEventListener('resize', updateViewport, { passive: true });
|
||||
window.addEventListener('orientationchange', updateViewport, { passive: true });
|
||||
window.visualViewport?.addEventListener('resize', updateViewport, { passive: true });
|
||||
window.visualViewport?.addEventListener('scroll', updateViewport, { passive: true });
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateViewport);
|
||||
window.removeEventListener('orientationchange', updateViewport);
|
||||
window.visualViewport?.removeEventListener('resize', updateViewport);
|
||||
window.visualViewport?.removeEventListener('scroll', updateViewport);
|
||||
document.documentElement.style.removeProperty('--app-viewport-height');
|
||||
});
|
||||
|
||||
return { isMobileViewport, updateViewport };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
|
||||
test('login decorations cannot intercept touch input', async () => {
|
||||
const login = await source('src/pages/login/Login.vue');
|
||||
const loginBox = await source('src/pages/login/LoginBox.vue');
|
||||
assert.match(login, /&::before[\s\S]*?pointer-events:\s*none/);
|
||||
assert.match(login, /&::after[\s\S]*?pointer-events:\s*none/);
|
||||
assert.match(loginBox, /html-type="submit"/);
|
||||
assert.match(loginBox, /class="login-submit/);
|
||||
});
|
||||
|
||||
test('legacy WebViews do not depend on the :has selector to finish mounting', async () => {
|
||||
const html = await source('index.html');
|
||||
const main = await source('src/main.ts');
|
||||
assert.doesNotMatch(html, /:has\(/);
|
||||
assert.match(html, /#stepin-app\.app-mounted/);
|
||||
assert.match(main, /classList\.add\('app-mounted'\)/);
|
||||
});
|
||||
|
||||
test('mobile overlays stay above fixed navigation', async () => {
|
||||
const nav = await source('src/components/layout/MobileBottomNav.vue');
|
||||
assert.match(nav, /z-index:\s*900/);
|
||||
assert.match(nav, /:z-index="1300"/);
|
||||
assert.match(nav, /destroy-on-close/);
|
||||
});
|
||||
|
||||
test('every primary mobile page provides an explicit title', async () => {
|
||||
const routes = await source('src/router/routes.ts');
|
||||
for (const title of ['数据看板', '同步记录', '任务中心', '关注博主', '抖音授权', '系统设置', '系统日志']) {
|
||||
assert.match(routes, new RegExp(`mobileTitle: '${title}'`));
|
||||
}
|
||||
});
|
||||
|
||||
test('failed migration record removal is explicit, responsive, and never starts sync automatically', async () => {
|
||||
const modal = await source('src/components/FailedMigrationRecordRemovalModal.vue');
|
||||
assert.match(modal, /影响全部历史迁移任务/);
|
||||
assert.match(modal, /旧本地文件、已上传的 WebDAV 文件/);
|
||||
assert.match(modal, /下一次正常同步/);
|
||||
assert.match(modal, /RemoveStorageMigrationFailedRecords/);
|
||||
assert.doesNotMatch(modal, /StartTaskSync/);
|
||||
assert.match(modal, /max-height:\s*calc\(100dvh - 190px\)/);
|
||||
assert.match(modal, /grid-template-columns:\s*1fr 1fr/);
|
||||
});
|
||||
|
||||
test('live monitoring and email notification stay per-blogger and touch friendly', async () => {
|
||||
const follow = await source('src/pages/followd/index.vue');
|
||||
const settings = await source('src/pages/set/AppSet.vue');
|
||||
assert.match(follow, /直播监测/);
|
||||
assert.match(follow, /开播邮件/);
|
||||
assert.match(follow, /UpdateFollowLiveMonitor/);
|
||||
assert.match(follow, /UpdateFollowLiveEmail/);
|
||||
assert.match(follow, /每 5 分钟自动检查/);
|
||||
assert.match(follow, /\.live-monitor-row[\s\S]*?min-height:\s*44px/);
|
||||
assert.match(follow, /\.live-refresh-btn[\s\S]*?width:\s*44px/);
|
||||
assert.match(follow, /@media \(max-width:\s*768px\)[\s\S]*?\.card-main-content\s*\{[\s\S]*?display:\s*grid/);
|
||||
assert.match(follow, /\.card-content\s*\{[\s\S]*?display:\s*contents/);
|
||||
assert.match(follow, /\.live-monitor-row\s*\{[\s\S]*?grid-column:\s*1 \/ -1/);
|
||||
assert.match(follow, /\.card-path-sync-container\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) max-content/);
|
||||
assert.match(follow, /\.sync-switch-wrapper\s*\{[\s\S]*?flex-shrink:\s*0/);
|
||||
assert.match(settings, /邮箱通知/);
|
||||
assert.match(settings, /发送测试邮件/);
|
||||
assert.match(settings, /SSL\/TLS(常用 465)/);
|
||||
assert.match(settings, /STARTTLS(常用 587)/);
|
||||
});
|
||||
|
||||
test('storage capability detection is always visible and touch friendly in task center', async () => {
|
||||
const tasks = await source('src/pages/tasks/index.vue');
|
||||
assert.match(tasks, /class="storage-probe-action"/);
|
||||
assert.match(tasks, /summary\.storageHealth\?\.status === 1 \? '重新检测存储' : '检测存储'/);
|
||||
assert.match(tasks, /CloudSyncOutlined/);
|
||||
assert.doesNotMatch(tasks, /v-if="summary\.storageHealth\?\.status === 1"[\s\S]{0,500}#action/);
|
||||
assert.match(tasks, /\.storage-probe-action\s*\{[\s\S]*?grid-column:\s*1 \/ -1/);
|
||||
assert.match(tasks, /\.task-actions :deep\(\.ant-btn\)\s*\{[\s\S]*?min-height:\s*44px/);
|
||||
});
|
||||
+1
-1
@@ -5,7 +5,7 @@ import Components from 'unplugin-vue-components/vite';
|
||||
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
|
||||
import { AntdvLessPlugin, AntdvModifyVars } from 'stepin/lib/style/plugins';
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
const timestamp = new Date().getTime();
|
||||
const timestamp = Number(process.env.VITE_BUILD_TIMESTAMP || Date.now());
|
||||
const prodRollupOptions = {
|
||||
output: {
|
||||
chunkFileNames: (chunk) => {
|
||||
|
||||
Reference in New Issue
Block a user