feat: add fnOS packaging, storage workflows and release pipeline

This commit is contained in:
2026-08-11 18:05:49 +08:00
parent c5922f9b08
commit 95932f0199
181 changed files with 24024 additions and 1164 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+1
View File
@@ -24,3 +24,4 @@ app.config.errorHandler = function (err) {
console.error('未捕获的异常,', err);
};
app.mount('#stepin-app');
document.getElementById('stepin-app')?.classList.add('app-mounted');
+322 -64
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+17 -21
View File
@@ -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>
+31 -3
View File
@@ -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>
+45 -13
View File
@@ -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>
+67 -2
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+689
View File
@@ -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>
+615 -37
View File
@@ -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>
+12 -1
View File
@@ -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>
+11 -1
View File
@@ -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>
+103 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+206
View File
@@ -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
};
+31
View File
@@ -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 };
}
+78
View File
@@ -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
View File
@@ -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) => {