Merge feature/merge-upstream: Modal 小屏 + 视频播放 + Docker Alpine(冲突以 feature 为准)
部署到群晖 / deploy (push) Failing after 3h9m39s

# Conflicts:
#	Dockerfile
#	Dockerfile-arm
#	dto/VideoPageRequestDTO.cs
#	model/AppConfig.cs
#	repository/DyCollectVideoRepository.cs
#	service/CommonService.cs
#	service/DyCollectVideoService.cs
#	service/DyHttpClientService.cs
This commit is contained in:
yuming
2026-07-03 20:03:46 +08:00
203 changed files with 28876 additions and 10250 deletions
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
<script lang="ts" setup>
import { reactive, ref, watch, UnwrapRef, onMounted } from 'vue';
import { FormInstance } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { useApiStore, useAccountStore } from '@/store';
import { useRouter } from 'vue-router';
import { ExclamationCircleOutlined, SyncOutlined, SaveOutlined } from '@ant-design/icons-vue';
interface UpSecUserIdItem {
uper?: string;
uid?: string;
syncAll: boolean;
}
type ConfigItem = {
id?: string;
userName: string; // 改为非可选,强制初始化
cookies: string;
savePath: string;
favSavePath: string;
secUserId: string;
status: number;
upSavePath: string;
// imgSavePath: string;
useSinglePath: boolean; // 非可选
};
// 强制初始化所有字段为非undefined值
const newConfig = (config?: ConfigItem): ConfigItem => {
return {
id: '0',
userName: '', // 强制空字符串
cookies: '',
savePath: '',
favSavePath: '',
secUserId: '',
status: 0,
upSavePath: '',
// imgSavePath: '',
useSinglePath: false,
};
};
// 改用 ref 而非 reactive(解决深层响应式问题)
const form = ref<ConfigItem>(newConfig());
const formModel = ref<FormInstance>();
const formLoading = ref(false);
// 重置表单:强制重置 + 清空校验状态
const resetConfig = () => {
form.value = newConfig();
formModel.value?.resetFields(); // 必须调用,清空校验提示
message.info('表单已重置为默认值');
};
// 监听统一路径(改用 ref 的取值方式)
watch(
[() => form.value.savePath, () => form.value.useSinglePath],
([newSavePath, useSinglePath]) => {
if (useSinglePath && newSavePath) {
form.value.favSavePath = newSavePath;
form.value.upSavePath = newSavePath;
// form.value.imgSavePath = newSavePath;
}
},
{ immediate: true }
);
// 校验规则:明确trigger + 不依赖组件自动触发
const formRules = reactive({
userName: [{ required: true, message: '请输入Cookie名称', trigger: 'change' }],
savePath: [{ required: true, message: '请输入收藏存储路径', trigger: 'change' }],
secUserId: [{ required: false, message: '请输入我的secUserId', trigger: 'change' }],
});
const router = useRouter();
// 定义rowCount并暴露到模板(之前定义在onMounted里,模板访问不到)
const rowCount = ref(6);
onMounted(() => {
const accountStore = useAccountStore(); // 移到内部避免提前引用
useApiStore()
.AppisInit()
.then((res) => {
if (res.code == 0 && res.data) {
if (accountStore.logged) {
router.push('/dashboard');
} else {
router.push('/login');
}
}
});
});
const accountStore = useAccountStore();
// 提交:手动指定校验字段 + 强制取值
const submitConfig = async () => {
formLoading.value = true;
try {
// 手动获取表单实例,防止null
const formInstance = formModel.value;
if (!formInstance) {
message.error('表单实例未初始化');
formLoading.value = false;
return;
}
// 手动校验(兜底方案)
const checkResult = manualCheckForm();
if (!checkResult.pass) {
message.error(checkResult.msg);
formLoading.value = false;
return;
}
// 提交时强制解构,确保传递最新值
const res = await useApiStore().DeskInitAsync({ ...form.value });
if (res.code === 0) {
message.success('配置保存成功,即将跳转登录页...');
setTimeout(() => {
router.push('/login');
}, 500);
} else {
message.error(`保存失败:${res.message || '未知错误'}`);
}
} catch (err: any) {
console.error('校验失败:', err);
const firstErr = err.errorFields?.[0]?.errors?.[0] || '请检查所有必填项';
message.error(firstErr);
} finally {
formLoading.value = false;
}
};
// 手动校验函数(兜底方案)
const manualCheckForm = (): { pass: boolean; msg: string } => {
if (!form.value.userName.trim()) {
return { pass: false, msg: '请输入Cookie名称' };
}
if (!form.value.savePath.trim()) {
return { pass: false, msg: '请输入收藏存储路径' };
}
return { pass: true, msg: '' };
};
</script>
<template>
<div class="init-config-page">
<div class="page-header">
<div class="header-content">
<div class="logo-container">
<img src="/logo.png" alt="抖小云Logo" class="logo-img" />
</div>
<h1 class="page-title">抖小云初始化配置信息</h1>
<!-- <p class="page-desc">第一次运行,请花两分钟完成初始配置</p> -->
</div>
</div>
<div class="config-card">
<a-form ref="formModel" :model="form" :rules="formRules" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }" layout="horizontal" class="config-form" @submit.prevent="submitConfig">
<!-- Cookie名称 -->
<a-form-item label="Cookie名" name="userName">
<a-input v-model:value="form.userName" placeholder="随便填个名称,作为唯一标识" @input="() => {}" />
</a-form-item>
<!-- Cookie值:现在rows能正常生效了 -->
<a-form-item label="Cookie值" name="cookies">
<div class="cookie-wrapper">
<a-textarea v-model:value="form.cookies" :rows="rowCount" placeholder="粘贴完整Cookie值,也可以先不填,保存配置后,后续可以在“抖音授权”修改" />
<div class="cookie-tip">
<a href="https://gitee.com/deathvicky/dysync.net" target="_blank" class="cookie-link">
查看Cookie以及secUserId获取方式
</a>
</div>
</div>
</a-form-item>
<!-- secUserId -->
<a-form-item label="我的secUserId" name="secUserId">
<div class="input-with-tip">
<a-input v-model:value="form.secUserId" placeholder="同步喜欢视频和关注博主视频必填" @input="() => {}" />
<a-tooltip title="同步喜欢视频和关注博主视频必填">
<ExclamationCircleOutlined style="color: #faad14; margin-left: 8px; font-size: 16px;" />
</a-tooltip>
</div>
</a-form-item>
<!-- 收藏路径 -->
<a-form-item label="收藏存储路径" name="savePath">
<div class="input-with-tip">
<a-input v-model:value="form.savePath" placeholder="/app/collect" @input="() => {}" />
<a-tooltip title="必填">
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px; margin-left: 8px; " />
</a-tooltip>
</div>
</a-form-item>
<!-- 统一路径开关 -->
<a-form-item label="统一存储路径" name="useSinglePath">
<div class="switch-with-desc">
<a-switch v-model:checked="form.useSinglePath" class="custom-large-switch" @change="() => {}" />
<div class="switch-desc">
<span class="main-desc">{{ form.useSinglePath ? '开启' : '关闭' }}</span>
<span class="sub-desc">{{ form.useSinglePath ? '所有类型视频(收藏的、喜欢的、关注博主的)共用一个路径' : '各类型视频(收藏的、喜欢的、关注博主的)文件路径独立配置' }}</span>
</div>
</div>
</a-form-item>
<!-- 其他路径 -->
<a-form-item v-if="!form.useSinglePath" label="喜欢存储路径" name="favSavePath">
<a-input v-model:value="form.favSavePath" placeholder="喜欢视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
</a-form-item>
<a-form-item v-if="!form.useSinglePath" label="关注存储路径" name="upSavePath">
<a-input v-model:value="form.upSavePath" placeholder="关注视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
</a-form-item>
<!-- <a-form-item v-if="!form.useSinglePath" label="图文存储路径" name="imgSavePath">
<a-input v-model:value="form.imgSavePath" placeholder="图文视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
</a-form-item> -->
<!-- 同步状态开关 -->
<a-form-item label="同步状态" name="status">
<div class="switch-with-desc">
<a-switch v-model:checked="form.status" :checked-value="1" :un-checked-value="0" class="custom-large-switch" />
<div class="switch-desc">
<span class="main-desc">{{ form.status === 1 ? '开启' : '关闭' }}</span>
<span class="sub-desc">建议初始不开启,保存设置后,进入系统配置完成其他设置后再手动开启!</span>
</div>
</div>
</a-form-item>
<!-- 操作按钮 -->
<a-form-item :wrapper-col="{ span: 24 }" class="form-actions">
<div class="btn-center-wrapper">
<a-button type="primary" html-type="submit" :loading="formLoading" class="save-btn">
<template #icon>
<SaveOutlined />
</template>保存配置
</a-button>
</div>
</a-form-item>
</a-form>
</div>
</div>
</template>
<style scoped>
.init-config-page {
min-height: 100vh;
background-color: #f5f7fa;
padding: 10px 50px;
box-sizing: border-box;
}
.page-header {
margin-bottom: 10px;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.header-content {
text-align: center;
max-width: 800px;
width: 100%;
}
.logo-container {
margin-bottom: 16px;
display: flex;
justify-content: center;
align-items: center;
}
.logo-img {
width: 60px;
height: 60px;
object-fit: contain;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.page-title {
font-size: 28px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
}
.page-desc {
font-size: 16px;
color: #6b7280;
margin: 0;
}
.config-card {
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
padding: 20px;
max-width: 1000px;
margin: 0 auto;
}
.config-form {
--ant-form-item-margin-bottom: 24px;
}
.input-with-tip {
display: flex;
align-items: center;
width: 100%;
}
.switch-with-desc {
display: flex;
align-items: center;
gap: 12px;
}
.switch-desc {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.switch-desc .main-desc {
font-size: 15px;
color: #374151;
font-weight: 500;
}
.switch-desc .sub-desc {
font-size: 13px;
color: #6b7280;
}
:deep(.custom-large-switch) {
transform: scale(1.2);
}
.form-actions {
margin-top: 30px !important;
margin-bottom: 0 !important;
}
.btn-center-wrapper {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
gap: 16px;
}
.save-btn {
padding: 0 40px;
height: 44px;
border-radius: 8px;
font-size: 16px;
background-color: #1677ff;
border-color: #1677ff;
}
.save-btn:hover {
background-color: #4096ff;
border-color: #4096ff;
}
/* 文本域恢复默认,让rows生效 */
:deep(.ant-input-textarea) {
font-size: 15px;
height: auto !important; /* 取消固定高度 */
min-height: auto !important;
}
/* Cookie容器和链接样式 */
.cookie-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.cookie-tip {
text-align: left;
}
.cookie-link {
color: #1677ff;
font-size: 13px;
text-decoration: none;
}
.cookie-link:hover {
color: #4096ff;
text-decoration: underline;
}
@media (max-width: 768px) {
.init-config-page {
padding: 10px 16px;
}
.config-card {
padding: 20px;
}
.logo-img {
width: 60px;
height: 60px;
}
.page-title {
font-size: 24px;
}
.switch-with-desc {
flex-direction: row;
align-items: center;
gap: 8px;
}
.switch-desc {
gap: 4px;
}
:deep(.custom-large-switch) {
transform: scale(1.1);
}
}
/* 滚动条样式 */
:deep(.ant-input-textarea-input) {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(150, 150, 150, 0.2) transparent;
}
:deep(.ant-input-textarea-input)::-webkit-scrollbar {
width: 6px;
height: 6px;
}
:deep(.ant-input-textarea-input)::-webkit-scrollbar-track {
background: transparent;
}
:deep(.ant-input-textarea-input)::-webkit-scrollbar-thumb {
background: rgba(150, 150, 150, 0.2);
border-radius: 3px;
}
:deep(.ant-input-disabled) {
background-color: #f9fafb !important;
color: #6b7280 !important;
cursor: not-allowed;
}
</style>
File diff suppressed because it is too large Load Diff
-129
View File
@@ -1,129 +0,0 @@
<template>
<a-card :bordered="false" :bodyStyle='{"padding-top":"0px","padding-bottom":"100px"}'>
<a-form :model="formState" :label-col="labelCol" :rules="rules" :wrapper-col="wrapperCol" ref="formRef">
<a-divider orientation="left"></a-divider>
<a-form-item has-feedback label="同步周期(分钟)" ref="Cron" name="Cron">
<a-input v-model:value="formState.Cron" placeholder="1:数字-例如20-表示20分钟执行一次;2:cron表达式,根据表达式周期执行" />
</a-form-item>
<a-form-item label="在线Cron表达式">
<a target="_blank" href="https://www.bejson.com/othertools/cron/">查看示例</a>
</a-form-item>
<a-form-item :wrapper-col="{ span: 10, offset: 3 }">
<a-button type="primary" danger @click="onSubmit">进入系统</a-button>
</a-form-item>
</a-form>
</a-card>
</template>
<script lang="ts" setup>
import { reactive, toRaw, ref, watch, createVNode, h } from 'vue';
import type { UnwrapRef } from 'vue';
import { Form } from 'ant-design-vue';
import type { Rule } from 'ant-design-vue/es/form';
import type { FormInstance } from 'ant-design-vue';
import { useApiStore } from '@/store';
import { Modal } from 'ant-design-vue';
import { checkDomain, checksubDomainPrefix, checkPass, checkUserName } from '@/utils/regexHelper';
const formRef = ref<FormInstance>();
const SK = ref(null);
interface FormState {
cloudName: string;
domainName: string;
recordType: string;
ipv6Prefix: string;
domainRecord: string;
AK: string;
SK: string;
DbType: number;
ConnString: string;
Cron: string;
UserName: string;
UswePwd: string;
}
interface CloudInfo {
key: string;
value: string;
doc: string;
}
interface DbTypeInfo {
key: number;
value: string;
conn: string;
}
interface showDBconn {
value: boolean;
}
const showDBconn: UnwrapRef<showDBconn> = reactive({
value: false,
});
const formState: UnwrapRef<FormState> = reactive({
cloudName: 'aliyun',
domainName: '',
recordType: '',
ipv6Prefix: '',
domainRecord: '',
AK: '',
SK: '',
DbType: 2,
ConnString: '',
Cron: '30',
UserName: '',
UswePwd: '',
});
const rules: Record<string, Rule[]> = {
Cron: [{ required: true, message: '请输入任务调度周期', trigger: 'change' }],
};
watch(
() => formState.DbType,
() => {
formRef.value.validateFields(['ConnString']);
},
{ flush: 'post' }
);
//检查数据库连接
import { message } from 'ant-design-vue';
import router from '@/router';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
const isFirst = ref(false);
//提交初始化
const onSubmit = () => {
// console.log('submit!', toRaw(formState));
formRef.value
.validate()
.then(() => {
console.log('values', formState, toRaw(formState));
useApiStore()
.apiInit(toRaw(formState))
.then((res) => {
if (res.code === 0) {
message.success('初始化成功');
setTimeout(() => {
router.push('/login');
}, 1000);
} else {
message.error(res.erro, 8);
}
});
})
.catch((error) => {
console.log('error', error);
});
};
const labelCol = { style: { width: '150px' } };
const wrapperCol = { span: 4 };
</script>
<style scoped>
.ant-card-body {
padding: 5px !important;
}
</style>
-3
View File
@@ -1,3 +0,0 @@
import Init from './Init.vue';
export default Init;
+289 -22
View File
@@ -3,54 +3,321 @@
<login-box class="shadow-lg" @success="onLoginSuccess" @failure="onLoginFail" />
</div>
</template>
<script lang="ts" setup>
import LoginBox from './LoginBox.vue';
import { useRouter } from 'vue-router';
// import http from '@/store/http';
import { message, notification, Modal } from 'ant-design-vue'; // 引入 Modal 用于确认提示
import { onMounted, ref, h } from 'vue';
import { useApiStore } from '@/store';
import { CopyOutlined } from '@ant-design/icons-vue';
const router = useRouter();
function onLoginSuccess() {
router.push('/dashboard');
}
import { message } from 'ant-design-vue';
// 版本数据存储
const currentTag = ref<string>('');
const latestTag = ref<string>('未知版本');
function onLoginFail(status, res) {
console.log(res);
if (res.code === 0) {
} else {
message.error(res.erro, 5);
// 复制版本号到剪贴板核心逻辑
const copyToClipboard = async (content: string, type: string) => {
if (!content || content === '未知版本') {
message.warning(`${type}无有效内容可复制`);
return;
}
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(content);
message.success(`${type}已复制到剪贴板 ✅`);
} else {
const textarea = document.createElement('textarea');
textarea.value = content;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
message.success(`${type}已复制到剪贴板 ✅`);
}
} catch (err) {
console.error('复制失败:', err);
message.error('复制失败,请手动选中复制');
}
};
// 统一的关闭处理:标记缓存 + 关闭通知
const handleNoticeClose = (noticeKey: string) => {
console.log('版本提醒通知已关闭,后续不再提醒');
// 标记为已提醒,存入缓存(确保版本通知和确认弹窗都不再出现)
localStorage.setItem('maintain_notice_shown', 'true');
// 关闭版本通知
notification.close(noticeKey);
};
// 关闭前的确认提示弹窗
const showCloseConfirm = (noticeKey: string) => {
Modal.confirm({
title: '确认关闭',
content: '关闭后该提醒将不再弹出,确定要关闭吗?',
okText: '确定',
cancelText: '取消',
onOk: () => {
// 确认关闭:执行统一处理
handleNoticeClose(noticeKey);
message.success('提醒已关闭,后续不再弹出');
},
onCancel: () => {
// 取消关闭:不执行任何操作,保留版本通知
message.info('已取消关闭');
},
});
};
// 打开右上角 notification 通知
const openVersionNotice = () => {
const noticeKey = `version_notice_${Date.now()}`;
notification.open({
message: '温馨提示一下',
key: noticeKey,
duration: 0,
placement: 'topRight',
// 通知描述内容
description: h('div', { class: 'notice-content' }, [
h('p', { class: 'notice-desc' }, '当前您使用的docker镜像为阿里云镜像,已停止维护。'),
h('p', { class: 'notice-version-item' }, [
h('strong', { class: 'notice-version-label' }, '当前版本:'),
h('span', { style: { color: '#ff4d4f', fontWeight: '500' } }, currentTag.value.replace('[不再维护]', '')),
h(CopyOutlined, {
class: 'notice-copy-icon',
title: '复制当前版本',
onClick: () => copyToClipboard(currentTag.value.replace('[不再维护]', ''), '当前版本'),
}),
]),
h('p', { class: 'notice-version-item' }, [
h('strong', { class: 'notice-version-label' }, '最新版本:'),
h('span', { style: { color: '#52c41a', fontWeight: '500' } }, latestTag.value),
h(CopyOutlined, {
class: 'notice-copy-icon',
title: '复制最新版本',
onClick: () => copyToClipboard(latestTag.value, '最新版本'),
}),
]),
h('p', { class: 'notice-tip' }, '建议升级到最新版本!'),
]),
// 「我已知晓」按钮:点击触发确认弹窗
btn: () =>
h(
'button',
{
class: 'notice-confirm-btn',
onClick: () => {
// 不直接关闭,先弹出确认提示
showCloseConfirm(noticeKey);
},
},
'我已知晓'
),
});
};
onMounted(() => {
useApiStore()
.AppisInit()
.then((res) => {
if (res.code == 0 && res.data) {
} else {
router.push('/init');
}
});
useApiStore()
.CheckTag()
.then((res) => {
if (res.code === 0) {
if (res.data.length > 0) {
const tag = res.data[0];
if (tag.indexOf('不再维护') !== -1) {
const hasShown = localStorage.getItem('maintain_notice_shown');
if (!hasShown) {
currentTag.value = tag;
latestTag.value = res.data.length >= 2 ? res.data[1] : '未知版本';
openVersionNotice();
}
}
}
} else {
message.error(res.message);
}
})
.catch((err) => {
console.error(err);
});
});
function onLoginSuccess() {
if (isMobileBrowser()) router.push('/mobile');
else 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);
}
</script>
<style scoped lang="less">
.login {
height: 100vh;
// 与深色登录框搭配的渐变背景
min-height: -webkit-fill-available;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
position: relative;
overflow: hidden;
padding: 20px 0;
// 可以添加一些装饰性背景元素
&::before {
content: '';
position: absolute;
width: 400px;
height: 400px;
width: 280px;
height: 280px;
border-radius: 50%;
background: rgba(54, 191, 250, 0.1);
top: 20%;
left: 15%;
filter: blur(80px);
top: 15%;
left: 5%;
filter: blur(60px);
@media (min-width: 768px) {
width: 400px;
height: 400px;
top: 20%;
left: 15%;
filter: blur(80px);
}
}
&::after {
content: '';
position: absolute;
width: 300px;
height: 300px;
width: 220px;
height: 220px;
border-radius: 50%;
background: rgba(54, 191, 250, 0.08);
bottom: 10%;
right: 10%;
filter: blur(60px);
bottom: 5%;
right: 5%;
filter: blur(40px);
@media (min-width: 768px) {
width: 300px;
height: 300px;
bottom: 10%;
right: 10%;
filter: blur(60px);
}
}
}
@supports (bottom: env(safe-area-inset-bottom)) {
.login {
padding-bottom: env(safe-area-inset-bottom);
padding-top: env(safe-area-inset-top);
}
}
</style>
<style lang="less">
.ant-notification {
.ant-notification-notice {
width: 500px !important;
padding: 16px !important;
}
.notice-content {
font-size: 14px !important;
line-height: 1.8 !important;
color: #334155 !important;
width: 480px !important;
.notice-desc {
margin-bottom: 12px !important;
padding-left: 2px !important;
margin: 0 !important;
}
.notice-version-item {
display: flex !important;
align-items: center !important;
margin: 8px 0 !important;
padding: 6px 10px !important;
background-color: #f8fafc !important;
border-radius: 6px !important;
transition: background-color 0.2s ease !important;
&:hover {
background-color: #f1f5f9 !important;
}
}
.notice-version-label {
color: #1e293b !important;
width: 70px !important;
flex-shrink: 0 !important;
font-size: 13px !important;
}
.notice-copy-icon {
flex-shrink: 0 !important;
color: #165dff !important;
font-size: 16px !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
margin-left: 8px !important;
&:hover {
color: #0d47a1 !important;
transform: scale(1.1) !important;
}
&:active {
transform: scale(0.95) !important;
}
}
.notice-tip {
margin-top: 12px !important;
color: #64748b !important;
padding-left: 2px !important;
font-style: italic !important;
font-size: 13px !important;
margin: 0 !important;
}
}
.notice-confirm-btn {
background-color: #165dff !important;
color: #ffffff !important;
border: none !important;
border-radius: 4px !important;
padding: 4px 12px !important;
font-size: 13px !important;
cursor: pointer !important;
transition: background-color 0.2s ease !important;
margin-top: 12px !important;
&:hover {
background-color: #0d47a1 !important;
}
}
}
</style>
+206 -101
View File
@@ -1,78 +1,103 @@
<template>
<div class="login-page min-h-screen flex items-center justify-center p-4 md:p-8">
<ThemeProvider :color="{
middle: { 'bg-base': '#f8fafc' },
primary: { DEFAULT: '#4f46e5', hover: '#4338ca' }
}">
<!-- 登录卡片 -->
<div class="login-box rounded-2xl shadow-lg p-8 md:p-10 max-w-md w-full border border-gray-200 transition-all duration-500 hover:shadow-xl transform hover:-translate-y-1">
<!-- 顶部Logo区域 -->
<div class="flex flex-col items-center mb-8">
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<img src="/public/logo.png">
<ThemeProvider :color="{
middle: { 'bg-base': '#fff' },
primary: { DEFAULT: '#4f46e5', hover: '#4338ca' }
}">
<!-- 登录卡片 - 移动端全屏 + PC端保留原宽度 -->
<div class="login-box rounded-2xl shadow-lg p-6 md:p-8 lg:p-10 max-w-md w-full border border-gray-200 transition-all duration-500 hover:shadow-xl transform hover:-translate-y-1 mx-auto" style="box-sizing: border-box; max-width: 90vw;">
<!-- 顶部Logo区域 - 边框圈精准贴合Logo内容 -->
<div class="flex flex-col items-center mb-6 md:mb-8">
<div class="w-30 h-30 md:w-36 md:h-36 bg-primary/10 rounded-full flex items-center justify-center mb-4 md:mb-5">
<div class="w-24 h-24 md:w-28 h-28 rounded-full flex items-center justify-center">
<img src="/logo.png" alt="抖小云logo" class="w-18 h-18 md:w-20 h-20 object-contain" />
</div>
<h2 class="third-title text-2xl font-bold text-gray-800 tracking-tight">抖音同步小帮手</h2>
</div>
<h2 class="third-title text-xl md:text-2xl font-bold text-gray-800 tracking-tight">抖小云</h2>
</div>
<a-form :model="form" :wrapperCol="{ span: 24 }" @finish="login" class="login-form w-full text-gray-700">
<!-- 用户名输入框 -->
<a-form-item :required="true" name="username" class="mb-4 md:mb-5" :validate-status="usernameStatus">
<a-input v-model:value="form.username" autocomplete="new-username" placeholder="请输入用户名" class="login-input h-[46px] md:h-[50px] rounded-lg bg-gray-50 border-gray-200 text-gray-800 placeholder:text-gray-400 focus:border-primary focus:bg-white text-base transition-all duration-300 focus:ring-2 focus:ring-primary/20" style="padding: 0 12px;">
<template #prefix>
<svg class="w-4 h-4 md:w-5 md:h-5 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</template>
</a-input>
</a-form-item>
<!-- 密码输入框 -->
<a-form-item :required="true" name="password" class="mb-2" :validate-status="passwordStatus">
<a-input v-model:value="form.password" autocomplete="new-password" placeholder="请输入密码" class="login-input h-[46px] md:h-[50px] rounded-lg bg-gray-50 border-gray-200 text-gray-800 placeholder:text-gray-400 focus:border-primary focus:bg-white text-base transition-all duration-300 focus:ring-2 focus:ring-primary/20" :type="showPassword ? 'text' : 'password'" style="padding: 0 12px;">
<template #prefix>
<svg class="w-4 h-4 md:w-5 md:h-5 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</template>
<!-- 密码显示/隐藏图标 -->
<template #suffix>
<div class="w-8 h-8 flex items-center justify-center" @click="showPassword = !showPassword">
<EyeOutlined v-if="showPassword" class="text-gray-400 hover:text-primary cursor-pointer transition-colors duration-300 text-lg" aria-label="隐藏密码" />
<EyeInvisibleOutlined v-else class="text-gray-400 hover:text-primary cursor-pointer transition-colors duration-300 text-lg" aria-label="显示密码" />
</div>
</template>
</a-input>
</a-form-item>
<!-- 记住密码 -->
<div class="flex items-center justify-between mb-5 md:mb-6 px-1">
<a-form-item class="mb-0">
<a-checkbox v-model:checked="rememberPassword" class="text-gray-600 hover:text-primary transition-colors duration-300 cursor-pointer">
<span class="text-xs md:text-sm">记住密码</span>
</a-checkbox>
</a-form-item>
<a-form-item class="mb-0">
<div class="text-gray-600 hover:text-primary transition-colors duration-300 cursor-pointer" @click="handleForgotPassword">
<span class="text-xs md:text-sm">忘记密码</span>
</div>
</a-form-item>
</div>
<a-form :model="form" :wrapperCol="{ span: 24 }" @finish="login" class="login-form w-full text-gray-700">
<!-- 用户名输入框 -->
<a-form-item :required="true" name="username" class="mb-5" :validate-status="usernameStatus">
<a-input v-model:value="form.username" autocomplete="new-username" placeholder="请输入用户名" class="login-input h-[50px] rounded-lg bg-gray-50 border-gray-200 text-gray-800 placeholder:text-gray-400 focus:border-primary focus:bg-white text-base transition-all duration-300 focus:ring-2 focus:ring-primary/20">
<template #prefix>
<svg class="w-5 h-5 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</template>
</a-input>
</a-form-item>
<!-- 登录按钮 -->
<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;">
<span v-if="!loading">登录</span>
<span v-else>登录中...</span>
</a-button>
</a-form>
</div>
</ThemeProvider>
<!-- 密码输入框 -->
<a-form-item :required="true" name="password" class="mb-2" :validate-status="passwordStatus">
<a-input v-model:value="form.password" autocomplete="new-password" placeholder="请输入密码" class="login-input h-[50px] rounded-lg bg-gray-50 border-gray-200 text-gray-800 placeholder:text-gray-400 focus:border-primary focus:bg-white text-base transition-all duration-300 focus:ring-2 focus:ring-primary/20" :type="showPassword ? 'text' : 'password'">
<template #prefix>
<svg class="w-5 h-5 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</template>
<!-- 密码显示/隐藏图标 -->
<template #suffix>
<EyeOutlined v-if="showPassword" @click="showPassword = !showPassword" class="text-gray-400 hover:text-primary cursor-pointer transition-colors duration-300 text-lg" aria-label="隐藏密码" />
<EyeInvisibleOutlined v-else @click="showPassword = !showPassword" class="text-gray-400 hover:text-primary cursor-pointer transition-colors duration-300 text-lg" aria-label="显示密码" />
</template>
</a-input>
</a-form-item>
<!-- 记住密码与忘记密码 -->
<div class="flex items-center justify-between mb-6 px-1">
<a-form-item class="mb-0">
<a-checkbox v-model:checked="rememberPassword" class="text-gray-600 hover:text-primary transition-colors duration-300 cursor-pointer">
<span class="text-sm">记住密码</span>
</a-checkbox>
</a-form-item>
<!-- <a href="#" class="text-primary hover:text-primary/80 text-sm font-medium transition-colors duration-300" @click.prevent="handleForgotPassword">
忘记密码?
</a> -->
</div>
<!-- 登录按钮 -->
<a-button htmlType="submit" class="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">
<span v-if="!loading">登录</span>
<span v-else>登录中...</span>
</a-button>
<!-- 注册入口 -->
<!-- <div class="text-center mt-6 text-gray-500 text-sm">
还没有账号?
<a href="#" class="text-primary hover:text-primary/80 font-medium transition-colors duration-300 ml-1" @click.prevent="handleRegister">
立即注册
</a>
</div> -->
</a-form>
<!-- 忘记密码弹窗 -->
<a-modal v-model:visible="forgotModalVisible" title="密码重置教程" width="50%" :max-width="500" centered :mask-closable="false" :footer="null" class="forgot-modal">
<div class="p-2">
<!-- 步骤说明 -->
<div class="space-y-4 mb-6">
<div class="flex items-start">
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center text-sm mr-3 mt-0.5">1</div>
<div class="flex-1 text-gray-700 text-sm leading-relaxed">在 db 目录下新建一个文本文件,命名为 <span class="px-2 py-0.5 bg-gray-100 text-primary font-medium rounded text-sm">pwd.txt</span></div>
</div>
<div class="flex items-start">
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center text-sm mr-3 mt-0.5">2</div>
<div class="flex-1 text-gray-700 text-sm leading-relaxed">打开文件写入你要设置的新密码,留空则自动重置为默认密码:<span class="px-2 py-0.5 bg-gray-100 text-primary font-medium rounded text-sm">douyin2026</span></div>
</div>
<div class="flex items-start">
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center text-sm mr-3 mt-0.5">3</div>
<div class="flex-1 text-gray-700 text-sm leading-relaxed">重启 docker服务或者飞牛安装的抖小云应用 ,使用新密码登录即可</div>
</div>
</div>
</ThemeProvider>
</div>
<!-- 知道了按钮 -->
<div class="flex justify-center">
<a-button type="primary" class="h-10 px-8 rounded-lg" @click="forgotModalVisible = false">
我知道了
</a-button>
</div>
</div>
</a-modal>
</template>
<script lang="ts" setup>
@@ -88,8 +113,14 @@ const showPassword = ref(false);
const rememberPassword = ref(false);
// 加载状态
const loading = ref(false);
// 忘记密码弹窗显示状态
const forgotModalVisible = ref(false);
// 表单状态(用于输入框验证反馈)
const form = reactive({
username: '',
password: '',
});
const usernameStatus = computed(() => (form.username ? 'success' : ''));
const passwordStatus = computed(() => (form.password ? 'success' : ''));
@@ -98,11 +129,6 @@ export interface LoginFormProps {
password: string;
}
const form = reactive<LoginFormProps>({
username: '',
password: '',
});
const emit = defineEmits<{
(e: 'success', fields: LoginFormProps): void;
(e: 'failure', reason: string, fields: LoginFormProps): void;
@@ -158,19 +184,20 @@ async function login(params: LoginFormProps) {
try {
const res = await accountStore.login(params.username, params.password);
emit('success', params);
console.log(res);
message.success('登录成功!');
} catch (e: any) {
// emit('failure', e.message, e.data);
message.error(e.data.erro || '登录失败,请重试');
emit('failure', e.message || '登录失败', params);
message.error(e.data?.erro || '登录失败,请重试');
} finally {
loading.value = false;
}
}
// 忘记密码处理
// 忘记密码处理 - 打开弹窗
function handleForgotPassword() {
forgotModalVisible.value = true;
emit('forgot-password');
message.info('忘记密码功能即将上线');
}
// 注册处理
@@ -180,15 +207,85 @@ function handleRegister() {
</script>
<style scoped>
/* 页面基础样式 */
.login-page {
/* 全局布局适配 - 替代原外层容器的样式 */
:root {
-webkit-text-size-adjust: 100%; /* 禁止iOS文本缩放 */
-webkit-font-smoothing: antialiased; /* 优化字体渲染 */
}
body {
margin: 0;
padding: 0;
overflow-x: hidden;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* 全局居中布局 */
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
}
/* 登录卡片核心样式 */
.login-box {
box-sizing: border-box;
touch-action: manipulation; /* 优化触摸性能 */
/* 移动端全屏,PC端保留原max-w-md(默认28rem/448px)+ 90vw限制 */
}
/* 移动端适配(<768px) */
@media (max-width: 767.98px) {
body {
padding: 0;
}
.login-box {
max-width: 100vw !important; /* 移动端全屏 */
min-height: 100vh;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 20px 16px !important;
padding-top: 20vh !important; /* 移动端垂直居中 */
}
.third-title {
font-size: 1.25rem !important;
}
::v-deep(.ant-input) {
font-size: 14px; /* 移动端字体适配 */
}
}
/* 小屏手机额外适配 */
@media (max-width: 480px) {
.login-box {
padding-top: 15vh !important;
}
}
/* PC端样式(保留原有宽度,仅调小最大宽度) */
@media (min-width: 768px) {
body {
padding: 8px;
}
.login-box {
/* 原宽度是 min(448px, 90vw),现调整为更小的数值,比如 380px(可根据需求修改) */
max-width: min(340px, 90vw) !important;
min-height: auto !important;
border: 1px solid #e5e7eb !important;
border-radius: 1rem !important;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1) !important;
padding-top: 0 !important;
}
}
/* 输入框聚焦动画优化 */
::v-deep(.ant-input) {
box-sizing: border-box;
}
::v-deep(.ant-input:focus) {
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2) !important;
border-color: #4f46e5 !important;
outline: none; /* 移除默认轮廓 */
}
/* 复选框样式优化 */
@@ -196,29 +293,26 @@ function handleRegister() {
background-color: #4f46e5 !important;
border-color: #4f46e5 !important;
}
::v-deep(.ant-checkbox:hover .ant-checkbox-inner) {
border-color: #4f46e5 !important;
}
::v-deep(.ant-checkbox) {
transform: scale(0.9); /* 移动端复选框适当缩小 */
}
/* 按钮样式优化 */
::v-deep(.ant-btn-primary) {
background-color: #4f46e5 !important;
border-color: #4f46e5 !important;
box-sizing: border-box;
}
::v-deep(.ant-btn-primary:hover) {
background-color: #4338ca !important;
border-color: #4338ca !important;
}
::v-deep(.ant-btn-primary:focus) {
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.3) !important;
}
/* 分割线样式优化 */
::v-deep(.ant-divider) {
background-color: #e5e7eb !important;
outline: none;
}
/* 加载状态动画优化 */
@@ -226,24 +320,35 @@ function handleRegister() {
margin-right: 8px !important;
}
/* 响应式调整 */
@media (max-width: 375px) {
.login-box {
padding: 60px 20px !important;
}
/* 弹窗样式美化 */
::v-deep(.forgot-modal .ant-modal-header) {
border-radius: 12px 12px 0 0;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
::v-deep(.forgot-modal .ant-modal-title) {
color: #1f2937;
font-weight: 600;
font-size: 16px;
}
::v-deep(.forgot-modal .ant-modal-body) {
padding: 16px 20px;
}
::v-deep(.forgot-modal .ant-modal-content) {
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
overflow: hidden;
}
.third-title {
font-size: 1.5rem !important;
}
/* 防止点击闪烁和触摸高亮 */
* {
-webkit-tap-highlight-color: transparent;
tap-highlight-color: transparent;
}
/* 平滑滚动 */
html {
scroll-behavior: smooth;
}
/* 防止点击闪烁 */
* {
-webkit-tap-highlight-color: transparent;
overflow-x: hidden; /* 防止移动端横向滚动 */
}
</style>
File diff suppressed because it is too large Load Diff
+254 -40
View File
@@ -1,79 +1,293 @@
<template>
<a-form layout="inline" style="margin-top:5px;margin-bottom:5px;">
<a-form layout="inline" style="margin-top:5px;margin-bottom:5px;align-items: center;">
<!-- 日期控制 -->
<a-form-item>
<a-date-picker v-model:value="dateValue" format="YYYYMMDD" :locale="locale" @change="datePickChange" />
<div class="date-control-group">
<a-button type="text" @click="handleDateMinus" class="date-btn"><left-outlined /></a-button>
<span class="current-date-text">{{ currentDateText }}</span>
<a-button type="text" @click="handleDatePlus" class="date-btn" :disabled="isToday"><right-outlined /></a-button>
</div>
</a-form-item>
<!-- debug/error单选按钮 -->
<a-form-item>
<a-radio-group v-model:value="typeValue" button-style="solid" @change="typeChange">
<a-radio-group class="log-type-radio-group" v-model:value="typeValue" @change="typeChange" button-style="solid">
<a-radio-button value="debug">debug</a-radio-button>
<a-radio-button value="error">error</a-radio-button>
</a-radio-group>
</a-form-item>
<!-- 复制按钮 - 同一行最右侧 -->
<a-form-item style="margin-left: auto;">
<a-button type="text" size="small" @click="copyLogs" class="copy-btn">
<copy-outlined />
</a-button>
</a-form-item>
</a-form>
<div class="container">
<a-card title="" :bordered="true">
<pre>{{ logs }}</pre>
<a-card title="" :bordered="true" :class="{ 'log-error': typeValue === 'error' }">
<pre class="card-width-pre">{{ logs }}</pre>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { defineComponent, reactive, ref, watch, onMounted } from 'vue';
import { ref, onMounted, 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';
type RangeValue = [Dayjs, Dayjs];
import 'dayjs/locale/zh-cn';
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
dayjs.locale('zh-cn');
const dateValue = ref<Dayjs>(dayjs(Date()));
const currentDate = ref<Dayjs>(dayjs());
const typeValue = ref<string>('debug');
const iframeUrl = ref<string>('');
const dateValue1 = ref<string>();
const logs = ref<string>('');
dateValue1.value = dayjs(Date()).format('YYYYMMDD');
iframeUrl.value = `type=${typeValue.value}&date=${dateValue1.value}`;
const datePickChange = (e, dateStr) => {
dateValue1.value = dateStr;
iframeUrl.value = `type=${typeValue.value}&date=${dateValue1.value}`;
console.log(iframeUrl);
const currentDateText = computed(() => {
return currentDate.value.format('YYYYMMDD');
});
const isToday = computed(() => {
return currentDate.value.isSame(dayjs(), 'day');
});
const handleDateMinus = () => {
currentDate.value = currentDate.value.subtract(1, 'day');
updateIframeUrlAndLoadLogs();
};
const handleDatePlus = () => {
if (isToday.value) return;
currentDate.value = currentDate.value.add(1, 'day');
updateIframeUrlAndLoadLogs();
};
const updateIframeUrlAndLoadLogs = () => {
iframeUrl.value = `${typeValue.value}/${currentDateText.value}`;
loadLogs();
};
const typeChange = (e) => {
console.log(e.target);
iframeUrl.value = `type=${e.target.value}&date=${dateValue1.value}`;
loadLogs();
const typeChange = (e: any) => {
typeValue.value = e.target.value;
updateIframeUrlAndLoadLogs();
};
const mIfrm = ref<any>(null);
onMounted(() => {
// console.log(mIfrm.value);
loadLogs();
});
const formatLogTime = (logContent: string) => {
const timeRegex = /(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.\d+ \+08:00/g;
return logContent.replace(timeRegex, '$1');
};
const loadLogs = () => {
useApiStore()
.apiGetLogs(iframeUrl.value)
.then((log) => {
// console.log(log);
const lines = log.split('\n'); // 将文本按换行符分割为行数组
const reversedLines = lines.reverse(); // 对行数组进行倒序操作
const reversedText = reversedLines.join('\n'); // 将行数组重新连接为一个字符串
log = formatLogTime(log);
const lines = log.split('\n');
const reversedLines = lines.reverse();
const reversedText = reversedLines.join('\n');
logs.value = reversedText;
})
.catch((err) => {
console.error('加载日志失败:', err);
logs.value = '日志加载失败,请稍后重试';
});
};
// 复制日志内容的核心函数
const copyLogs = async () => {
try {
// 空内容判断
if (!logs.value || logs.value === '日志加载失败,请稍后重试') {
message.warning('暂无可复制的日志内容');
return;
}
// 使用浏览器剪贴板API复制内容
await navigator.clipboard.writeText(logs.value);
message.success('日志内容复制成功!');
} catch (err) {
console.error('复制失败:', err);
// 降级方案:兼容不支持Clipboard API的浏览器
const textArea = document.createElement('textarea');
textArea.value = logs.value;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
// 提示反馈
message.success('日志内容复制成功!');
}
};
onMounted(() => {
updateIframeUrlAndLoadLogs();
});
</script>
<style lang='less' scoped>
html {
<style lang='less' scoped>
// 基础样式:仅清理默认margin/padding,不设置全屏
html,
body {
height: 100vh;
margin: 0;
padding: 0;
}
// 表单容器:正常宽度,保留少量间距
:deep(.ant-form) {
width: 100%;
padding: 0 8px;
box-sizing: border-box;
margin: 0;
}
// 日志容器:自适应高度,无固定上限,保留上下留白
.container {
width: 100%;
height: 100%;
// max-height: 400px;
iframe {
.word-wrap {
color: white !important;
padding: 0 8px;
margin: 16px 0; // 上下留白,远离屏幕边缘
box-sizing: border-box;
overflow: hidden;
}
// pre样式:自适应高度,内容过多时内部滚动
.card-width-pre {
width: 100% !important;
min-width: 100%;
padding: 8px;
margin: 0 !important;
white-space: pre-wrap;
word-break: break-all;
// 核心:最小高度保证容器不塌陷,最大高度限制不超视口一半
min-height: 200px;
max-height: calc(67vh); // 最多占视口一半,绝对不会满屏
box-sizing: border-box;
overflow-y: auto;
overflow-x: auto;
font-size: 14px;
line-height: 1.5;
// 滚动条美化(保留)
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.2);
}
}
.date-control-group {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.date-btn {
width: 36px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.current-date-text {
font-size: 14px;
color: #1f2329;
font-weight: 500;
min-width: 80px;
text-align: center;
}
}
// 复制按钮样式
.copy-btn {
// height: 32px; /* 和日期按钮/单选按钮高度一致 */
// padding: 0 16px;
color: #722ed1;
}
// card-body样式:恢复默认内边距,作为pre的宽度基准
:deep(.ant-card-body) {
padding: 4px; // 恢复合理内边距,作为pre的宽度容器
height: 100%; // 全屏继承容器高度
width: 100%;
box-sizing: border-box;
overflow: hidden; // 新增:隐藏卡片内溢出,确保pre独占滚动区域
}
// error状态下pre文字颜色
.log-error {
.card-width-pre {
color: #f5222d !important;
}
}
// radio-button样式(保留之前的修正版)
:deep(.log-type-radio-group) {
.ant-radio-button-wrapper:first-child {
&.ant-radio-button-wrapper-checked {
background-color: #52c41a !important;
border-color: #52c41a !important;
color: #fff !important;
&:hover,
&:active {
background-color: #389e0d !important;
border-color: #389e0d !important;
}
}
&:not(.ant-radio-button-wrapper-checked):hover {
border-color: #73d13d !important;
color: #52c41a !important;
}
}
.ant-radio-button-wrapper:last-child {
&.ant-radio-button-wrapper-checked {
background-color: #f5222d !important;
border-color: #f5222d !important;
color: #fff !important;
&:hover,
&:active {
background-color: #cf1322 !important;
border-color: #cf1322 !important;
}
}
&:not(.ant-radio-button-wrapper-checked):hover {
border-color: #ff4d4f !important;
color: #f5222d !important;
}
}
.ant-radio-button-wrapper {
&:first-child {
border-radius: 6px 0 0 6px !important;
}
&:last-child {
border-radius: 0 6px 6px 0 !important;
}
&:not(:first-child) {
margin-left: -1px !important;
}
}
.ant-radio-button-wrapper-checked {
&:first-child {
&::before {
background-color: transparent !important;
}
}
}
}
</style>
// 调整form布局,让复制按钮靠右
:deep(.ant-form-inline) {
display: flex;
align-items: center;
width: 100%;
}
</style>
-173
View File
@@ -1,173 +0,0 @@
<template>
<a-drawer v-model:visible="visible" class="custom-class" title="邮件配置" placement="right" @after-visible-change="afterVisibleChange" :maskClosable="true">
<a-tabs v-model:activeKey="activeKey">
<a-tab-pane key="1">
<template #tab>
<span>
<!-- <BellOutlined /> -->
邮件STMP配置
</span>
</template>
<a-form ref="emailFromRef" :model="EmailFormData" name="basic" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }" autocomplete="off" @finish="onFinish" @finishFailed="onFinishFailed">
<a-form-item label="是否开启" ref="Open">
<a-checkbox v-model:checked="EmailFormData.Open">解析结果邮件通知</a-checkbox>
</a-form-item>
<a-form-item v-if="EmailFormData.Open" label="Stmp地址" ref="Stmp" name="Stmp" :rules="[{ required: EmailFormData.Open, message: '请输入stmp服务地址!',validator:validateStmp }]">
<a-input v-model:value="EmailFormData.Stmp" />
</a-form-item>
<a-form-item v-if="EmailFormData.Open" label="Stmp端口" ref="Port" name="Port" :rules="[{ required: EmailFormData.Open, message: '请输入stmp服务端口!',validator:validatePort }]">
<a-input v-model:value="EmailFormData.Port" placeholder="默认465,yeah-587" />
</a-form-item>
<a-form-item v-if="EmailFormData.Open" label="收件Email" type='email' ref="From" name="From" :rules="[{ required: EmailFormData.Open, message: '请输入收件Email地址!',validator:validateEmail }]">
<a-input v-model:value="EmailFormData.From" />
<span style="color:#888">此处收件人亦是发件人</span>
</a-form-item>
<a-form-item v-if="EmailFormData.Open" label="Stmp授权码" ref="Code" name="Code" :rules="[{ required: EmailFormData.Open, message: '请输入Stmp授权码!' }]">
<a-input v-model:value="EmailFormData.Code" />
<span style="color:#888">邮箱设置开启stmp服务会提示</span>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 8, span: 16 }">
<a-button type="primary" html-type="submit">确认</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-drawer>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue';
import { defineComponent, ref, onMounted, reactive, watch } from 'vue';
import type { FormInstance } from 'ant-design-vue';
import { useApiStore, useAccountStore } from '@/store';
import http from '@/store/http';
import type { Rule } from 'ant-design-vue/es/form';
import { checkEmail, checkDomain, checkPort } from '@/utils/regexHelper';
const activeKey = ref<string>('1');
const visible = ref<boolean>(false);
function showEmail(vis: boolean) {
visible.value = vis;
if (vis) loadStmpInfo();
}
const emailFromRef = ref<FormInstance>();
const afterVisibleChange = (bool: boolean) => {
if (!bool) {
emailFromRef.value.resetFields();
}
};
const loadStmpInfo = () => {
// useApiStore()
// .apiGetStmpConfig()
// .then((res) => {
// if (res.code === 0) {
// EmailFormData.Open = res.data.open;
// EmailFormData.Stmp = res.data.stmp;
// EmailFormData.Port = res.data.port;
// EmailFormData.Code = res.data.code;
// EmailFormData.From = res.data.from;
// EmailFormData.Id = res.data.id;
// }
// });
};
interface EmailFormState {
Open: boolean;
Stmp: string;
Port: number;
Code: string;
From: string;
Id: string;
To: string;
}
const EmailFormData = reactive<EmailFormState>({
Open: false,
Stmp: 'smtp.qq.com',
Port: 465,
Code: '',
From: '',
Id: '',
To: '',
});
const onFinish = (values: EmailFormState) => {
EmailFormData.To = EmailFormData.From;
// useApiStore()
// .apiSaveStmpConfig(EmailFormData)
// .then((res) => {
// console.log(res);
// if (res.code === 0) {
// message.success('修改配置成功');
// visible.value = false;
// } else {
// message.error(res.erro, 8);
// }
// });
};
//验证邮箱
const validateEmail = async (_rule: Rule, value: string) => {
if (checkEmail(value)) {
return Promise.resolve();
} else {
return Promise.reject('请输入正确的邮箱地址');
}
};
//验证stmp服务
const validateStmp = async (_rule: Rule, value: string) => {
if (checkDomain(value)) {
return Promise.resolve();
} else {
return Promise.reject('请输入正确的stmp-server地址');
}
};
//验证端口
const validatePort = async (_rule: Rule, value: string) => {
if (checkPort(value)) {
return Promise.resolve();
} else {
return Promise.reject('请输入正确的端口');
}
};
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
watch(
() => EmailFormData.Open,
() => {
emailFromRef.value.validateFields(['Stmp', 'Port', 'From', 'Code']);
},
{ flush: 'post' }
);
// 将updateMessage方法暴露给父组件调用
defineExpose({
showEmail,
});
</script>
<style>
.avatar-uploader > .ant-upload {
width: 100px;
height: 100px;
}
.ant-upload-picture-card-wrapper {
height: 100%;
}
.ant-upload-select-picture-card i {
font-size: 32px;
color: #999;
margin-top: 50px !important;
}
.ant-upload-select-picture-card {
margin-top: 50px !important;
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
}
.ant-tabs-content {
text-align: center;
}
</style>
+1 -23
View File
@@ -1,26 +1,6 @@
<template>
<a-drawer v-model:visible="visible" class="custom-class" title="个人设置" placement="right" @after-visible-change="afterVisibleChange" :maskClosable="true">
<a-tabs v-model:activeKey="activeKey">
<!-- <a-tab-pane key="1" style="height:100%">
<template #tab>
<span>
<user-outlined />
修改头像
</span>
</template>
<a-upload style="" v-model:file-list="fileList" name="file" list-type="picture-card" class="avatar-uploader" :show-upload-list="false" :action="uploadAction" :before-upload="beforeUpload" @change="handleChange" accept=".jpg, .jpeg, .png">
<img v-if="imageUrl" :src="imageUrl" alt="avatar" style="height: 112px; width: 112px; border-radius: 50%;" />
<div v-else>
<loading-outlined v-if="loading"></loading-outlined>
<plus-outlined v-else></plus-outlined>
<div class="ant-upload-text">选择图片</div>
</div>
</a-upload>
<div style="margin-top:20px;">
上传成功即修改成功
</div>
</a-tab-pane> -->
<a-tab-pane key="2">
<template #tab>
<span>
@@ -60,7 +40,6 @@ import { checkPass } from '@/utils/regexHelper';
import type { Rule } from 'ant-design-vue/es/form';
const activeKey = ref<string>('2');
const uploadAction = ref<string>('');
const visible = ref<boolean>(false);
function show(vis: boolean) {
@@ -122,7 +101,6 @@ const loadUserInfo = () => {
if (res.code === 0) {
if (res.data.avatar != null && res.data.avatar !== '') imageUrl.value = `/upload/${res.data.avatar}`;
passwordFormData.UserId = res.data.id;
uploadAction.value = `/api/auth/UpdateUserAvatar?Uid=${res.data.id}`;
}
});
};
@@ -150,7 +128,7 @@ const onFinish = (values: PassFormState) => {
visible.value = false;
http.removeAuthorization();
} else {
message.error(res.erro, 8);
message.error(res.message, 8);
}
});
}
+2 -2
View File
@@ -1,4 +1,4 @@
import MyPersonal from './MyPersonal.vue';
import EmailSet from './EmailSet.vue';
// import EmailSet from './EmailSet.vue';
export { MyPersonal, EmailSet };
export { MyPersonal };
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-745
View File
@@ -1,745 +0,0 @@
<template>
<div class="stats-dashboard">
<div class="dashboard-container">
<!-- 页面标题 -->
<!-- 核心统计概览 -->
<section class="stats-overview">
<div class="stat-card primary-card">
<div class="stat-header">
<span class="stat-meta">总视频数</span>
<div class="stat-icon video-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg>
</div>
</div>
<div class="stat-value">{{ totalVideos }}</div>
<div class="stat-trend">
<!-- <span class="trend-up">较上月 +5.2%</span> -->
</div>
</div>
<div class="stat-card secondary-card">
<div class="stat-header">
<span class="stat-meta">占用空间</span>
<div class="stat-icon size-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12H2v8h20v-8z" />
<path d="M6 18h.01" />
<path d="M10 18h.01" />
</svg>
</div>
</div>
<div class="stat-value">{{ fileSizeTotal }} G</div>
<div class="stat-trend">
<!-- <span class="trend-up">较上月 +12.8%</span> -->
</div>
</div>
</section>
<!-- 次级统计卡片组 -->
<section class="stats-grid">
<!-- 我喜欢的数量卡片 -->
<div class="stat-card mini-card">
<div class="stat-header">
<span class="stat-meta">我喜欢的</span>
<div class="stat-icon like-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
</div>
</div>
<div class="stat-value">{{ favoriteCount }}</div>
</div>
<!-- 我收藏的数量卡片 -->
<div class="stat-card mini-card">
<div class="stat-header">
<span class="stat-meta">我收藏的</span>
<div class="stat-icon collect-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
</svg>
</div>
</div>
<div class="stat-value">{{ collectCount }}</div>
</div>
<!-- 作者总数卡片 -->
<div class="stat-card mini-card">
<div class="stat-header">
<span class="stat-meta">作者总数</span>
<div class="stat-icon author-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
</div>
<div class="stat-value">{{ totalAuthors }}</div>
</div>
<!-- 分类总数卡片 -->
<div class="stat-card mini-card">
<div class="stat-header">
<span class="stat-meta">分类总数</span>
<div class="stat-icon cate-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2" />
<path d="M17 5v4" />
<path d="M7 5v4" />
</svg>
</div>
</div>
<div class="stat-value">{{ categoryTotal }}</div>
</div>
</section>
<!-- 详细分类统计 -->
<section class="detailed-stats">
<div class="stats-header">
<h2>详细统计</h2>
<div class="tab-controls">
<button class="tab-btn" :class="{ active: currentTab === 'author' }" @click="currentTab = 'author'">
视频作者
</button>
<button class="tab-btn" :class="{ active: currentTab === 'type' }" @click="currentTab = 'type'">
视频分类
</button>
</div>
</div>
<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">
<div class="author-avatar">
<img :src="author.icon" alt="作者头像" />
</div>
<div class="author-info">
<h3 class="author-name">{{ author.name }}</h3>
<p class="author-stats">作品数: {{ author.count }}</p>
</div>
<div class="author-progress">
<div class="progress-bar" :style="{ width: `${(author.count / totalVideos) * 100}%` }"></div>
</div>
</div>
</div>
</div>
<!-- 分类统计 -->
<div v-else key="category-view" class="stats-content">
<div class="categories-grid">
<div class="category-card" v-for="(category, index) in categories" :key="index" :style="{ '--category-color': category.color }">
<div class="category-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 12 12" fill="white">
<use :xlink:href="`#${category.icon}`"></use>
</svg>
</div>
<div class="category-info">
<h3 class="category-name">{{ category.name }}</h3>
<p class="category-stats">作品数: {{ category.count }}</p>
</div>
<div class="category-percentage">
{{ Math.round((category.count / totalVideos) * 100) }}%
</div>
</div>
</div>
</div>
</transition>
</section>
</div>
<!-- SVG图标定义 -->
<svg style="display: none;">
<!-- 所有图标定义保持不变 -->
<symbol id="cup" viewBox="0 0 12 12">
<path d="M18 4h2v16h-2zM4 4h14v2H4zM4 8h10v2H4zM4 12h10v2H4zM4 16h6v2H4zM4 20h6v2H4z" />
</symbol>
<!-- 其他图标省略,保持原样 -->
</svg>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { useApiStore } from '@/store';
// 类型接口
interface Author {
name: string;
count: number;
icon: string;
}
interface Category {
name: string;
count: number;
color: string;
icon: string;
}
// 状态管理
const totalVideos = ref<number>(0);
const totalAuthors = ref<number>(0);
const categoryTotal = ref<number>(0);
const fileSizeTotal = ref<number>(0);
const favoriteCount = ref<number>(0);
const collectCount = ref<number>(0);
const categories = ref<Category[]>([]);
const authors = ref<Author[]>([]);
const currentTab = ref<string>('author');
// 组件名称
defineOptions({
name: 'StatsDashboard',
});
// 加载数据
onMounted(() => {
loadDashboardData();
});
const loadDashboardData = async () => {
try {
const res = await useApiStore().VideoStatics();
totalVideos.value = res.data.videoCount;
totalAuthors.value = res.data.authorCount;
categoryTotal.value = res.data.categoryCount;
favoriteCount.value = res.data.favoriteCount;
collectCount.value = res.data.collectCount;
categories.value = res.data.categories;
fileSizeTotal.value = res.data.viedoSizeTotal; // 修复拼写错误
authors.value = res.data.authors;
// 分类图标和颜色随机分配
const cates = getRandomElements(categoriessss.value, categories.value.length);
const colors = getRandomElements(colorArray, categories.value.length);
categories.value.forEach((item, index) => {
item.icon = cates[index];
item.color = colors[index];
});
} catch (err) {
console.error('加载仪表盘数据失败:', err);
}
};
// 随机获取数组元素方法
const getRandomElements = (arr: any[], n: number) => {
if (n <= 0) return [];
if (n >= arr.length) return [...arr];
return [...arr].sort(() => Math.random() - 0.5).slice(0, n);
};
// 图标列表和颜色数组
const categoriessss = ref<any[]>([
'cup',
'spoon',
'fork',
'knife',
'plate',
'bottle',
'toothbrush',
'comb',
'mirror',
'soap',
'mobile',
'tablet',
'camera',
'headphones',
'speaker',
'tv',
'watch',
'charger',
]);
const colorArray = [
'#3A7CA5',
'#D9A566',
'#6B4226',
'#829399',
'#A63446',
'#4F6367',
'#FE5F55',
'#C7EFCF',
'#78C0E0',
'#49BEAA',
'#49DCB1',
'#FF9999',
'#66B2FF',
'#99FF99',
];
</script>
<style scoped>
/* 白天模式默认样式(无dark-mode类时生效) */
.stats-dashboard {
min-height: 100vh;
background-color: #ffffff; /* 白天背景:白色 */
color: #333333; /* 白天文本:深灰 */
font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
padding: 20px 0;
}
/* 加宽整体宽度并减少两侧留白 */
.dashboard-container {
max-width: 1400px; /* 从1200px加宽至1600px */
margin: 0 auto;
padding: 0 15px; /* 两侧留白从20px减至15px */
}
/* 响应式适配:避免宽屏溢出 */
@media (max-width: 1700px) {
.dashboard-container {
max-width: 95%; /* 中等屏幕用百分比控制宽度 */
}
}
/* 头部样式 */
.dashboard-header {
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 1px solid #e0e0e0; /* 白天边框:浅灰 */
}
.dashboard-header h1 {
font-size: 28px;
margin: 0 0 10px 0;
color: #333333; /* 白天标题:深灰 */
}
.header-subtitle {
font-size: 16px;
color: #666666; /* 白天副标题:中灰 */
margin: 0;
}
/* 统计卡片样式(增强白天阴影) */
.stat-card {
background: #f5f5f5; /* 白天卡片背景:浅灰 */
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); /* 增强阴影 */
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15); /* 增强hover阴影 */
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.stat-meta {
font-size: 14px;
color: #666666; /* 白天元数据:中灰 */
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.video-icon {
background-color: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.size-icon {
background-color: rgba(33, 150, 243, 0.2);
color: #2196f3;
}
.like-icon {
background-color: rgba(233, 30, 99, 0.2);
color: #e91e63;
}
.collect-icon {
background-color: rgba(255, 152, 0, 0.2);
color: #ff9800;
}
.author-icon {
background-color: rgba(156, 39, 176, 0.2);
color: #9c27b0;
}
.cate-icon {
background-color: rgba(0, 188, 212, 0.2);
color: #00bcd4;
}
.stat-value {
font-size: 32px;
font-weight: 700;
margin: 0 0 10px 0;
color: #333333;
line-height: 1;
}
.stat-trend {
font-size: 13px;
}
.trend-up {
color: #4caf50;
display: flex;
align-items: center;
}
.trend-up::before {
content: '↑';
margin-right: 4px;
}
/* 概览区域布局 */
.stats-overview {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
margin-bottom: 30px;
}
@media (min-width: 768px) {
.stats-overview {
grid-template-columns: 2fr 1fr;
}
}
.primary-card {
border-top: 4px solid #4caf50;
}
.secondary-card {
border-top: 4px solid #2196f3;
}
/* 次级统计网格布局 */
.stats-grid {
display: grid;
grid-template-columns: 1fr;
gap: 15px;
margin-bottom: 30px;
}
@media (min-width: 576px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 768px) {
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.mini-card .stat-value {
font-size: 24px;
}
/* 详细统计区域 */
.detailed-stats {
background: #f5f5f5;
border-radius: 12px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.stats-header h2 {
margin: 0;
font-size: 20px;
color: #333333;
}
.tab-controls {
display: flex;
gap: 10px;
}
.tab-btn {
background: transparent;
border: none;
color: #666666;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
}
.tab-btn.active {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
font-weight: 500;
}
.tab-btn:hover:not(.active) {
background: rgba(0, 0, 0, 0.05);
color: #333333;
}
/* 统计内容区域 */
.stats-content {
animation: fadeIn 0.5s ease;
}
.authors-grid,
.categories-grid {
display: grid;
grid-template-columns: 1fr;
gap: 15px;
}
@media (min-width: 576px) {
.authors-grid,
.categories-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 992px) {
.authors-grid,
.categories-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* 作者卡片(增强白天阴影) */
.author-card {
background: #eeeeee;
border-radius: 8px;
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); /* 新增阴影 */
}
.author-card:hover {
transform: translateX(5px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12); /* 增强hover阴影 */
}
.author-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
overflow: hidden;
}
.author-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.author-info {
flex: 1;
}
.author-name {
margin: 0 0 5px 0;
font-size: 16px;
color: #333333;
}
.author-stats {
margin: 0;
font-size: 13px;
color: #666666;
}
.author-progress {
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: #4caf50;
border-radius: 3px;
transition: width 0.5s ease;
}
/* 分类卡片(增强白天阴影) */
.category-card {
background: #eeeeee;
border-radius: 8px;
padding: 15px;
display: flex;
align-items: center;
gap: 15px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); /* 新增阴影 */
}
.category-card:hover {
transform: translateX(5px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12); /* 增强hover阴影 */
}
.category-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background-color: var(--category-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.category-info {
flex: 1;
}
.category-name {
margin: 0 0 3px 0;
font-size: 16px;
color: #333333;
}
.category-stats {
margin: 0;
font-size: 13px;
color: #666666;
}
.category-percentage {
font-size: 14px;
font-weight: 500;
color: var(--category-color);
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stats-fade-enter-from,
.stats-fade-leave-to {
opacity: 0;
transform: translateY(10px);
}
.stats-fade-enter-active,
.stats-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
/* 晚上模式样式 */
html.dark-mode .stats-dashboard {
background-color: #1a1a2e;
color: #eaeaea;
}
html.dark-mode .dashboard-header {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
html.dark-mode .dashboard-header h1 {
color: #ffffff;
}
html.dark-mode .header-subtitle {
color: #b0b0c3;
}
html.dark-mode .stat-card {
background: rgba(30, 30, 50, 0.8);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
html.dark-mode .stat-card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
html.dark-mode .stat-meta {
color: #b0b0c3;
}
html.dark-mode .stat-value {
color: #ffffff;
}
html.dark-mode .detailed-stats {
background: rgba(30, 30, 50, 0.8);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
html.dark-mode .stats-header h2 {
color: #ffffff;
}
html.dark-mode .tab-btn {
color: #b0b0c3;
}
html.dark-mode .tab-btn:hover:not(.active) {
background: rgba(255, 255, 255, 0.05);
color: #ffffff;
}
html.dark-mode .author-card {
background: rgba(40, 40, 65, 0.6);
box-shadow: none;
}
html.dark-mode .author-name {
color: #ffffff;
}
html.dark-mode .author-stats {
color: #b0b0c3;
}
html.dark-mode .author-progress {
background: rgba(255, 255, 255, 0.1);
}
html.dark-mode .category-card {
background: rgba(40, 40, 65, 0.6);
box-shadow: none;
}
html.dark-mode .category-name {
color: #ffffff;
}
html.dark-mode .category-stats {
color: #b0b0c3;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff