Initial project

This commit is contained in:
Timi
2025-07-16 16:41:21 +08:00
parent 09bd61b486
commit 1c4c53bb91
38 changed files with 12796 additions and 129 deletions

5
.editorconfig Normal file
View File

@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = tab
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true

2
.env.development Normal file
View File

@ -0,0 +1,2 @@
# 接口
VITE_API=https://api.imyeyu.dev

15
.eslintignore Normal file
View File

@ -0,0 +1,15 @@
*.sh
node_modules
*.md
*.woff
*.ttf
.vscode
.idea
dist
/public
/docs
.husky
.local
.eslintrc.cjs
dist
pnpm-lock.yaml

82
.eslintrc.cjs Normal file
View File

@ -0,0 +1,82 @@
module.exports = {
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:vue/vue3-essential",
"./.eslintrc-auto-import.json"
],
"overrides": [
{
"env": {
"node": true
},
"files": [
".eslintrc.{js}"
],
"parserOptions": {
"sourceType": "script"
}
}
],
"parserOptions": {
"ecmaVersion": "latest",
"parser": "@typescript-eslint/parser",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"vue"
],
"rules": { // 注释是解释使用该设置的效果,而不是设置属性本身
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
// 其他
"camelcase": 2, // 变量驼峰式命名
"quotes": ["error", "double"], // 强制双引字符串
"eqeqeq": ["error", "always"], // 强制全等比较
"semi": ["error", "always"], // 强制语句分号结束
"max-len": [
"error",
{
"code": 180
}
],
// 逗号
"comma-style": [2, "last"], // 逗号出现在行末 [first, last]
"comma-dangle": [2, "never"], // 数组或对象不可带最后一个逗号 [never, always, always-multiline]
// 空格
"no-trailing-spaces": "error", // 禁止行末存在空格
"comma-spacing": [2, { "before": false, "after": true }], // 逗号后需要空格
"semi-spacing": ["error", { "before": false, "after": true }], // 分号后需要空格
"computed-property-spacing": [2, "never"], // 以方括号取对象属性时,[ 后面和 ] 前面需要空格, [never, always]
"space-before-function-paren": ["error", { // 函数括号前空格
"anonymous": "always", // 针对匿名函数表达式,比如 function () {}
"named": "never", // 针对命名函数表达式,比如 function foo() {}
"asyncArrow": "always" // 针对异步的箭头函数表达式,比如 async () => {}
}],
// 缩进
"no-mixed-spaces-and-tabs": "off", // 允许混合缩进
"no-tabs": ["error", { allowIndentationTabs: true }], // 使用 Tab 缩进
"indent": ["error", "tab", { // Tab 缩进相关
SwitchCase: 1 // Switch Case 缩进一级
}],
// 框架
"@typescript-eslint/ban-types": "off", // TS 允许空对象
"@typescript-eslint/no-empty-function": "off", // TS 允许空函数
"@typescript-eslint/no-explicit-any": "off", // TS 允许 any 类型
"@typescript-eslint/explicit-module-boundary-types": "off", // TS 允许显式模块边界类型?
"vue/no-multiple-template-root": "off", // Vue3 支持多个根节点
"@typescript-eslint/no-this-alias": "off", // 允许 this 变量本地化
"vue/no-v-model-argument": "off", // 允许 v-model 支持参数
"vue/multi-word-component-names": "off", // 允许单个词语的组件名
"@typescript-eslint/no-unused-expressions": [ // 允许逻辑与(&&)、逻辑或(||)短路
"error",
{ "allowShortCircuit": true }
]
}
};

145
.gitignore vendored
View File

@ -1,138 +1,29 @@
# ---> Node
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) node_modules
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist dist
dist-ssr
*.local
# Gatsby files # Editor directories and files
.cache/ .vscode/*
# Comment in the public line in if your project uses Gatsby and not Next.js !.vscode/extensions.json
# https://nextjs.org/blog/next-9-1#public-directory-support .idea
# public .DS_Store
*.suo
# vuepress build output *.ntvs*
.vuepress/dist *.njsproj
*.sln
# vuepress v2.x temp and cache directory *.sw?
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
/.env.production
/.eslintrc-auto-import.json
/components.d.ts
/src/auto-imports.d.ts

View File

@ -1,3 +1,18 @@
# space # Vue 3 + TypeScript + Vite
账号系统个人中心前端 This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support For `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="icon" href="public/favicon.ico" type="image/x-icon">
<title>账号中心 - 夜雨</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "space",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "4",
"less": "^4.3.0",
"pinia": "^3.0.1",
"timi-web": "link:..\\timi-web",
"timi-tdesign-pc": "link:..\\timi-tdesign-pc",
"tdesign-vue-next": "1.13.0"
},
"devDependencies": {
"@types/node": "^22.15.18",
"@vitejs/plugin-vue": "^5.2.4",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/compiler-sfc": "^3.5.13",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.4.0",
"typescript": "^5.8.3",
"vite": "6.3.4",
"vue-tsc": "^2.2.10",
"eslint": "^8.56.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-vue": "^9.32.0",
"less": "^4.3.0",
"prettier": "^3.5.2",
"unplugin-auto-import": "^19.2.0",
"unplugin-vue-components": "^28.5.0",
"vite-plugin-dts": "^4.5.3",
"vite-plugin-vue-setup-extend": "^0.4.0"
}
}

9704
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Sitemap: ./sitemap.xml

25
src/Root.vue Normal file
View File

@ -0,0 +1,25 @@
<template>
<b-e-flower-fall class="background"/>
<root-layout v-if="isReady" class="diselect" icp="粤ICP备2025368555号-1" domain="imyeyu.com" author="夜雨">
<router-view/>
</root-layout>
<popup/>
</template>
<script lang="ts" setup>
import { BEFlowerFall, Popup, SettingKey, SettingMapper } from "timi-web";
import { RootLayout } from "timi-tdesign-pc";
const isReady = ref(false);
onMounted(async () => {
await SettingMapper.loadSetting({ key: SettingKey.FMC_MAX_BIND });
isReady.value = true;
});
</script>
<style lang="less">
.background {
background: url("@/assets/img/main.png") fixed right bottom;
}
</style>

11
src/api/DeveloperAPI.ts Normal file
View File

@ -0,0 +1,11 @@
import { axios, Developer } from "timi-web";
const BASE_URI = "/git/developer";
async function update(developer: Developer): Promise<void> {
return axios.post(`${BASE_URI}/update`, developer);
}
export default {
update
};

20
src/api/MinecraftAPI.ts Normal file
View File

@ -0,0 +1,20 @@
import { axios } from "timi-web";
import { MinecraftPlayer } from "@/types/MinecraftPlayer.ts";
async function list(): Promise<MinecraftPlayer[]> {
return axios.post("/fmc/player/list");
}
async function bind(name: string): Promise<void> {
return axios.post("/fmc/player/bind", { name });
}
async function unbind(id: number): Promise<void> {
return axios.post("/fmc/player/unbind", { id });
}
export default {
list,
bind,
unbind
};

105
src/api/UserAPI.ts Normal file
View File

@ -0,0 +1,105 @@
import { axios, CommentBizType, CommentReplyView, CommentView, PageResult, SettingKey, SettingMapper, Toolkit, userStore } from "timi-web";
import { UserCommentPage, UserPrivacy, UserRequest } from "@/types/User.ts";
import { CommentReplyPage } from "../../../timi-web/src";
async function updateProfile(request: UserRequest): Promise<void> {
return axios({
method: "POST",
url: axios.defaults.baseURL + "/user/profile/update",
headers: {
"Content-Type": "multipart/form-data",
"Token": userStore.loginUser?.token?.value
},
data: Toolkit.toFormData(request)
});
}
/**
* 获取隐私控制
*
* @returns 隐私控制
*/
async function getPrivacy(): Promise<UserPrivacy> {
return axios.post("/user/privacy");
}
/**
* 更新隐私控制
*
* @param userPrivacy 隐私控制
* @returns true 为更新成功
*/
async function updatePrivacy(userPrivacy: UserPrivacy): Promise<boolean> {
return axios.post("/user/privacy/update", userPrivacy);
}
function getCommentTargetUrl(comment: CommentView) {
let url: string;
switch (comment.bizType) {
case CommentBizType.ARTICLE:
url = `${SettingMapper.getDomainLink(SettingKey.DOMAIN_BLOG)}/aid${comment.bizId}.html`;
break;
case CommentBizType.GIT_ISSUE:
url = `${SettingMapper.getDomainLink(SettingKey.DOMAIN_GIT)}/${(comment.repository as any).name}/issues/${comment.bizId}`;
break;
case CommentBizType.GIT_MERGE:
url = `${SettingMapper.getDomainLink(SettingKey.DOMAIN_GIT)}/${(comment.repository as any).name}/merges/${comment.bizId}`;
break;
}
return url;
}
async function listComment(page: UserCommentPage): Promise<PageResult<CommentView>> {
return axios.post("/user/comment/list", page);
}
/**
* 删除评论
*
* @param commentId 评论 ID
* @returns true 为删除成功
*/
async function deleteComment(commentId: number): Promise<boolean> {
return axios.post("/user/comment/delete", { commentId });
}
/**
* 获取评论下的回复
*
* @param page 回复分页
*/
async function listCommentReply(page: CommentReplyPage): Promise<PageResult<CommentReplyView>> {
return axios.post("/user/comment/reply/list", page);
}
/**
* 删除回复
*
* @param replyId 回复 ID
* @returns true 为删除成功
*/
async function deleteCommentReply(replyId: number): Promise<boolean> {
return axios.post("/user/comment/reply/delete", { replyId });
}
/**
* 忽略被回复
*
* @param replyId 回复 ID
* @returns true 为删除成功
*/
async function ignoreCommentReply(replyId: number): Promise<boolean> {
return axios.post("/user/comment/reply/ignore", { replyId });
}
export default {
updateProfile,
getPrivacy,
updatePrivacy,
getCommentTargetUrl,
listComment,
deleteComment,
listCommentReply,
deleteCommentReply,
ignoreCommentReply
};

BIN
src/assets/img/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -0,0 +1,611 @@
<template>
<div class="avatar-editor no-select">
<div class="header">
<div class="file">
<t-input class="name" type="text" v-model="fileName" placeholder="" readonly />
<t-button>选择文件</t-button>
<input ref="file" class="mask" type="file" accept="image/png" @change="onSelectedFile($event)" />
</div>
<t-button
class="reset"
theme="default"
@click="reset()"
>还原默认</t-button>
<t-button
class="cancel"
v-show="avatarAttr.isLoad"
theme="default"
@click="cancel()"
>取消</t-button>
</div>
<div class="content" v-show="avatarAttr.isLoad">
<!-- 编辑器 -->
<div ref="editor" class="editor">
<img ref="avatar" class="img" :class="(<any>ImageType)[rendererType]" :src="avatarAttr.data" />
<div ref="selector" class="selector" v-draggable="selectorConfig">
<div class="corner lt" v-draggable="selectorConfigLT"></div>
<div class="corner rt" v-draggable="selectorConfigRT"></div>
<div class="corner lb" v-draggable="selectorConfigLB"></div>
<div class="corner rb" v-draggable="selectorConfigRB"></div>
</div>
</div>
<div class="preview">
<!-- 预览 -->
<div class="item x128">
<img class="img" :class="(<any>ImageType)[rendererType]" :style="previewCSS128" :src="avatarAttr.data" />
</div>
<div class="item x64">
<img class="img" :class="(<any>ImageType)[rendererType]" :style="previewCSS64" :src="avatarAttr.data" />
</div>
</div>
</div>
<canvas ref="canvas" class="canvas" width="256" height="256" hidden></canvas>
</div>
</template>
<script lang="ts" setup>
import { CommonAPI, ImageType, IOSize, PublicResources, SettingKey, SettingMapper } from "timi-web";
// _________________256px___________________
// | |<--- 编辑器容器
// | |
// | Avatar Editor |
// |1_______________________________________2|
// || ___________________ |<---- 图像
// || | | ||
// || | |<-------------- 选区
// || | | ||
// || Image | Selector | || 256px
// || | | ||
// || | | ||
// || |___________________| ||
// ||_______________________________________||
// |3 4|
// | |
// | |
// |_________________________________________|
//
// 注释:
// 1. 相对于编辑器的图像左上端点 minX, minY <-- 相对于编辑器,不是选区限位
// 2. 相对于编辑器的图像右上端点 maxX, minY
// 3. 相对于编辑器的图像左下端点 minX, maxY
// 4. 相对于编辑器的图像右下端点 maxX, maxY
//
// 头像属性
type AvatarAttr = {
// 端点位置坐标(相对于编辑器)
minX: number;
minY: number;
maxX: number;
maxY: number;
width: number; // 图像实际宽度
height: number; // 图像实际高度
scale: number; // 从实际尺寸渲染到编辑器的缩放
isHorizontal?: boolean;
isVertical?: boolean;
isSquare?: boolean; // 是否正方形
data?: string;
name?: string;
isLoad: boolean;
}
// 1_________________________________________
// | 2,5___________________ | <-- 实际图像
// | | | |
// | | | |
// | Image | |<------ 选区,此时选区最大化选择图像右侧
// | | Selector |6 |7
// | | | |
// | | | |
// | 4___________________| |
// |_________________________________________|
// 3
//
// 注释:
// 1. 相对于编辑器的选区限位左上端点 minX, minY限第 5 点的实时坐标)
// 2. 相对于编辑器的选区限位右上端点 maxX, minY
// 3. 相对于编辑器的选区限位左下端点 minX, maxY
// 4. 相对于编辑器的选区限位右下端点 maxX, maxY
// 5. 选区实时所在坐标 x, y相对于编辑器
// 6. 选区实时尺寸 size
// **. 选区触发坐标尺寸调整触发坐标 xy 为相对于选区位置5比如选区最下角就是 [0, 选区高度]
//
// 选区属性
type SelectorAttr = {
x: number;
y: number;
size: number;
minX: number;
minY: number;
maxX: number;
maxY: number;
maxSize: number;
}
/** 选区对预览区(包括渲染区 - Canvas 画板)的坐标和缩放参数 */
type SelectorScaleAttr = {
x: number;
y: number;
scale: number;
}
// props
const props = withDefaults(defineProps<{
rendererType?: ImageType
}>(), {
rendererType: ImageType.PIXELATED
});
const { rendererType } = toRefs(props);
// ref
const img = ref<HTMLImageElement>();
const file = ref<HTMLInputElement>();
const canvas = ref<HTMLCanvasElement>();
const editor = ref<HTMLElement>();
const avatar = ref<HTMLImageElement>();
const selector = ref<HTMLElement>();
const fileName = ref<string>("");
const avatarAttr = reactive<AvatarAttr>({
minX: 0,
minY: 0,
maxX: 0,
maxY: 0,
width: 0,
height: 0,
scale: 1,
isLoad: false
});
const selectorAttr = reactive<SelectorAttr>({
x: 0,
y: 0,
size: 0,
minX: 0,
minY: 0,
maxX: 0,
maxY: 0,
maxSize: 0
});
// computed
const previewCSS64 = computed(() => {
const r = getPreviewCSS(64);
return `top: ${r.y}px; left: ${r.x}px; transform: scale(${r.scale})`;
});
const previewCSS128 = computed(() => {
const r = getPreviewCSS(128);
return `top: ${r.y}px; left: ${r.x}px; transform: scale(${r.scale})`;
});
const selectorConfig = computed(() => {
return {
// 点击选区时忽略本事件
interruptWhen: (e: MouseEvent) => (e.target as HTMLElement).classList.contains("corner"),
onDragging: (_e: MouseEvent, px: number, py: number) => {
// X 轴限位
if (px < selectorAttr.minX) {
selectorAttr.x = selectorAttr.minX;
} else if (selectorAttr.maxX < px) {
selectorAttr.x = selectorAttr.maxX;
} else {
selectorAttr.x = px;
}
// Y 轴限位
if (py < selectorAttr.minY) {
selectorAttr.y = selectorAttr.minY;
} else if (selectorAttr.maxY < py) {
selectorAttr.y = selectorAttr.maxY;
} else {
selectorAttr.y = py;
}
if (selector.value) {
selector.value.style.top = selectorAttr.y + "px";
selector.value.style.left = selectorAttr.x + "px";
}
}
};
});
const selectorConfigLT = computed(() => {
let x, y, width;
let oldAttr: SelectorAttr;
return {
onMouseDown: () => oldAttr = { ...selectorAttr },
onDragging: (_e: MouseEvent, _px: number, _py: number, dx: number, dy: number) => {
if (!selector.value) {
return;
}
width = Math.max(oldAttr.size - dx, oldAttr.size - dy, 24);
// 限尺寸
if (oldAttr.x + oldAttr.size - width < avatarAttr.minX || oldAttr.y + oldAttr.size - width < avatarAttr.minY) {
width = Math.min(oldAttr.x + oldAttr.size - avatarAttr.minX, oldAttr.y + oldAttr.size - avatarAttr.minY);
}
x = oldAttr.x + oldAttr.size - width;
y = oldAttr.y + oldAttr.size - width;
selector.value.style.left = x + "px";
selector.value.style.top = y + "px";
selector.value.style.width = selector.value.style.height = width + "px";
// 更新限位
selectorAttr.x = x;
selectorAttr.y = y;
selectorAttr.size = width;
selectorAttr.maxX = avatarAttr.maxX - width;
selectorAttr.maxY = avatarAttr.maxY - width;
}
};
});
const selectorConfigRT = computed(() => {
let y, size;
let oldAttr: SelectorAttr;
return {
onMouseDown: () => oldAttr = { ...selectorAttr },
onDragging: (_e: MouseEvent, _px: number, _py: number, dx: number, dy: number) => {
if (!selector.value) {
return;
}
size = Math.max(oldAttr.size + dx, oldAttr.size - dy, 24);
// 限尺寸
if (oldAttr.y + oldAttr.size - size < avatarAttr.minY || avatarAttr.maxX < oldAttr.x + size) {
size = Math.min(oldAttr.y + oldAttr.size - avatarAttr.minY, avatarAttr.maxX - selector.value.offsetLeft);
}
y = oldAttr.y + oldAttr.size - size;
selector.value.style.top = y + "px";
selector.value.style.width = selector.value.style.height = size + "px";
// 更新限位
selectorAttr.y = y;
selectorAttr.size = size;
selectorAttr.maxX = avatarAttr.maxX - size;
selectorAttr.maxY = avatarAttr.maxY - size;
}
};
});
const selectorConfigLB = computed(() => {
let x, size;
let oldAttr: SelectorAttr;
return {
onMouseDown: () => oldAttr = { ...selectorAttr },
onDragging: (_e: MouseEvent, _px: number, _py: number, dx: number, dy: number) => {
if (!selector.value) {
return;
}
size = Math.max(oldAttr.size - dx, oldAttr.size + dy, 24);
// 限尺寸
if (oldAttr.x + oldAttr.size - size < avatarAttr.minX || avatarAttr.maxY < oldAttr.y + size) {
size = Math.min(oldAttr.x + oldAttr.size - avatarAttr.minX, avatarAttr.maxY - selector.value.offsetTop);
}
x = oldAttr.x + oldAttr.size - size;
selector.value.style.left = x + "px";
selector.value.style.width = selector.value.style.height = size + "px";
// 更新限位
selectorAttr.x = x;
selectorAttr.size = size;
selectorAttr.maxX = avatarAttr.maxX - size;
selectorAttr.maxY = avatarAttr.maxY - size;
}
};
});
const selectorConfigRB = computed(() => {
let size;
return {
onDragging: (_e: MouseEvent, px: number, py: number) => {
if (!selector.value) {
return;
}
px += 10;
py += 10;
// 相对选区最大偏移
size = Math.max(px, py, 24);
// 计算相对编辑器最大偏移
const maxX = selector.value.offsetLeft + size;
const maxY = selector.value.offsetTop + size;
if (avatarAttr.maxX < maxX || avatarAttr.maxY < maxY) {
size = Math.min(avatarAttr.maxX - selector.value.offsetLeft, avatarAttr.maxY - selector.value.offsetTop);
}
selector.value.style.width = selector.value.style.height = size + "px";
// 更新限位
selectorAttr.size = size;
selectorAttr.maxX = avatarAttr.maxX - size;
selectorAttr.maxY = avatarAttr.maxY - size;
}
};
});
// method
const getPreviewCSS = (previewSize: number): SelectorScaleAttr => {
const selectorScale = selectorAttr.maxSize / selectorAttr.size;
// 预览区缩放比
const previewScale = previewSize / (avatarAttr.isHorizontal ? avatarAttr.height : avatarAttr.width);
let x = 0;
if (selectorAttr.maxX !== selectorAttr.minX) {
// 选区限位最大值和最小值不能相等,因为要相减作为除数计算 X 轴移动百分比(如果相等说明选区 X 轴完全覆盖,不可移动)
const movePercent = (selectorAttr.x - selectorAttr.minX) / (selectorAttr.maxX - selectorAttr.minX);
x = movePercent * (avatarAttr.width * (previewScale * selectorScale) - previewSize);
}
let y = 0;
if (selectorAttr.maxY !== selectorAttr.minY) {
const movePercent = (selectorAttr.y - selectorAttr.minY) / (selectorAttr.maxY - selectorAttr.minY);
y = movePercent * (avatarAttr.height * (previewScale * selectorScale) - previewSize);
}
return { x: -x, y: -y, scale: previewScale * selectorScale };
};
const onSelectedFile = (event: any) => {
const file = event.target.files[0];
if (file) {
avatarAttr.name = file.name;
if (file.type === "image/png") {
if (file.size <= IOSize.MB * 16) {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
if (reader.readyState === 2) {
if (reader.result) {
const data = reader.result as string;
parseImageData(data);
fileName.value = file.name;
return;
}
}
};
}
}
}
};
/**
* 解析图片到编辑器
*
* @param data 可以是地址也可以是 Base64 数据
*/
const parseImageData = (data: string) => {
avatarAttr.data = data;
const newImg = new Image();
newImg.crossOrigin = "anonymous";
newImg.src = data;
newImg.onload = async () => {
if (!selector.value || !avatar.value) {
return;
}
// 基本属性
avatarAttr.width = newImg.width;
avatarAttr.height = newImg.height;
avatarAttr.isHorizontal = newImg.height < newImg.width;
avatarAttr.isVertical = newImg.height < newImg.width;
avatarAttr.isSquare = !avatarAttr.isHorizontal && !avatarAttr.isVertical;
// 选区限位
if (avatarAttr.isHorizontal) {
avatarAttr.scale = 256 / avatarAttr.width;
// 显示图像的端点坐标
avatarAttr.minX = 0;
avatarAttr.maxX = 256;
avatarAttr.minY = 128 - avatarAttr.height * avatarAttr.scale * .5;
avatarAttr.maxY = 128 + avatarAttr.height * avatarAttr.scale * .5;
// 选区端点
selectorAttr.minX = 0;
selectorAttr.minY = avatarAttr.minY;
selectorAttr.maxX = 256 - avatarAttr.height * avatarAttr.scale;
selectorAttr.maxY = avatarAttr.minY;
} else {
avatarAttr.scale = 256 / avatarAttr.height;
// 显示图像的端点坐标
avatarAttr.minX = 128 - avatarAttr.width * avatarAttr.scale * .5;
avatarAttr.maxX = 128 + avatarAttr.width * avatarAttr.scale * .5;
avatarAttr.minY = 0;
avatarAttr.maxY = 256;
// 选区端点
selectorAttr.minX = avatarAttr.minX;
selectorAttr.minY = 0;
selectorAttr.maxX = avatarAttr.minX;
selectorAttr.maxY = 256 - avatarAttr.width * avatarAttr.scale;
}
avatarAttr.isLoad = true;
await nextTick();
// 图片尺寸
avatar.value.style.width = "auto";
avatar.value.style.height = "auto";
if ((avatarAttr.isSquare || avatarAttr.isHorizontal) && avatarAttr.width < 256) {
avatar.value.style.width = "100%";
}
if ((avatarAttr.isSquare || avatarAttr.isVertical) && avatarAttr.height < 256) {
avatar.value.style.height = "100%";
}
// 选区属性
let size;
if (avatarAttr.isHorizontal) {
size = avatarAttr.height * avatarAttr.scale;
selectorAttr.x = selectorAttr.maxX * .5;
selectorAttr.y = selectorAttr.minY;
} else {
size = avatarAttr.width * avatarAttr.scale;
selectorAttr.x = selectorAttr.minX;
selectorAttr.y = selectorAttr.maxY * .5;
}
selectorAttr.maxSize = selectorAttr.size = size;
selector.value.style.top = selectorAttr.x + "px";
selector.value.style.left = selectorAttr.y + "px";
selector.value.style.width = size + "px";
selector.value.style.height = size + "px";
};
img.value = newImg;
};
/** 还原默认头像(虽然是获取默认头像,但实际修改的还是专有头像) */
const reset = async () => {
const res = JSON.parse(SettingMapper.getValue(SettingKey.PUBLIC_RESOURCES) as string) as PublicResources;
parseImageData(CommonAPI.getAttachmentReadAPI(res.user.avatar));
fileName.value = "default.png";
};
/** 取消编辑 */
const cancel = () => {
avatarAttr.isLoad = false;
fileName.value = "";
img.value = undefined;
if (file.value) {
file.value.value = "";
}
};
/** @returns 回调头像编辑结果Base64 数据),为 null 时表示没有更新头像 */
const getValue = (): string | null => {
if (img.value && avatarAttr.isLoad && canvas.value) {
const r = getPreviewCSS(256);
const ctx = canvas.value.getContext("2d");
if (ctx) {
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img.value, r.x, r.y, img.value.width * r.scale, img.value.height * r.scale);
return canvas.value.toDataURL();
}
}
return null;
};
defineExpose({
getValue,
cancel
});
</script>
<style scoped>
.avatar-editor {
width: 100%;
.header {
display: flex;
.file {
width: 80%;
display: flex;
position: relative;
.mask {
width: 100%;
height: 100%;
opacity: 0;
position: absolute;
}
}
.reset,
.cancel {
margin-left: 1rem;
}
}
.content {
display: flex;
margin-top: 1rem;
.editor {
width: 256px;
height: 256px;
border: var(--tui-border);
display: flex;
position: relative;
overflow: hidden;
max-width: 256px;
max-height: 256px;
align-items: center;
margin-right: 12px;
justify-content: center;
background-size: 30px 30px;
background-image:
linear-gradient(45deg, #E5E5E5 25%, transparent 0, transparent 75%, #E5E5E5 0),
linear-gradient(45deg, #E5E5E5 25%, transparent 0, transparent 75%, #E5E5E5 0);
background-position: 0 0, 15px 15px;
.img {
position: absolute;
max-width: 100%;
max-height: 100%;
pointer-events: none;
}
.selector {
cursor: all-scroll;
outline: 256px solid rgba(0, 0, 0, .2);
position: absolute;
&:before {
content: "";
width: calc(100% - 2px);
height: calc(100% - 2px);
border: 1px solid #FFF;
position: absolute;
}
.corner {
width: 8px;
height: 8px;
border: 1px solid #FFF;
position: absolute;
background: rgba(0, 0, 0, .4);
&.lt {
top: 0;
left: 0;
cursor: nw-resize;
}
&.rt {
top: 0;
right: 0;
cursor: ne-resize;
}
&.lb {
left: 0;
bottom: 0;
cursor: ne-resize;
}
&.rb {
right: 0;
bottom: 0;
cursor: nw-resize;
}
}
}
}
.preview {
.item {
border: var(--tui-border);
overflow: hidden;
position: relative;
&.x128 {
width: 128px;
height: 128px;
max-width: 128px;
max-height: 128px;
margin-bottom: 12px;
}
&.x64 {
width: 64px;
height: 64px;
max-width: 64px;
max-height: 64px;
}
img {
position: absolute;
transform-origin: top left;
}
}
}
}
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<div v-if="value" class="profile-visitor">
<t-form>
<t-form-item v-if="level" label="等级" name="level">
<user-level class="level-icon" :value="level.value" />
</t-form-item>
<t-form-item v-if="value.email" label="邮箱" name="email">
<div v-text="value.email"></div>
</t-form-item>
<t-form-item v-if="value.profile.sex" label="性别" name="sex">
<icon
:class="{ girl: isGirl, boy: isBoy }"
:name="isBoy ? 'BOY' : 'GIRL'"
:fill="`var(--tui-${isBoy ? 'blue' : 'pink'})`"
/>
</t-form-item>
<t-form-item v-if="value.profile.birthdate" label="出生日期" name="birthdate">
<div v-text="Time.toDateTime(value.profile.birthdate)"></div>
</t-form-item>
<t-form-item v-if="value.profile.qq" label="QQ" name="qq">
<div v-text="value.profile.qq"></div>
</t-form-item>
<t-form-item label="注册时间" name="createdAt">
<div v-text="Time.toDateTime(value.createdAt)"></div>
</t-form-item>
</t-form>
</div>
</template>
<script lang="ts" setup>
import { Icon, Time, Toolkit, UserLevel } from "timi-web";
import { UserView } from "timi-web";
const props = withDefaults(defineProps<{
value?: UserView
}>(), {});
const { value } = toRefs(props);
const level = computed(() => Toolkit.toUserLevel(value.value?.profile.exp));
const isBoy = computed(() => value.value?.profile.sex === 1);
const isGirl = computed(() => value.value?.profile.sex === 0);
</script>
<style lang="less" scoped>
</style>

View File

@ -0,0 +1,128 @@
<template>
<div class="wrapper-selector">
<div class="header">
<div class="file">
<t-input class="name" type="text" :value="fileName" placeholder="" readonly />
<t-button>选择文件</t-button>
<input ref="file" class="mask" type="file" accept="image/png" @change="onSelectedFile($event)" />
</div>
<t-button
class="reset"
theme="default"
@click="reset"
>还原默认</t-button>
<t-button
v-show="fileName"
class="cancel"
theme="default"
@click="cancel"
>取消</t-button>
</div>
<img v-if="previewSrc" class="preview" :src="previewSrc" alt="preview" />
<canvas ref="canvas" class="canvas" hidden></canvas>
</div>
</template>
<script lang="ts" setup>
import { CommonAPI, IOSize, SettingKey, SettingMapper } from "timi-web";
const img = ref<HTMLImageElement>();
const file = ref<HTMLInputElement>();
const canvas = ref<HTMLCanvasElement>();
const fileName = ref<string>();
const previewSrc = ref();
const onSelectedFile = (event: any) => {
const file = event.target.files[0];
if (file && file.type === "image/png") {
if (file.size <= IOSize.MB * 2) {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
if (reader.readyState === 2 && reader.result) {
img.value = new Image();
img.value.crossOrigin = "anonymous";
img.value.src = previewSrc.value = reader.result as string;
fileName.value = file.name;
}
};
}
}
};
const reset = async () => {
const value = SettingMapper.getValue(SettingKey.PUBLIC_RESOURCES);
if (value) {
const res = JSON.parse(value);
img.value = new Image();
img.value.crossOrigin = "anonymous";
img.value.src = previewSrc.value = CommonAPI.getAttachmentReadAPI(res.DEFAULT_WRAPPER);
fileName.value = "default.png";
}
};
/** 取消编辑 */
const cancel = () => {
fileName.value = "";
img.value = previewSrc.value = undefined;
if (file.value) {
file.value.value = "";
}
};
/** @returns 回调头像编辑结果Base64 数据),为 null 时表示没有更新头像 */
const getValue = (): string | null => {
if (img.value && canvas.value) {
canvas.value.width = img.value.width;
canvas.value.height = img.value.height;
const ctx = canvas.value.getContext("2d");
if (ctx) {
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img.value, 0, 0, img.value.width, img.value.height);
return canvas.value.toDataURL();
}
}
return null;
};
defineExpose({
getValue,
cancel
});
</script>
<style lang="less" scoped>
.wrapper-selector {
width: 100%;
.header {
width: 100%;
display: flex;
.file {
width: 80%;
display: flex;
position: relative;
.mask {
width: 100%;
height: 100%;
opacity: 0;
position: absolute;
}
}
.reset,
.cancel {
margin-left: 1rem;
}
}
.preview {
width: 100%;
margin-top: 1rem;
}
}
</style>

View File

@ -0,0 +1,88 @@
<template>
<div class="space-comment-layout">
<t-tabs class="menu" placement="left" v-model="tabValue">
<t-tab-panel :value="Tab.COMMENT" label="我的评论" />
<t-tab-panel :value="Tab.REPLY_BY_SENDER" label="我的回复" />
<t-tab-panel :value="Tab.REPLY_BY_RECEIVER" label="回复我的" />
</t-tabs>
<div class="content">
<router-view />
</div>
</div>
</template>
<script lang="ts" setup>
import { useVisitUserStore } from "@/store/visitUser.ts";
enum Tab {
COMMENT = "CommentList",
REPLY_BY_SENDER = "ReplyBySenderList",
REPLY_BY_RECEIVER = "ReplyByReceiverList"
}
const regexMenu: {
[key: string]: Tab
} = {
"CommentList|CommentLayout": Tab.COMMENT,
"ReplyBySenderList": Tab.REPLY_BY_SENDER,
"ReplyByReceiverList": Tab.REPLY_BY_RECEIVER
};
const route = useRoute();
const router = useRouter();
const userStore = useVisitUserStore();
const tabValue = ref<Tab>();
const syncMenuRouter = (routerName?: string) => {
if (routerName) {
for (const item in regexMenu) {
if (new RegExp(item).test(routerName as string)) {
tabValue.value = regexMenu[item];
break;
}
}
}
};
watch(() => route.name, (routerName) => syncMenuRouter(routerName as string));
watch(tabValue, async () => {
await router.push({
name: tabValue.value,
params: {
id: userStore.visitUserView?.id
}
});
});
onMounted(async () => {
syncMenuRouter(route.name as string);
});
</script>
<style lang="less" scoped>
.space-comment-layout {
display: flex;
border-bottom: var(--tui-border);
.menu {
display: flex;
min-width: 10rem;
justify-content: end;
:deep(.t-tabs__nav-container) {
padding-top: 2rem;
&::after {
display: none;
}
}
}
.content {
width: calc(100% - 10rem);
min-height: 520px;
border-left: var(--tui-border);
}
}
</style>

313
src/layout/IndexLayout.vue Normal file
View File

@ -0,0 +1,313 @@
<template>
<div class="space-layout">
<loading :show-on="!userView" />
<div v-if="userView" class="header">
<div class="info">
<t-image
class="wrapper"
:class="(<any>ImageType)[userView.profile.wrapperType]"
:src="UserAPI.getWrapperURL(userView.profile)"
/>
<div class="base-info">
<t-image
class="avatar"
:class="(<any>ImageType)[userView.profile.avatarType]"
:src="UserAPI.getAvatarURL(userView.profile)"
/>
<div class="text selectable">
<h4 class="name" v-text="userView.name"></h4>
<p v-if="userView.profile.description" class="description" v-text="userView.profile.description"></p>
</div>
</div>
<div class="action">
<t-button
v-if="!userStore.isLogged()"
class="button login"
theme="default"
@click="visitableAuth = true"
>登录</t-button>
<!-- TODO 加已登录头像 -->
<t-button
v-if="userStore.isLogged() && visitUserStore.isVisitor"
class="button index"
theme="default"
@click="toIndex"
>个人首页</t-button>
<t-button
v-if="userStore.isLogged()"
class="button logout"
theme="default"
@click="logout()"
>退出登录</t-button>
</div>
</div>
<div class="menu">
<t-tabs class="tab" v-model="tabValue">
<t-tab-panel :value="Tab.PROFILE" label="账号中心" />
<t-tab-panel :value="Tab.PRIVACY" label="隐私" />
<t-tab-panel :value="Tab.FOREVER_MC" label="ForeverMC" />
<t-tab-panel :value="Tab.DEVELOPER" label="开发者" />
<t-tab-panel :value="Tab.COMMENT" label="评论" />
</t-tabs>
</div>
</div>
<router-view />
</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 { ImageType, Loading, UserAPI, userStore } from "timi-web";
import { AuthorizeForm } from "timi-tdesign-pc";
import { useVisitUserStore } from "@/store/visitUser.ts";
enum Tab {
PROFILE = "ProfileInfo",
PRIVACY = "PrivacyInfo",
FOREVER_MC = "ForeverMC",
DEVELOPER = "Developer",
COMMENT = "CommentLayout"
}
const regexMenu: {
[key: string]: Tab
} = {
"ProfileInfo|SpaceLayout": Tab.PROFILE,
"PrivacyInfo": Tab.PRIVACY,
"ForeverMC": Tab.FOREVER_MC,
"Developer": Tab.DEVELOPER,
"CommentLayout|CommentList|ReplyBySenderList|ReplyByReceiverList": Tab.COMMENT
};
const route = useRoute();
const router = useRouter();
const visitUserStore = useVisitUserStore();
const tabValue = ref<Tab>();
const visitableAuth = ref<boolean>(false);
const userView = computed(() => visitUserStore.visitUserView);
watch(() => route.name, (routerName) => syncMenuRouter(routerName as string));
watch(tabValue, async () => {
await router.push({
name: tabValue.value,
params: {
id: visitUserStore.visitUserView?.id
}
});
});
const toIndex = async () => {
await router.push({
name: "ProfileInfo",
params: {
id: userStore.loginUser.user?.id
}
});
await visitUserStore.updateVisitUserView(route.params.id as any as number);
};
const logout = () => {
userStore.logout();
router.push("/");
};
const syncMenuRouter = (routerName?: string) => {
if (routerName) {
for (const item in regexMenu) {
if (new RegExp(item).test(routerName as string)) {
tabValue.value = regexMenu[item];
break;
}
}
}
};
onMounted(async () => {
syncMenuRouter(route.name as string);
await visitUserStore.updateVisitUserView(route.params.id as any as number);
});
</script>
<style lang="less" scoped>
.space-layout {
.header {
top: -200px;
margin: 0;
z-index: 4;
position: sticky;
transition: top 500ms var(--tui-bezier);
align-items: center;
.info {
width: 100%;
position: relative;
.wrapper {
pointer-events: none;
}
.base-info {
bottom: -30px;
display: flex;
position: absolute;
transition: 500ms var(--tui-bezier);
margin-left: 3rem;
.avatar {
width: 128px;
height: 128px;
padding: 3px;
min-width: 128px;
min-height: 128px;
transition: 500ms var(--tui-bezier);
background: #CCC;
pointer-events: none;
}
.text {
color: #FFF;
z-index: 1;
transition: 500ms var(--tui-bezier);
padding-left: 1rem;
.name {
width: fit-content;
margin: 1rem 0;
padding: 2px 4px;
min-width: fit-content;
transition: 500ms var(--tui-bezier);
background: rgba(0, 0, 0, .6);
border-radius: 2px;
}
.description {
margin: 0 12px 0 0;
padding: 2px 4px;
display: -webkit-box;
overflow: hidden;
transition: 500ms var(--tui-bezier);
background: rgba(0, 0, 0, .4);
border-radius: 2px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
}
.action {
top: 2rem;
right: 1rem;
z-index: 2;
position: absolute;
.button {
opacity: 0;
transition: 250ms;
visibility: hidden;
margin-right: 1rem;
}
}
&:hover {
.action {
.button {
opacity: 1;
transition: 250ms;
visibility: visible;
}
}
}
}
.menu {
z-index: 5;
position: relative;
visibility: v-bind("visitUserStore.isVisitor ? 'hidden' : 'visible'");
transition: 500ms var(--tui-bezier);
background: rgba(255, 255, 255, .8);
margin-left: calc(3rem + 3px + 128px + 3px);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
.tab {
background: transparent;
:deep(.t-tabs__operations) {
border-bottom: none;
}
}
}
}
}
@media screen and (max-width: 1920px) {
.space-layout {
.header {
top: -140px;
transition: 500ms var(--tui-bezier);
}
}
}
@media screen and (max-width: 720px) {
.space-layout {
.header {
top: -60px;
transition: 500ms var(--tui-bezier);
.info {
.base-info {
margin-left: 2rem;
.avatar {
width: 64px;
height: 64px;
min-width: 64px;
min-height: 64px;
}
.text {
display: flex;
align-items: flex-start;
padding-left: .5rem;
.name {
margin: 0;
}
.description {
margin: 0 0 0 1rem;
-webkit-line-clamp: 1;
}
}
}
}
.menu {
margin-left: calc(2rem + 3px + 64px + 3px);
}
}
}
}
</style>

87
src/main.ts Normal file
View File

@ -0,0 +1,87 @@
import { createApp } from "vue";
import router from "@/router";
import type { NavigationGuardNext, RouteLocationNormalized } from "vue-router";
import { axios, Network, userStore, VDraggable, VPopup } from "timi-web";
import { DialogPlugin } from "tdesign-vue-next";
import Root from "@/Root.vue";
import "tdesign-vue-next/dist/tdesign.css";
import "timi-tdesign-pc/style.css";
import "timi-web/style.css";
console.log(`
______ __ _ _
/ __\\ \\ \\ \\ \\
/ . . \\ ' \\ \\ \\ \\
( ) imyeyu.com ) ) ) )
'\\ ___ /' / / / /
====='===='=====================/_/_/_/
`);
// ---------- axios 网络请求 ----------
axios.defaults.baseURL = import.meta.env.VITE_API;
axios.interceptors.request.use(Network.userTokenInterceptors);
// ---------- Router 路由 ----------
router.beforeEach(async (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext): Promise<void> => {
await userStore.login4Storage();
const isLogged = userStore.isLogged();
const visitUserId = to.params.id;
const currentUserId = userStore.loginUser.user?.id;
// 处理已登录用户的路由
if (isLogged) {
// 访问其他用户页面时,只允许查看其首页
if (visitUserId && Number(visitUserId) !== currentUserId && to.name !== "ProfileInfo") {
next({
name: "ProfileInfo",
params: { id: visitUserId }
});
return;
}
// 空间首页重定向到个人信息页
if (to.name === "SpaceIndex") {
next({
name: "ProfileInfo",
params: { id: currentUserId }
});
return;
}
} else if (to.name !== "ProfileInfo" && to.name !== "SpaceIndex") {
// 处理未登录用户的路由
next({ name: "SpaceIndex" });
return;
}
// 有效路由
if (to.matched.length !== 0) {
next();
return;
}
// 无效路由
if (from.name) {
next({ name: from.name });
return;
}
const alert = DialogPlugin.alert({
header: "错误404",
body: "找不到该页面",
confirmBtn: {
content: "关闭",
variant: "base",
theme: "default"
},
onConfirm: () => {
alert.destroy();
next("/");
}
});
});
// ---------- Vue ----------
const app = createApp(Root);
app.use(createPinia());
app.use(router);
app.directive("draggable", VDraggable as any);
app.directive("popup", VPopup as any);
app.mount("#root");

69
src/router/index.ts Normal file
View File

@ -0,0 +1,69 @@
import { createRouter, createWebHistory } from "vue-router";
export default createRouter({
history: createWebHistory("/"),
routes: [
{
path: "",
name: "SpaceIndex",
component: () => import("@/views/SpaceIndex.vue")
},
{
path: "/:id",
name: "IndexLayout",
component: () => import("@/layout/IndexLayout.vue"),
children: [
{
path: "",
alias: "data",
name: "ProfileInfo",
component: () => import("@/views/ProfileInfo.vue"),
meta: {
keepAlive: true
}
},
{
path: "forevermc",
name: "ForeverMC",
component: () => import("@/views/ForeverMC.vue")
},
{
path: "privacy",
name: "PrivacyInfo",
component: () => import("@/views/PrivacyInfo.vue")
},
{
path: "developer",
name: "Developer",
component: () => import("@/views/Developer.vue"),
meta: {
keepAlive: true
}
},
{
path: "comment/",
name: "CommentLayout",
component: () => import("@/layout/CommentLayout.vue"),
children: [
{
path: "",
alias: "list",
name: "CommentList",
component: () => import("@/views/comment/CommentList.vue")
},
{
path: "reply/sender",
name: "ReplyBySenderList",
component: () => import("@/views/comment/ReplyBySenderList.vue")
},
{
path: "reply/receiver",
name: "ReplyByReceiverList",
component: () => import("@/views/comment/ReplyByReceiverList.vue")
}
]
}
]
}
]
});

43
src/store/visitUser.ts Normal file
View File

@ -0,0 +1,43 @@
import { defineStore } from "pinia";
import { UserAPI, userStore, UserView } from "timi-web";
const store = defineStore("visitUser", () => {
const visitUserView = ref<UserView>();
const isVisitor = computed(() => {
const loginUser = userStore.loginUser;
return !userStore.isLogged() || !loginUser.token || !visitUserView.value || loginUser.token.id !== visitUserView.value.id;
});
async function updateVisitUserView(id: number): Promise<void> {
if (visitUserView.value && visitUserView.value.id === id) {
return;
}
if (!visitUserView.value && userStore.loginUser.user?.id === Number(id)) {
visitUserView.value = userStore.loginUser.user;
return;
}
visitUserView.value = await UserAPI.view(id);
}
async function reloadVisitUserView(): Promise<void> {
if (visitUserView.value) {
const id = visitUserView.value.id as number;
visitUserView.value = undefined;
await updateVisitUserView(id);
}
}
return {
isVisitor,
visitUserView,
updateVisitUserView,
reloadVisitUserView
};
});
export function useVisitUserStore() {
return store();
}

View File

@ -0,0 +1,7 @@
import { Model } from "timi-web";
export type MinecraftPlayer = {
playerId: number;
name: string;
lastLoginAt: number;
} & Model

28
src/types/User.ts Normal file
View File

@ -0,0 +1,28 @@
import { Page, User, UserProfile } from "timi-web";
export type UserRequest = {
profile: {
avatar?: File;
wrapper?: File;
} & UserProfile
} & User
export type UserPrivacy = {
userId: number;
email?: boolean;
sex?: boolean;
birthdate?: boolean;
qq?: boolean;
lastLoginAt?: boolean;
createdAt?: boolean;
updatedAt?: number;
}
export enum UserCommentBizType {
ARTICLE = "博客文章",
GIT_ISSUE = "开源站 - 反馈",
GIT_MERGE = "开源站 - 合并请求"
}
export type UserCommentPage = {
} & Page;

105
src/views/Developer.vue Normal file
View File

@ -0,0 +1,105 @@
<template>
<div class="developer">
<loading :show-on="!developer" />
<t-alert v-if="developer" class="top-tips" :theme="developer.rsa ? 'success' : 'info'">
<template #message>
<span v-if="developer.rsa"></span>
<span>完善开发者信息可参与 </span>
<a :href="gitURL" v-text="gitURL"></a>
<span> 的开源项目开发</span>
</template>
</t-alert>
<t-form v-if="developer" class="form" :data="developer" @submit="doSubmit">
<t-form-item class="name" label="开发者" name="name">
<t-input class="input" v-model="developer.name" placeholder="">
<template #tips>
<div class="tips gray markdown selectable">
<span>使用 </span>
<code>git config user.name</code>
<span> 命令获取</span>
</div>
</template>
</t-input>
</t-form-item>
<t-form-item class="rsa" label="RSA 公钥" name="rsa">
<t-textarea
class="value break-all"
v-model="developer.rsa"
:autosize="{ minRows: 6, maxRows: 10 }"
>
</t-textarea>
</t-form-item>
<markdown-view :content="rsaTips" />
<t-button class="submit" type="submit">保存</t-button>
</t-form>
<markdown-view :content="tips" />
</div>
</template>
<script lang="ts" setup>
import {
CommonAPI,
Developer,
DeveloperAPI as BaseDeveloperAPI,
Loading,
MarkdownView,
SettingKey,
SettingMapper,
TemplateBizType
} from "timi-web";
import { MessagePlugin } from "tdesign-vue-next";
import DeveloperAPI from "@/api/DeveloperAPI.ts";
const gitURL = ref(`https://${SettingMapper.getValue(SettingKey.DOMAIN_GIT)}`);
const tips = ref();
const rsaTips = ref();
const developer = ref<Developer>();
const doSubmit = () => {
if (developer.value) {
DeveloperAPI.update(developer.value).then(() => {
MessagePlugin.success("保存成功");
}).catch(msg => {
MessagePlugin.error("保存失败:" + msg);
});
}
};
onMounted(async () => {
developer.value = await BaseDeveloperAPI.get();
rsaTips.value = await CommonAPI.getTemplate(TemplateBizType.GIT, "DEVELOPER_RSA");
tips.value = await CommonAPI.getTemplate(TemplateBizType.GIT, "DEVELOPER");
});
</script>
<style lang="less" scoped>
.developer {
padding: 2rem;
display: flex;
align-items: center;
flex-direction: column;
.top-tips {
width: 100%;
margin-bottom: 1rem;
}
.form {
width: 100%;
.name {
margin-bottom: 2rem;
}
.rsa {
margin-bottom: 1rem;
}
.submit {
width: 8rem;
margin: 2rem auto 4rem auto;
display: block;
}
}
}
</style>

262
src/views/ForeverMC.vue Normal file
View File

@ -0,0 +1,262 @@
<template>
<div class="forever-mc">
<loading v-if="doFetching" :show-on="doFetching" />
<template v-else>
<markdown-view :content="computedBindTips" />
<t-table
class="table"
row-key="id"
:data="playerList"
:columns="columns"
>
<template #lastLoginAt="{ row }">
<span v-text="Time.toDateTime(row.lastLoginAt)"></span>
</template>
<template #action="{ row }">
<icon
class="cur-pointer"
name="FAIL"
fill="RED"
@click="doDelete(row.id)"
v-popup="`解除绑定`"
/>
</template>
</t-table>
<t-form class="bind-form" :disabled="computedBoundMax">
<p
v-if="computedBoundMax"
class="bound-max-tips red"
v-text="`已达到最大绑定数量:${maxBind}`"
></p>
<t-form-item label="添加绑定" name="name">
<t-input class="short" v-model="newBind" placeholder="请输入玩家昵称" />
<template #statusIcon>
<t-button
class="submit"
theme="success"
:disabled="doSubmittingBind || computedBoundMax"
@click="doSubmitBind"
>提交</t-button>
</template>
</t-form-item>
</t-form>
</template>
</div>
<t-dialog
class="unbind-confirm-dialog"
v-model:visible="visibleUnbindConfirmDialog"
attach="body"
:close-btn="false"
header="注意: "
>
<template #body>
<markdown-view :content="computedUnbindTips" />
</template>
<template #footer>
<div class="footer">
<t-button
class="submit"
theme="default"
@click="doSubmitUnbind"
:loading="doSubmittingUnbind"
:disabled="doSubmittingUnbind || delayConfirm"
:content="confirmBtnText"
/>
<t-button
@click="visibleUnbindConfirmDialog = false"
:disabled="doSubmittingUnbind"
content="取消"
/>
</div>
</template>
</t-dialog>
</template>
<script lang="ts" setup>
import {
CommonAPI,
Icon,
Loading,
MarkdownView,
SettingKey,
SettingMapper,
TemplateBizType,
Time,
Toolkit
} from "timi-web";
import MinecraftAPI from "@/api/MinecraftAPI.ts";
import { useVisitUserStore } from "@/store/visitUser.ts";
import { MinecraftPlayer } from "@/types/MinecraftPlayer.ts";
import { MessagePlugin } from "tdesign-vue-next";
defineOptions({
name: "ForeverMC"
});
const visitUserStore = useVisitUserStore();
const columns = computed(() => [
{
title: "昵称",
colKey: "name",
width: "40%"
},
{
title: "上次登录",
colKey: "lastLoginAt",
align: "center"
},
{
align: "right",
width: "3rem",
colKey: "id",
cell: "action"
}
]);
const tipsBind = ref();
const tipsUnbind = ref();
const computedBindTips = computed(() => {
if (tipsBind.value) {
return Toolkit.format(tipsBind.value, {
maxBind: SettingMapper.getValue(SettingKey.FMC_MAX_BIND)
});
}
return "";
});
onMounted(() => {
Toolkit.async(async () => {
tipsBind.value = await CommonAPI.getTemplate(TemplateBizType.FOREVER_MC, "PLAYER_BIND");
tipsUnbind.value = await CommonAPI.getTemplate(TemplateBizType.FOREVER_MC, "PLAYER_UNBIND");
});
});
const playerList = ref<MinecraftPlayer[]>([]);
const doFetching = ref<boolean>(false);
async function doFetch() {
if (!doFetching.value) {
doFetching.value = true;
playerList.value.length = 0;
playerList.value.push(...(await MinecraftAPI.list()));
}
doFetching.value = false;
}
watch(() => visitUserStore.visitUserView, doFetch);
onMounted(() => Toolkit.async(async () => await doFetch()));
const newBind = ref();
const maxBind = SettingMapper.getValueRef(SettingKey.FMC_MAX_BIND);
const doSubmittingBind = ref<boolean>(false);
async function doSubmitBind() {
doSubmittingBind.value = true;
await Toolkit.sleep(1E3);
MinecraftAPI.bind(newBind.value).then(async () => {
await MessagePlugin.success("添加绑定成功");
await doFetch();
newBind.value = "";
doSubmittingBind.value = false;
}).catch(async (msg: string) => {
await MessagePlugin.error(msg);
doSubmittingBind.value = false;
});
}
const doSubmittingUnbind = ref<boolean>(false);
const visibleUnbindConfirmDialog = ref<boolean>(false);
const unbindId = ref<number>();
const delayConfirm = ref<boolean>(false);
const delaySeconds = ref<number>(8);
const delayTimer = ref();
watch(visibleUnbindConfirmDialog, () => delayConfirm.value = visibleUnbindConfirmDialog.value);
watch(delayConfirm, () => {
if (delayTimer.value) {
clearInterval(delayTimer.value);
}
delaySeconds.value = 8;
delayTimer.value = setInterval(() => {
delaySeconds.value--;
if (delaySeconds.value === 0) {
delayConfirm.value = false;
clearInterval(delayTimer.value);
}
}, 1E3);
});
const computedBoundMax = computed(() => SettingMapper.getValue(SettingKey.FMC_MAX_BIND) as any as number <= playerList.value.length);
const computedUnbindTips = computed(() => {
if (unbindId.value) {
return Toolkit.format(tipsUnbind.value, {
name: playerList.value.find(i => i.id === unbindId.value)?.name
});
}
return "";
});
const confirmBtnText = computed(() => {
if (delayConfirm.value) {
return `确认 (${delaySeconds.value})`;
}
if (doSubmittingUnbind.value) {
return "正在保存..";
}
return "确认";
});
async function doSubmitUnbind() {
if (unbindId.value) {
doSubmittingUnbind.value = true;
await Toolkit.sleep(1E3);
MinecraftAPI.unbind(unbindId.value).then(async () => {
await MessagePlugin.success("解除绑定成功");
await doFetch();
visibleUnbindConfirmDialog.value = doSubmittingUnbind.value = false;
}).catch(async (msg: string) => {
await MessagePlugin.error(msg);
visibleUnbindConfirmDialog.value = doSubmittingUnbind.value = false;
});
}
}
async function doDelete(id: number) {
unbindId.value = id;
visibleUnbindConfirmDialog.value = true;
}
</script>
<style lang="less" scoped>
.forever-mc {
padding: 2rem 4rem;
display: flex;
transition: padding 500ms var(--tui-bezier);
align-items: center;
flex-direction: column;
justify-content: center;
.table {
max-width: 26rem;
margin-top: 2rem;
}
.bind-form {
margin: 1rem 0;
.bound-max-tips {
text-align: center;
}
.submit {
width: 4rem;
display: block;
}
}
}
@media screen and (max-width: 720px) {
.forever-mc {
padding: 2rem;
transition: padding 500ms var(--tui-bezier);
}
}
:global(.update-confirm-dialog .footer) {
display: flex;
justify-content: center;
}
</style>

81
src/views/PrivacyInfo.vue Normal file
View File

@ -0,0 +1,81 @@
<template>
<div class="privacy-info">
<h4>允许访客看到你以下选中的信息</h4>
<loading :show-on="!privacy" />
<t-form v-if="privacy" class="form">
<t-form-item label="邮箱">
<t-checkbox v-model="privacy.email" />
</t-form-item>
<t-form-item label="性别">
<t-checkbox v-model="privacy.sex" />
</t-form-item>
<t-form-item label="出生日期">
<t-checkbox v-model="privacy.birthdate" />
</t-form-item>
<t-form-item label="QQ">
<t-checkbox v-model="privacy.qq" />
</t-form-item>
<t-form-item label="最近登录">
<t-checkbox v-model="privacy.lastLoginAt" />
</t-form-item>
<t-form-item label="注册时间">
<t-checkbox v-model="privacy.createdAt" />
</t-form-item>
<t-button
class="submit"
@click="doSubmit"
:loading="doSubmitting"
:disabled="doSubmitting"
:content="doSubmitting ? '正在保存..' : '保存'"
/>
</t-form>
</div>
</template>
<script lang="ts" setup>
import { Loading, Toolkit } from "../../../timi-web";
import { MessagePlugin } from "tdesign-vue-next";
import { UserPrivacy } from "@/types/User.ts";
import UserAPI from "@/api/UserAPI.ts";
const doSubmitting = ref<boolean>(false);
const privacy = ref<UserPrivacy>();
const doSubmit = async () => {
if (!privacy.value) {
return;
}
doSubmitting.value = true;
await Toolkit.sleep(500);
await UserAPI.updatePrivacy(privacy.value);
await MessagePlugin.success("保存成功");
doSubmitting.value = false;
};
onMounted(async () => {
privacy.value = await UserAPI.getPrivacy();
});
</script>
<style lang="less" scoped>
.privacy-info {
width: 24rem;
margin: 0 auto;
padding: 2rem 4rem;
.form {
:deep(.t-form__item) {
margin-bottom: .25rem;
}
.submit {
width: 8rem;
margin: 2rem auto;
display: block;
}
}
}
</style>

247
src/views/ProfileInfo.vue Normal file
View File

@ -0,0 +1,247 @@
<template>
<div class="profile-info">
<loading :show-on="!userRequest" />
<template v-if="userRequest">
<profile-visitor v-if="visitUserStore.isVisitor" :value="userRequest" />
<t-form v-else>
<t-form-item label="修改头像" name="avatar">
<avatar-editor ref="avatarEditorComp" :renderer-type="userRequest.profile.avatarType" />
</t-form-item>
<t-form-item label="修改背景" name="avatar">
<wrapper-selector ref="wrapperSelectorComp" />
</t-form-item>
<t-form-item label="头像渲染方式" name="avatar-type">
<t-select
class="image-type-selector"
v-model="userRequest.profile.avatarType"
>
<t-option v-for="(value, key) in ImageTypeItem" :key="key" :value="key" :label="value" />
</t-select>
</t-form-item>
<t-form-item label="封面渲染方式" name="wrapper-type">
<t-select class="image-type-selector" v-model="userRequest.profile.wrapperType">
<t-option v-for="(value, key) in ImageTypeItem" :key="key" :value="key" :label="value" />
</t-select>
</t-form-item>
<t-form-item v-if="level" label="等级" name="level">
<user-level class="level-icon" :value="level.value" />
<div class="level" :style="levelStyle">
<div class="value" :style="levelValueStyle"></div>
</div>
</t-form-item>
<t-form-item label="昵称" name="name">
<t-input class="short" v-model="userRequest.name" />
</t-form-item>
<t-form-item label="邮箱" name="email">
<t-input class="short" v-model="userRequest.email" />
</t-form-item>
<t-form-item label="性别" name="sex">
<icon
class="sex boy"
:class="{ active: isBoy }"
name="BOY"
fill="var(--tui-blue)"
@click="updateSex(1)"
v-popup="`男生`"
/>
<icon
class="sex girl"
:class="{ active: isGirl }"
name="GIRL"
fill="var(--tui-pink)"
@click="updateSex(0)"
v-popup="`女生`"
/>
</t-form-item>
<t-form-item label="出生日期" name="birthdate">
<t-date-picker
class="short"
valueType="time-stamp"
:firstDayOfWeek="7"
v-model="userRequest.profile.birthdate"
placeholder=" "
/>
</t-form-item>
<t-form-item label="QQ" name="qq">
<t-input class="short" v-model="userRequest.profile.qq" placeholder="" />
</t-form-item>
<t-form-item label="说明" name="description">
<t-textarea v-model="userRequest.profile.description" />
</t-form-item>
<t-form-item label="注册时间" name="createdAt">
<div v-text="Time.toDateTime(userRequest.createdAt)"></div>
</t-form-item>
<t-form-item label="最近登录" name="lastLoggedAt">
<div v-text="Time.toDateTime(userRequest.profile.lastLoginAt)"></div>
</t-form-item>
<t-button
class="submit"
@click="doSubmit"
:loading="doSubmitting"
:disabled="doSubmitting"
:content="doSubmitting ? '正在保存..' : '保存'"
/>
</t-form>
</template>
</div>
</template>
<script lang="ts" setup>
import AvatarEditor from "@/components/AvatarEditor.vue";
import { Icon, Loading, Time, Toolkit, UserLevel, userStore } from "timi-web";
import { useVisitUserStore } from "@/store/visitUser.ts";
import { MessagePlugin } from "tdesign-vue-next";
import UserAPI from "@/api/UserAPI.ts";
import { UserRequest } from "@/types/User.ts";
import WrapperSelector from "@/components/WrapperSelector.vue";
import ProfileVisitor from "@/components/ProfileVisitor.vue";
defineOptions({
name: "ProfileInfo"
});
enum ImageTypeItem {
PIXELATED = "像素",
SMOOTH = "平滑",
AUTO = "双线性"
}
const visitUserStore = useVisitUserStore();
// ref
const userRequest = ref<UserRequest>();
const avatarEditorComp = ref();
const wrapperSelectorComp = ref();
const doSubmitting = ref<boolean>(false);
// computed
const level = computed(() => Toolkit.toUserLevel(userRequest.value?.profile.exp));
const levelStyle = computed(() => {
return {
border: `1px solid var(--tui-level-${level.value?.value})`
};
});
const levelValueStyle = computed(() => {
return {
width: level.value?.percent * 100 + "%",
background: `var(--tui-level-${level.value?.value})`
};
});
const isBoy = computed(() => userRequest.value?.profile.sex === 1);
const isGirl = computed(() => userRequest.value?.profile.sex === 0);
// method
const updateSex = (newSex: number) => {
if (userRequest.value) {
if (userRequest.value.profile.sex === newSex) {
userRequest.value.profile.sex = undefined;
} else {
userRequest.value.profile.sex = newSex;
}
}
};
const doSubmit = async () => {
if (!userRequest.value) {
return;
}
doSubmitting.value = true;
await Toolkit.sleep(500);
if (wrapperSelectorComp.value) {
const value = wrapperSelectorComp.value.getValue();
if (value) {
userRequest.value.profile.wrapper = Toolkit.base64ToFile(value, "wrapper.png");
}
}
if (avatarEditorComp.value) {
const value = avatarEditorComp.value.getValue();
if (value) {
userRequest.value.profile.avatar = Toolkit.base64ToFile(value, "avatar.png");
}
}
UserAPI.updateProfile(userRequest.value).then(async () => {
await MessagePlugin.success("保存成功");
await userStore.reloadProfile();
await visitUserStore.reloadVisitUserView();
updateProfile();
wrapperSelectorComp.value && wrapperSelectorComp.value.cancel();
avatarEditorComp.value && avatarEditorComp.value.cancel();
doSubmitting.value = false;
}).catch(async (msg) => {
await MessagePlugin.error("保存失败:" + msg);
});
};
const updateProfile = () => {
if (visitUserStore.visitUserView) {
const cloneObj = {} as UserRequest;
Toolkit.deepClone(visitUserStore.visitUserView, cloneObj);
userRequest.value = cloneObj;
}
};
watch(() => visitUserStore.visitUserView, updateProfile);
onMounted(() => {
if (!userRequest.value) {
updateProfile();
}
});
</script>
<style lang="less" scoped>
.profile-info {
padding: 2rem 4rem;
transition: padding 500ms var(--tui-bezier);
.image-type-selector {
width: 8rem;
}
.level {
width: calc(16rem - 19px - 8px);
height: 5px;
display: flex;
padding: 1px;
margin-left: 4px;
.value {
height: 100%;
}
}
.short {
width: 16rem;
}
.sex {
cursor: var(--pointer);
border: 1px solid #FAC7D4;
margin: 4px .618rem 0 0;
display: flex;
padding: 3px;
background: #FFF;
pointer-events: all;
&.active {
border: 1px solid #525870;
background: #F1F1F1;
}
}
.submit {
width: 8rem;
margin: 2rem auto;
display: block;
}
}
@media screen and (max-width: 720px) {
.profile-info {
padding: 2rem;
transition: padding 500ms var(--tui-bezier);
}
}
</style>

36
src/views/SpaceIndex.vue Normal file
View File

@ -0,0 +1,36 @@
<template>
<div class="space-index">
<authorize-form
class="form"
@login-success="onSuccess"
@register-success="onSuccess"
/>
</div>
</template>
<script lang="ts" setup>
import { userStore } from "timi-web";
import { AuthorizeForm } from "timi-tdesign-pc";
const router = useRouter();
const onSuccess = async () => {
await router.push({
name: "ProfileInfo",
params: {
id: userStore.loginUser.user?.id
}
});
};
</script>
<style lang="less" scoped>
.space-index {
margin-top: 4rem;
.form {
width: 20rem;
margin: 0 auto;
}
}
</style>

View File

@ -0,0 +1,134 @@
<template>
<div class="comment-list">
<div class="items">
<loading :showOn="!pageResult" />
<template v-if="pageResult">
<section class="item" v-for="item in pageResult.list" :key="item.id">
<header class="header">
<div class="left">
<div class="biz-type" v-text="(<any> UserCommentBizType)[item.bizType]"></div>
<h5 v-if="item.article" class="title selectable" v-text="(item.article as any).title"></h5>
</div>
<div class="right">
<t-link
:href="UserAPI.getCommentTargetUrl(item)"
class="word-space"
theme="primary"
hover="color"
target="_blank"
>查看</t-link>
<t-popconfirm
content="关联这个评论的所有回复也会删除,确定继续删除这条评论吗?"
@confirm="doDelete(item)"
:popup-props="{ placement: 'left' }"
>
<t-link class="del" theme="danger" hover="color">删除</t-link>
</t-popconfirm>
</div>
</header>
<div class="content gray clip-text selectable" v-text="item.content"></div>
</section>
</template>
</div>
<footer v-if="pageResult && 16 < pageResult.total" class="footer">
<t-pagination
:total="pageResult.total"
:pageSize="16"
:showPageSize="false"
:onCurrentChange="(current: number) => page.index = current - 1"
>
<template #totalContent>
<div style="flex: 1" v-text="`共 ${pageResult.total} 条评论`"></div>
</template>
</t-pagination>
</footer>
</div>
</template>
<script lang="ts" setup>
import { CommentView, Loading, Page, PageResult, Toolkit } from "timi-web";
import { UserCommentBizType } from "@/types/User.ts";
import UserAPI from "@/api/UserAPI.ts";
const page = reactive<Page>({
index: 0,
size: 12
});
const pageResult = ref<PageResult<CommentView>>();
const fetchList = Toolkit.debounce(async () => pageResult.value = await UserAPI.listComment(page));
watch(() => page.index, fetchList);
onMounted(fetchList);
async function doDelete(item: CommentView) {
item.id && await UserAPI.deleteComment(item.id);
await fetchList();
}
</script>
<style lang="less" scoped>
.comment-list {
height: 100%;
display: flex;
flex-direction: column;
.items {
flex: 1;
overflow: auto;
font-size: 1rem;
.item {
padding: .5rem 1rem 2rem 1rem;
&:hover {
background: var(--tui-dark-white);
}
.header {
display: flex;
align-items: center;
margin-bottom: .5rem;
justify-content: space-between;
.left {
display: flex;
align-items: center;
.biz-type {
width: fit-content;
color: #FFF;
padding: .1rem .5rem;
text-align: center;
background: var(--tui-light-blue);
margin-right: .5rem;
}
.title {
margin: 0;
}
}
.right {
display: flex;
font-size: 14px;
align-items: center;
justify-content: flex-end;
}
}
.content {
flex: 1;
font-size: 14px;
margin-right: 1rem;
}
}
}
.footer {
bottom: -1px;
padding: var(--tui-page-padding);
position: sticky;
background: rgba(255, 255, 255, .8);
border-top: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}
</style>

View File

@ -0,0 +1,144 @@
<template>
<div class="reply-by-receiver-list">
<div class="items">
<loading :showOn="!pageResult"/>
<template v-if="pageResult">
<section class="item" v-for="item in pageResult.list" :key="item.id">
<header class="header">
<div class="left">
<div class="biz-type" v-text="(<any> UserCommentBizType)[item.comment.bizType]"></div>
<h5 v-if="item.comment.article" class="title selectable" v-text="(item.comment.article as any).title"></h5>
<div class="gray sender">
<span class="word-space" v-if="item.sender" v-text="item.sender.name"></span>
<span class="word-space" v-if="item.senderNick" v-text="item.senderNick"></span>
<span v-if="item.sender || item.senderNick">回复</span>
</div>
</div>
<div class="right">
<t-link
:href="UserAPI.getCommentTargetUrl(item.comment)"
class="word-space"
theme="primary"
hover="color" target="_blank"
>查看</t-link>
<t-popconfirm
content="确定继续删除这条回复通知吗?"
@confirm="doDelete(item)"
:popup-props="{ placement: 'left' }"
>
<t-link class="del" theme="danger" hover="color">删除</t-link>
</t-popconfirm>
</div>
</header>
<div class="content gray clip-text selectable" v-text="item.content"></div>
</section>
</template>
</div>
<footer v-if="pageResult && 16 < pageResult.total" class="footer">
<t-pagination
v-if="pageResult"
:total="pageResult.total"
:pageSize="16"
:showPageSize="false"
:onCurrentChange="(current: number) => page.index = current - 1"
>
<template #totalContent>
<div style="flex: 1" v-text="`共 ${pageResult.total} 条回复`"></div>
</template>
</t-pagination>
</footer>
</div>
</template>
<script lang="ts" setup>
import { CommentReplyBizType, CommentReplyPage, CommentReplyView, Loading, PageResult, Toolkit } from "timi-web";
import { UserCommentBizType } from "@/types/User.ts";
import UserAPI from "@/api/UserAPI.ts";
const page = reactive<CommentReplyPage>({
bizType: (<any>CommentReplyBizType).RECEIVER,
index: 0,
size: 12
});
const pageResult = ref<PageResult<CommentReplyView>>();
const fetchList = Toolkit.debounce(async () => pageResult.value = await UserAPI.listCommentReply(page));
watch(() => page.index, fetchList);
onMounted(fetchList);
async function doDelete(item: CommentReplyView) {
item.id && await UserAPI.ignoreCommentReply(item.id);
await fetchList();
}
</script>
<style lang="less" scoped>
.reply-by-receiver-list {
height: 100%;
display: flex;
flex-direction: column;
.items {
flex: 1;
font-size: 1rem;
.item {
padding: .5rem 1rem 2rem 1rem;
&:hover {
background: var(--tui-dark-white);
}
.header {
display: flex;
align-items: center;
margin-bottom: .5rem;
justify-content: space-between;
.left {
display: flex;
align-items: center;
.biz-type {
width: fit-content;
color: #FFF;
padding: .1rem .5rem;
text-align: center;
background: var(--tui-light-blue);
margin-right: .5rem;
}
.title {
margin: 0;
}
.sender {
font-size: 12px;
}
}
.right {
display: flex;
font-size: 14px;
align-items: center;
justify-content: flex-end;
}
}
.content {
font-size: 14px;
margin-right: 1rem;
}
}
}
.footer {
bottom: -1px;
padding: var(--tui-page-padding);
position: sticky;
background: rgba(255, 255, 255, .8);
border-top: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}
</style>

View File

@ -0,0 +1,147 @@
<template>
<div class="reply-by-sender-list">
<div class="items">
<loading :showOn="!pageResult"/>
<template v-if="pageResult">
<section class="item" v-for="item in pageResult.list" :key="item.id">
<header class="header">
<div class="left">
<div class="biz-type" v-text="(<any> UserCommentBizType)[item.comment.bizType]"></div>
<h5 v-if="item.comment.article" class="title selectable" v-text="(item.comment.article as any).title"></h5>
<div class="gray receiver">
<span v-if="item.receiver || item.receiverNick" class="word-space">回复</span>
<span v-if="item.receiver" v-text="item.receiver.name"></span>
<span v-if="item.receiverNick" v-text="item.receiverNick"></span>
</div>
</div>
<div class="right">
<t-link
:href="UserAPI.getCommentTargetUrl(item.comment)"
class="word-space"
theme="primary"
hover="color"
target="_blank"
>查看</t-link>
<t-popconfirm
content="确定继续删除这条回复吗?"
@confirm="doDelete(item)"
:popup-props="{ placement: 'left' }"
>
<t-link class="del" theme="danger" hover="color">删除</t-link>
</t-popconfirm>
</div>
</header>
<div class="content gray clip-text selectable" v-text="item.content"></div>
</section>
</template>
</div>
<footer v-if="pageResult && 16 < pageResult.total" class="footer">
<t-pagination
v-if="pageResult"
:total="pageResult.total"
:pageSize="16"
:showPageSize="false"
:onCurrentChange="(current: number) => page.index = current - 1"
>
<template #totalContent>
<div style="flex: 1" v-text="`共 ${pageResult.total} 条回复`"></div>
</template>
</t-pagination>
</footer>
</div>
</template>
<script lang="ts" setup>
import { CommentReplyBizType, CommentReplyPage, CommentReplyView, Loading, PageResult, Toolkit } from "timi-web";
import { UserCommentBizType } from "@/types/User.ts";
import UserAPI from "@/api/UserAPI.ts";
const page = reactive<CommentReplyPage>({
bizType: (<any>CommentReplyBizType).SENDER,
index: 0,
size: 12
});
const pageResult = ref<PageResult<CommentReplyView>>();
const fetchList = Toolkit.debounce(async () => pageResult.value = await UserAPI.listCommentReply(page));
watch(() => page.index, fetchList);
onMounted(fetchList);
async function doDelete(item: CommentReplyView) {
item.id && await UserAPI.deleteCommentReply(item.id);
await fetchList();
}
</script>
<style lang="less" scoped>
.reply-by-sender-list {
height: 100%;
display: flex;
flex-direction: column;
.items {
flex: 1;
overflow: auto;
font-size: 1rem;
.item {
padding: .5rem 1rem 2rem 1rem;
&:hover {
background: var(--tui-dark-white);
}
.header {
display: flex;
align-items: center;
margin-bottom: .5rem;
justify-content: space-between;
.left {
display: flex;
align-items: center;
.biz-type {
width: fit-content;
color: #FFF;
padding: .1rem .5rem;
text-align: center;
background: var(--tui-light-blue);
margin-right: .5rem;
}
.title {
margin: 0;
}
.receiver {
font-size: 12px;
}
}
.right {
display: flex;
font-size: 14px;
align-items: center;
justify-content: flex-end;
}
}
.content {
flex: 1;
font-size: 14px;
margin-right: 1rem;
}
}
}
.footer {
bottom: -1px;
padding: var(--tui-page-padding);
position: sticky;
background: rgba(255, 255, 255, .8);
border-top: var(--tui-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}
</style>

13
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
interface ImportMetaEnv {
readonly VITE_API: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

42
tsconfig.json Normal file
View File

@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Node",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.vue",
"node_modules/tdesign-vue-next/global.d.ts"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

12
tsconfig.node.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

90
vite.config.ts Normal file
View File

@ -0,0 +1,90 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import dts from "vite-plugin-dts";
import { resolve } from "path";
import VueSetupExtend from "vite-plugin-vue-setup-extend";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { TDesignResolver } from "unplugin-vue-components/resolvers";
export default defineConfig({
base: "/",
plugins: [
vue(),
dts({
include: "./src"
}),
VueSetupExtend(),
AutoImport({
imports: [
"vue",
"vue-router",
"pinia",
{
"axios": [
["default", "axios"]
]
}
],
dts: "src/auto-imports.d.ts",
eslintrc: {
enabled: true,
globalsPropValue: true
},
resolvers: [
TDesignResolver({
library: "vue-next"
})
]
}),
Components({
dirs: [
"src/components",
"src/layout"
],
resolvers: [
TDesignResolver({
library: "vue-next"
})
]
})
],
css: {
preprocessorOptions: {
less: {
math: "always",
lessOptions: {
modifyVars: {
"@btn-border-radius": "0"
},
javascriptEnabled: true
}
}
}
},
define: {
"process.env": {}
},
resolve: {
alias: {
"@": resolve(__dirname, "src")
},
extensions: [".js", ".json", ".ts"]
},
server: {
port: 82,
host: "space.imyeyu.dev"
},
build: {
sourcemap: false,
minify: "terser",
terserOptions: {
compress: {
// eslint-disable-next-line camelcase
drop_console: true,
// eslint-disable-next-line camelcase
drop_debugger: true
}
}
}
});