Initial project

This commit is contained in:
Timi
2025-07-16 16:35:28 +08:00
parent 1e8213575b
commit 1c1f2f6594
36 changed files with 11610 additions and 129 deletions

View File

@ -0,0 +1,92 @@
<template>
<div class="login-menu">
<t-dropdown
v-if="userStore.isLogged() && userStore.loginUser.user"
class="logged"
trigger="click"
v-popup:config="popupUserStore.config"
@mouseenter="popupUserStore.user.value = userStore.loginUser.user"
>
<t-button class="button" variant="text">
<template #icon>
<t-avatar
class="icon"
:class="(<any>ImageType)[userStore.loginUser.user.profile.avatarType]"
:image="UserAPI.getAvatarURL(userStore.loginUser.user.profile)"
size="small"
shape="round"
/>
</template>
<span v-text="userStore.loginUser.user.name"></span>
<template #suffix>
<icon name="ARROW_1_S" />
</template>
</t-button>
<t-dropdown-menu>
<t-dropdown-item @click="toUserSpace">
<template #prefixIcon>
<icon name="USER" />
</template>
<span>个人中心</span>
</t-dropdown-item>
<t-dropdown-item @click="userStore.logout()">
<template #prefixIcon>
<icon />
</template>
<span>退出登录</span>
</t-dropdown-item>
</t-dropdown-menu>
</t-dropdown>
<t-button
v-else
variant="text"
@click="visitableAuth = true"
>
<template #icon>
<icon class="icon" name="USER" />
</template>
<span>登录</span>
</t-button>
</div>
<t-dialog
v-model:visible="visitableAuth"
attach="body"
:close-btn="false"
:header="false"
:footer="false"
>
<authorize-form
v-if="visitableAuth"
@login-success="visitableAuth = false"
@register-success="visitableAuth = false"
/>
</t-dialog>
</template>
<script lang="ts" setup>
import { Icon, ImageType, SettingKey, SettingMapper, UserAPI, userStore } from "timi-web";
import { AuthorizeForm, popupUserStore } from "timi-tdesign-pc";
const visitableAuth = ref<boolean>(false);
const toUserSpace = () => {
const loginUserId = userStore.loginUser.token?.id;
if (loginUserId) {
window.open(`${SettingMapper.getDomainLink(SettingKey.DOMAIN_SPACE)}/${loginUserId}`, "_blank");
}
};
</script>
<style lang="less" scoped>
.login-menu {
.icon {
margin-right: .5rem;
}
.logged {
&.button {
padding: 0;
}
}
}
</style>

View File

@ -0,0 +1,59 @@
<template>
<div class="article-list">
<article
class="article"
v-for="item in list"
:key="item.id"
>
<header class="header">
<h4 class="title" >
<router-link class="link black" :to="`/aid${item.id}.html`">
<span v-text="item.title"></span>
</router-link>
</h4>
</header>
<section class="digest gray" v-text="item.digest"></section>
</article>
</div>
</template>
<script lang="ts" setup>
import { Article } from "@/types/Article";
const props = withDefaults(defineProps<{
list: Article<any>[]
}>(), {
list: () => []
});
const { list } = toRefs(props);
</script>
<style lang="less" scoped>
.article-list {
padding: 1rem;
.article {
margin-bottom: 1rem;
&:hover {
background: #EEE;
}
.header {
.title {
margin: 0;
padding: .25rem 1rem;
border-left: 3px solid var(--td-brand-color);
}
}
.digest {
height: 3rem;
font-size: 13px;
margin-top: .25rem;
padding-left: calc(1rem + 3px);
}
}
}
</style>

View File

@ -0,0 +1,164 @@
<template>
<div class="article-about">
<div v-if="article">
<markdown-view class="content" :content="article.data" />
<p
class="update-at gray"
v-text="'最后编辑时间:' + Time.toPassedDateTime(article.updatedAt || article.createdAt)"
></p>
</div>
<div class="spectrum" @click="toggleBGM" v-popup:text="'点击暂停/播放'">
<canvas ref="canvas" :width="canvasWidth" height="80"></canvas>
</div>
<audio ref="player" autoplay>
<source src="@/assets/media/fragile.mp3" type="audio/mpeg" />
</audio>
<p class="survival-time light-gray" v-text="survivalTime"></p>
<comment
v-if="SettingMapper.is(SettingKey.ENABLE_COMMENT) && article && article.showComment"
:bizType="CommentBizType.ARTICLE"
:bizId="1"
:titleStickyOffset="49"
:canComment="article.canComment"
/>
</div>
</template>
<script lang="ts" setup>
import { CommentBizType, MarkdownView, SettingKey, SettingMapper, Time } from "timi-web";
import ArticleAPI from "@/api/ArticleAPI";
import { ArticleView } from "@/types/Article";
import { Comment } from "timi-tdesign-pc";
// 文章
const article = ref<ArticleView<any>>();
onMounted(async () => article.value = await ArticleAPI.view(1));
// 运行时间
const survivalTime = ref<string>("网站已运行 ---- 年 --- 天 -- 小时 -- 分钟 -- 秒");
const survivalTimer = ref();
onMounted(async () => {
const begin = new Date("2017/10/9 22:32:52");
clearInterval(survivalTimer.value);
survivalTimer.value = setInterval(() => {
const r = Time.between(begin);
survivalTime.value = `网站已运行 ${r.y}${r.d}${r.h} 小时 ${r.m.toString().padStart(2, "0")} 分钟 ${r.s.toString().padStart(2, "0")}`;
}, 1000);
});
onBeforeUnmount(() => clearInterval(survivalTimer.value));
// 音乐
const player = ref<HTMLAudioElement>();
const canvas = ref<HTMLCanvasElement>();
const canvasWidth = ref(480);
const spectrumRender = ref<number>();
const toggleBGM = () => {
if (player.value) {
if (player.value.paused) {
player.value.play();
} else {
player.value.pause();
}
}
};
const drawSpectrum = () => {
if (!canvas.value || !player.value) {
return;
}
let ctx: any = new AudioContext();
const analyser = ctx.createAnalyser();
const audioSrc = ctx.createMediaElementSource(<HTMLMediaElement>player.value);
audioSrc.connect(analyser);
analyser.connect(ctx.destination);
const cWidth = canvas.value.width,
meterSize = 7,
cHeight = canvas.value.height - 2,
meterWidth = 6,
capHeight = 2,
meterNum = 2400 / meterSize,
capYPositionArray = [];
ctx = canvas.value.getContext("2d");
// 渐变
const gradient = ctx.createLinearGradient(0, 0, 0, 100);
gradient.addColorStop(1, "#A67D7B");
gradient.addColorStop(0.5, "#A67D7B");
gradient.addColorStop(0, "#A67D7B");
const capStyle = "#A67D7B";
// 动画
(function renderFrame() {
const array = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(array);
const step = Math.round(array.length / meterNum);
ctx.clearRect(0, 0, cWidth, cHeight);
for (let i = 0; i < meterNum; i++) {
const value = array[i * step];
if (capYPositionArray.length < Math.round(meterNum)) {
capYPositionArray.push(value);
}
ctx.fillStyle = capStyle;
if (value < capYPositionArray[i]) {
ctx.fillRect(4 + i * meterSize, cHeight - (--capYPositionArray[i] / 3.2), meterWidth, capHeight);
} else {
ctx.fillRect(4 + i * meterSize, cHeight - value / 3.2, meterWidth, capHeight);
capYPositionArray[i] = value;
}
ctx.fillStyle = gradient;
ctx.fillRect(4 + i * meterSize, cHeight - value / 3.2 + capHeight, meterWidth, cHeight);
}
spectrumRender.value = requestAnimationFrame(renderFrame);
})();
};
onMounted(async () => {
if (player.value) {
player.value.volume = .2;
player.value.addEventListener("loadeddata", () => {
if (player.value && 2 <= player.value.readyState) {
// TODO: 成功 play 才允许绘制
drawSpectrum();
}
});
}
});
onBeforeUnmount(() => {
if (spectrumRender.value) {
cancelAnimationFrame(spectrumRender.value);
}
});
</script>
<style lang="less" scoped>
.article-about {
.content {
width: calc(100% - 2rem);
padding: .5rem 1rem;
}
.update-at {
text-align: right;
padding-right: 1rem;
}
.spectrum {
width: 20rem;
height: 320px;
margin: 0 auto;
display: flex;
background: url("@/assets/img/nagiasu.png") bottom no-repeat;
flex-direction: column;
justify-content: end;
background-size: 100%;
}
.survival-time {
height: 16px;
font-size: 13px;
text-align: center;
}
}
</style>

View File

@ -0,0 +1,346 @@
<template>
<div v-if="article" class="article-music">
<p class="time" v-text="articleTime" @click="isCreatedAt = !isCreatedAt"></p>
<!-- 光盘 -->
<div class="disk-box" v-if="article.extendData">
<div class="cover">
<img :src="coverUrl" alt="专辑封面" />
</div>
<!-- SVG 绘制扇形文字 -->
<div class="disk">
<svg viewBox="0 0 100 100">
<path d="M 0,50 a 50,50 0 1,1 0,1 z" id="circle" />
<text fill="#F4F4F4">
<textPath xlink:href="#circle">{{ article.extendData.title }}</textPath>
</text>
</svg>
</div>
</div>
<markdown-view class="description" :content="article.data" />
<div class="content selectable" v-if="article.extendData">
<section class="list">
<h4 class="title">曲目</h4>
<ul class="items">
<li class="item" v-for="(item, i) in article.extendData.list" :key="i" :data-i="`${i + 1}.`">
<div class="value">
<span v-text="item.title"></span>
<icon v-if="item.audio" class="icon cur-pointer" name="MUSIC" @click="doActiveMedia(i)" />
<icon v-if="item.video" class="icon cur-pointer" name="VIDEO" @click="doActiveMedia(i)" />
</div>
<div v-if="activeMediaIndex === i" class="media">
<audio v-if="item.audio" :src="Toolkit.toResURL(item.audio)" controls></audio>
<video v-if="item.video" :src="Toolkit.toResURL(item.video)" controls></video>
</div>
</li>
</ul>
</section>
<section class="info">
<h4 class="title">信息</h4>
<ul class="items">
<li class="item" v-for="(item, i) in article.extendData.info" :key="i">
<span class="label diselect" v-text="`${item.key}`"></span>
<span class="gray" v-text="item.value"></span>
</li>
</ul>
</section>
</div>
</div>
<comment
v-if="SettingMapper.is(SettingKey.ENABLE_COMMENT) && article && article.showComment"
:bizType="CommentBizType.ARTICLE"
:bizId="article.id!"
:titleStickyOffset="80"
:canComment="article.canComment"
/>
</template>
<script lang="ts" setup>
import { ArticleAttachType, ArticleMusicExtendData, ArticleView } from "@/types/Article.ts";
import { CommentBizType, CommonAPI, Icon, MarkdownView, SettingKey, SettingMapper, Time, Toolkit } from "timi-web";
import { Comment } from "timi-tdesign-pc";
const props = defineProps<{
article?: ArticleView<ArticleMusicExtendData>,
}>();
const { article } = toRefs(props);
const isCreatedAt = ref<boolean>(false);
const articleTime = computed(() => {
if (article.value) {
if (isCreatedAt.value || !article.value.updatedAt) {
return "发布于 " + Time.toPassedDateTime(article.value.createdAt);
} else {
return "编辑于 " + Time.toPassedDateTime(article.value.updatedAt);
}
}
});
// 专辑封面
const coverUrl = computed(() => {
if (article.value && article.value.attachmentList) {
for (let i = 0; i < article.value.attachmentList.length; i++) {
const item = article.value.attachmentList[i];
const attachType = (<any>ArticleAttachType)[item.attachType!];
if (attachType === ArticleAttachType.COVER) {
return CommonAPI.getAttachmentReadAPI(item.mongoId);
}
}
}
return undefined;
})
// 展开可播放媒体
const activeMediaIndex = ref();
function doActiveMedia(index: number) {
if (index === activeMediaIndex.value) {
activeMediaIndex.value = undefined;
} else {
activeMediaIndex.value = index;
}
}
</script>
<style lang="less" scoped>
.article-music {
.time {
text-align: center;
}
.disk-box {
width: 628px;
height: 430px;
margin: 12px auto;
display: flex;
position: relative;
align-items: center;
.cover {
left: 3rem;
width: 456px;
height: 428px;
z-index: 2;
padding: 2px 0 0 2px;
position: absolute;
transition: .3s;
/* 蒙板 */
&::after {
content: '';
top: 0;
left: 0;
width: 458px;
height: 430px;
z-index: 3;
position: absolute;
background: url('~@/assets/img/disk.png') no-repeat;
}
img {
width: 100%;
height: 100%;
border: var(--tui-border);
box-shadow: var(--tui-shadow);
}
}
.disk {
top: 50%;
right: 3rem;
color: #F4F4F4;
width: 16rem;
height: 16rem;
border: 3px solid #888;
padding: 40px;
z-index: 1;
position: absolute;
font-size: .5rem;
transform: rotateZ(50deg);
transition: .3s;
margin-top: calc(-43px - 8rem);
background: #212121;
border-radius: 50%;
&::after {
content: '';
top: 50%;
left: 50%;
width: 30px;
height: 30px;
margin: -23px 0 0 -23px;
border: 8px solid #BBB;
position: absolute;
background: #F4F4F4;
border-radius: 50%;
}
svg {
display: block;
overflow: visible;
path {
fill: none;
}
}
}
&:hover,
&:active {
.cover {
left: 0;
transition: 520ms var(--tui-bezier);
}
.disk {
right: 0;
transform: rotateZ(120deg);
transition: 520ms var(--tui-bezier);
}
}
}
.description {
padding: 1rem;
}
.content {
display: flex;
padding: 12px 20px;
flex-wrap: wrap;
justify-content: space-between;
.list {
flex: 10;
border: 1px solid #CDDEF0;
min-width: 440px;
background: #FFF;
margin-right: 1.5rem;
.title {
margin: 0;
padding: .5rem 1rem;
background: #CDDEF0;
}
.items {
margin: 0;
position: relative;
list-style: none;
padding-left: 0;
.item {
padding: 4px 0;
display: flex;
line-height: 1.5;
flex-direction: column;
&::before {
content: attr(data-i);
color: #525870;
width: 32px;
height: 32px;
position: absolute;
text-align: right;
font-weight: bold;
}
&:nth-child(2n) {
background: #F4F4F4;
}
.value {
display: flex;
align-items: center;
text-indent: 2.5em;
.icon {
margin-left: .5rem;
&:hover {
fill: #FF7A9B;
}
}
}
.media {
display: flex;
justify-content: center;
audio,
video {
width: calc(100% - 5em);
margin: 4px auto;
border: var(--tui-border);
box-shadow: var(--tui-shadow);
}
audio {
height: 2.5rem;
background: #F1F3F4;
}
}
}
}
}
.info {
color: #666;
flex: 2;
min-width: 220px;
text-align: left;
line-height: 1.6;
margin-bottom: 24px;
.title {
margin: .5rem 0 0 0;
}
.items {
padding: 4px;
font-size: 12px;
margin-top: 0;
.item {
padding: 6px 0;
line-height: 1.5;
border-bottom: 1px dashed #777;
.label {
color: #525870;
font-weight: bold;
}
}
}
}
}
}
@media screen and (max-width: 680px) {
.article-music {
.disk-box {
width: calc(628px * .5);
height: calc(430px * .5);
.cover {
left: 3rem;
width: calc(456px * .5);
height: calc(428px * .5);
&::after {
width: calc(458px * .5);
height: calc(430px * .5);
}
}
.disk {
width: calc(16rem * .4);
height: calc(16rem * .4);
margin-top: calc(-43px - 8rem * .4);
}
}
}
}
</style>

View File

@ -0,0 +1,52 @@
<template>
<div v-if="article" class="article-public">
<div class="time" v-text="articleTime" @click="isCreatedAt = !isCreatedAt"></div>
<markdown-view class="content" :content="article.data" />
<comment
v-if="SettingMapper.is(SettingKey.ENABLE_COMMENT) && article.showComment"
:bizType="CommentBizType.ARTICLE"
:bizId="article.id!"
:titleStickyOffset="80"
:canComment="article.canComment"
/>
</div>
</template>
<script lang="ts" setup>
import { CommentBizType, MarkdownView, SettingKey, SettingMapper, Time } from "timi-web";
import { ArticleView } from "@/types/Article";
import { Comment } from "timi-tdesign-pc";
const props = defineProps<{
article?: ArticleView<any>,
}>();
const { article } = toRefs(props);
const isCreatedAt = ref<boolean>(false);
const articleTime = computed(() => {
if (article.value) {
if (isCreatedAt.value || !article.value.updatedAt) {
return "发布于 " + Time.toPassedDateTime(article.value.createdAt);
} else {
return "编辑于 " + Time.toPassedDateTime(article.value.updatedAt);
}
}
});
</script>
<style lang="less" scoped>
.article-public {
.time {
text-align: center;
margin: 1rem 0 2rem 0;
}
.content {
width: calc(100% - 2rem);
padding: .5rem 1rem;
}
}
</style>

View File

@ -0,0 +1,168 @@
<template>
<div v-if="article" class="article-software">
<p class="time" v-text="articleTime" @click="isCreatedAt = !isCreatedAt"></p>
<div class="header">
<img class="logo" :src="coverUrl" alt="程序封面" />
<div v-if="article.extendData" class="info">
<div>
<span>官网</span>
<a :href="article.extendData.url" v-text="article.extendData.url"></a>
</div>
<div>
<span>版本</span>
<span class="selectable" v-text="article.extendData.version"></span>
</div>
<div>
<span>文件格式</span>
<span class="selectable" v-text="article.extendData.format"></span>
</div>
<div class="runtimes">
<span>运行环境</span>
<span
class="runtime"
v-for="item in article.extendData.runtime"
v-text="`${item} `"
:key="item"
></span>
</div>
<div>
<span>大小</span>
<span v-text="IOSize.format(article.extendData.size)"></span>
</div>
<div class="downloads">
<span>下载</span>
<a
class="download"
v-for="item in article.extendData.downloads"
:href="Toolkit.toResURL(item.value)"
:key="item.value"
v-text="item.type"
target="_blank"
></a>
</div>
<div>
<span>解压密码</span>
<span class="selectable" v-text="article.extendData.password"></span>
</div>
</div>
</div>
<markdown-view class="content" :content="article.data" />
</div>
<comment
v-if="SettingMapper.is(SettingKey.ENABLE_COMMENT) && article && article.showComment"
:bizType="CommentBizType.ARTICLE"
:bizId="article.id!"
:titleStickyOffset="80"
:canComment="article.canComment"
/>
</template>
<script lang="ts" setup>
import { ArticleAttachType, ArticleSoftwareExtendData, ArticleView } from "@/types/Article.ts";
import { CommentBizType, CommonAPI, IOSize, MarkdownView, SettingKey, SettingMapper, Time, Toolkit } from "timi-web";
import { Comment } from "timi-tdesign-pc";
const props = defineProps<{
article?: ArticleView<ArticleSoftwareExtendData>,
}>();
const { article } = toRefs(props);
const isCreatedAt = ref<boolean>(false);
const articleTime = computed(() => {
if (article.value) {
if (isCreatedAt.value || !article.value.updatedAt) {
return "发布于 " + Time.toPassedDateTime(article.value.createdAt);
} else {
return "编辑于 " + Time.toPassedDateTime(article.value.updatedAt);
}
}
});
const coverUrl = computed(() => {
if (article.value && article.value.attachmentList) {
for (let i = 0; i < article.value.attachmentList.length; i++) {
const item = article.value.attachmentList[i];
const attachType = (<any>ArticleAttachType)[item.attachType!];
if (attachType === ArticleAttachType.COVER) {
return CommonAPI.getAttachmentReadAPI(item.mongoId);
}
}
}
return undefined;
})
</script>
<style lang="less" scoped>
.article-software {
.time {
text-align: center;
}
.header {
display: flex;
padding: 1rem;
margin-bottom: 2rem;
.logo {
width: 140px;
height: 140px;
transition: 520ms var(--tui-bezier);
}
.info {
font-size: 13px;
border-left: 2px solid var(--tui-light-gray);
margin-left: 1rem;
padding-left: 1rem;
> div {
margin-bottom: 4px;
> span:first-child {
color: #666;
padding: 2px 0;
}
}
.downloads {
.download {
border: 1px solid #FF7A9B;
padding: 1px 4px;
background: transparent;
margin-right: 6px;
&:hover {
color: #FFF;
background: #FF7A9B;
}
&:active {
background: #FFAAC0;
}
}
}
}
}
.content {
width: calc(100% - 2rem);
padding: 1rem;
}
}
@media screen and (max-width: 650px) {
.article-software {
.header {
.logo {
width: 70px;
height: 70px;
}
}
}
}
</style>