项目背景
随着前端业务的不断发展,前端对设计稿的还原程度也成为了影响用户对产品体验的一个关键指标,作为最靠近用户侧的研发,前端工程师通常需要和设计师同学通力配合来提升用户体验。其中,设计走查是设计同学最常见的测试前端同学是否完美还原了自己设计理念的方式,本文旨在通过设计走查平台在前端侧的实践总结下在前端上游研发链路中的一些基础建设,以期能够为其他有相关需要的同学提供一些实践思路。
方案
一个前端工程师的主要目标是寻找一款贴近浏览器原生的框架,svelte 是你不二的选择。
前端架构选型,从整体的产品业务形态来看,属于较为简单的单一页面形态,并且考虑到要支持浏览器的插件生态,因而前端部分选择使用了svelte的框架方案。选择 svelte
作为本业务的前端技术选型主要考虑到以下两方面原因:一是考虑到业务形态较为简单,只有一个上传图片的页面;二是由于浏览器插件相关的编写还是更加偏向原生js一些,使用大型框架有些大材小用。综合近几年 svelte
的迅猛发展,小型业务还是考虑使用它作为一个框架使用的,其在编译时利用位掩码做的脏值检查的思路其实还是可以作为框架开发者借鉴的一个思路的(ps:对这个感兴趣的同学,可以看一下新兴前端框架 Svelte 从入门到原理这篇文章的介绍),但是目前发展相对还是比较初期,整个生态相对还不够完善,同时也是给广大开发者提供了很好的蓝海空间,比如:目前还没有出现类似 Element UI
和 Ant Design
这种十分好用的组件库系统,虽然有几个,但是个人感觉很不好用,作者在本项目中对需要用到的几个组件做了简单的封装,有需要的同学可以参考借鉴一下。
目录
public
- build
- bg.jpeg
- favicon.png
- global.css
- index.html
- manifest.json
scripts
- setupCrxScript.js
- setupTypeScript.js
src
components
- Button.svelte
- Dialog.svelte
- Icon.svelte
- Input.svelte
- Message.svelte
- Tooltip.svelte
- Upload.svelte
utils
- function.js
- image.js
- index.js
- App.svelte
- main.js
- rollup.config.js
实践
设计走查平台提供了管理平台及对应的Chrome插件,可提供给测试及UI同学用于图片的比对,提升研发效率
源码
svelte作者--前端著名的轮子哥Rich Harris,同时也是rollup的作者,因而本项目中就选择了 rollup
作为打包构建的工具,同时为了将Chrome插件发布到内网中(ps:本项目主要用于项目内部基建应用,因而未在公有云及Chrome官方平台去发布,服务端涉及到了图片的比对计算);在scripts目录下内置了两个脚本,一个用于生成ts,一个用于向云平台发送压缩包;由于 svelte
的组件库生态相对不是特别丰富(ps:业界常见已经开源的 svelte
组件库可以参看这篇文章Svelte的UI组件库),对比了业界几个相关的组件库后,决定自己实现下业务中需要用到的组件,具体组件放在了components目录下
rollup.config.js
import svelte from 'rollup-plugin-svelte';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import livereload from 'rollup-plugin-livereload';
import { terser } from 'rollup-plugin-terser';
import css from 'rollup-plugin-css-only';
const production = !process.env.ROLLUP_WATCH;
function serve() {
let server;
function toExit() {
if (server) server.kill(0);
}
return {
writeBundle() {
if (server) return;
server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
stdio: ['ignore', 'inherit', 'inherit'],
shell: true
});
process.on('SIGTERM', toExit);
process.on('exit', toExit);
}
};
}
export default {
input: 'src/main.js',
output: {
sourcemap: true,
format: 'iife',
name: 'app',
file: 'public/build/bundle.js'
},
plugins: [
svelte({
compilerOptions: {
// enable run-time checks when not in production
dev: !production
}
}),
// we'll extract any component CSS out into
// a separate file - better for performance
css({ output: 'bundle.css' }),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ['svelte']
}),
commonjs(),
// In dev mode, call `npm run start` once
// the bundle has been generated
!production && serve(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload('public'),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser()
],
watch: {
clearScreen: false
}
};
scripts
setupCrxScript.js
通过minio这个库来进行私有云平台的对象存储库上传,archiver这个主要用于压缩
const fs = require('fs');
const path = require('path');
const archiver = require('archiver');
const Minio = require('minio');
const minio = new Minio.Client({
endPoint: '',
port: 80,
useSSL: false,
accessKey: '',
secretKey: ''
})
const output = fs.createWriteStream(path.resolve(__dirname,'../pixelpiper.zip'));
const archive = archiver('zip', {
zlib: { level: 9 }
});
output.on('close', function() {
console.log(archive.pointer() + ' total bytes');
console.log('archiver has been finalized and the output file descriptor has closed.');
// 压缩完成后向 cdn 中传递压缩包
const file = path.resolve(__dirname, '../pixelpiper.zip');
fs.stat(file, function(error, stats) {
if(error) {
return console.error(error)
}
minio.putObject('cdn', 'pixelpiper.zip', fs.createReadStream(file), stats.size, 'application/zip', function(err, etag) {
return console.log(err, etag) // err should be null
})
})
});
output.on('end', function() {
console.log('Data has been drained');
});
archive.on('warning', function(err) {
if (err.code === 'ENOENT') {
} else {
throw err;
}
});
archive.on('error', function(err) {
throw err;
});
archive.pipe(output);
archive.directory(path.resolve(__dirname, '../public'), false);
archive.finalize();
setupTypeScript.js
// @ts-check
/** This script modifies the project to support TS code in .svelte files like:
<script lang="ts">
export let name: string;
</script>
As well as validating the code for CI.
*/
/** To work on this script:
rm -rf test-template template && git clone sveltejs/template test-template && node scripts/setupTypeScript.js test-template
*/
const fs = require("fs")
const path = require("path")
const { argv } = require("process")
const projectRoot = argv[2] || path.join(__dirname, "..")
// Add deps to pkg.json
const packageJSON = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"))
packageJSON.devDependencies = Object.assign(packageJSON.devDependencies, {
"svelte-check": "^1.0.0",
"svelte-preprocess": "^4.0.0",
"@rollup/plugin-typescript": "^8.0.0",
"typescript": "^4.0.0",
"tslib": "^2.0.0",
"@tsconfig/svelte": "^1.0.0"
})
// Add script for checking
packageJSON.scripts = Object.assign(packageJSON.scripts, {
"validate": "svelte-check"
})
// Write the package JSON
fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify(packageJSON, null, " "))
// mv src/main.js to main.ts - note, we need to edit rollup.config.js for this too
const beforeMainJSPath = path.join(projectRoot, "src", "main.js")
const afterMainTSPath = path.join(projectRoot, "src", "main.ts")
fs.renameSync(beforeMainJSPath, afterMainTSPath)
// Switch the app.svelte file to use TS
const appSveltePath = path.join(projectRoot, "src", "App.svelte")
let appFile = fs.readFileSync(appSveltePath, "utf8")
appFile = appFile.replace("<script>", '<script lang="ts">')
appFile = appFile.replace("export let name;", 'export let name: string;')
fs.writeFileSync(appSveltePath, appFile)
// Edit rollup config
const rollupConfigPath = path.join(projectRoot, "rollup.config.js")
let rollupConfig = fs.readFileSync(rollupConfigPath, "utf8")
// Edit imports
rollupConfig = rollupConfig.replace(`'rollup-plugin-terser';`, `'rollup-plugin-terser';
import sveltePreprocess from 'svelte-preprocess';
import typescript from '@rollup/plugin-typescript';`)
// Replace name of entry point
rollupConfig = rollupConfig.replace(`'src/main.js'`, `'src/main.ts'`)
// Add preprocessor
rollupConfig = rollupConfig.replace(
'compilerOptions:',
'preprocess: sveltePreprocess({ sourceMap: !production }),\n\t\t\tcompilerOptions:'
);
// Add TypeScript
rollupConfig = rollupConfig.replace(
'commonjs(),',
'commonjs(),\n\t\ttypescript({\n\t\t\tsourceMap: !production,\n\t\t\tinlineSources: !production\n\t\t}),'
);
fs.writeFileSync(rollupConfigPath, rollupConfig)
// Add TSConfig
const tsconfig = `{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
}`
const tsconfigPath = path.join(projectRoot, "tsconfig.json")
fs.writeFileSync(tsconfigPath, tsconfig)
// Delete this script, but not during testing
if (!argv[2]) {
// Remove the script
fs.unlinkSync(path.join(__filename))
// Check for Mac's DS_store file, and if it's the only one left remove it
const remainingFiles = fs.readdirSync(path.join(__dirname))
if (remainingFiles.length === 1 && remainingFiles[0] === '.DS_store') {
fs.unlinkSync(path.join(__dirname, '.DS_store'))
}
// Check if the scripts folder is empty
if (fs.readdirSync(path.join(__dirname)).length === 0) {
// Remove the scripts folder
fs.rmdirSync(path.join(__dirname))
}
}
// Adds the extension recommendation
fs.mkdirSync(path.join(projectRoot, ".vscode"), { recursive: true })
fs.writeFileSync(path.join(projectRoot, ".vscode", "extensions.json"), `{
"recommendations": ["svelte.svelte-vscode"]
}
`)
console.log("Converted to TypeScript.")
if (fs.existsSync(path.join(projectRoot, "node_modules"))) {
console.log("\nYou will need to re-run your dependency manager to get started.")
}
components
项目中用到了一些通用组件,借鉴了下Element UI的组件样式和思路,主要封装了 Button(按钮)
、 Dialog(对话框)
、 Icon(图标)
、 Input(输入框)
、 Message(消息)
、 Tooltip(提示工具)
、 Upload(上传)
几个组件
Button.svelte
<script>
import Icon from './Icon.svelte';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let icon, type='default';
function handleClick() {
dispatch('click')
}
function computedButtonClass(type) {
switch (type) {
case 'primary':
return 'button button-primary';
case 'default':
return 'button button-default';
case 'text':
return 'button button-text';
default:
return 'button button-default';
}
}
</script>
<style>
.button {
border: 0;
border-radius: 2px;
}
.button:hover {
cursor: pointer;
}
.button-primary {
background-color: rgb(77, 187, 41, 1);
color: white;
border: 1px solid rgb(77, 187, 41, 1);
}
.button-primary:hover {
background-color: rgba(77, 187, 41,.8);
}
.button-default {
background-color: white;
color: #999;
border: 1px solid #e0e0e0;
}
.button-default:hover {
background-color: rgba(77, 187, 41,.1);
color: rgba(77, 187, 41,.6);
border: 1px solid #e0e0e0;
}
.button-text {
background-color: transparent;
border: none;
}
</style>
<button class={computedButtonClass(type)} on:click={handleClick}>
{#if icon}
<Icon name="{icon}" />
{/if}
<span>
<slot></slot>
</span>
</button>
Dialog.svelte
<script>
import Icon from "./Icon.svelte";
import Button from "./Button.svelte";
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let title, visible = false;
function handleClose() {
visible = false;
}
function handleShade() {
visible = false;
}
function handleCancel() {
visible = false;
}
function handleSubmit() {
dispatch('submit')
}
</script>
<style>
.dialog-wrapper {
width: 100vw;
height: 100vh;
position: absolute;
z-index: 100000;
background-color: rgba(0, 0, 0, .3);
}
.dialog {
width: 400px;
height: max-content;
background-color: white;
box-shadow: 0 0 10px #ececec;
position: absolute;
z-index: 100001;
border-radius: 2px;
margin: auto;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #ececec;
padding: 10px;
}
.dialog-header .dialog-title {
font-size: 16px;
}
.dialog-body {
padding: 10px;
}
.dialog-footer {
display: flex;
justify-content: right;
padding: 10px;
}
</style>
{#if visible}
<div class="dialog-wrapper" on:click={handleShade}>
</div>
<div class="dialog">
<div class="dialog-header">
<slot name="title">
<span class="dialog-title">{ title }</span>
</slot>
<Button type="text" on:click={handleClose}>
<Icon name="iconclose" />
</Button>
</div>
<div class="dialog-body">
<slot></slot>
</div>
<div class="dialog-footer">
<div class="dialog-button-group">
<Button on:click={handleCancel}>取消</Button>
<Button type="primary" on:click={handleSubmit}>确定</Button>
</div>
</div>
</div>
{/if}
Icon.svelte
常用的icon主要通过iconfont来引入
<script>
export let name;
</script>
<i class="{`iconfont ${name}`}"></i>
Input.svelte
<script>
export let value;
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function handleChange() {
dispatch('input')
}
</script>
<style>
.input {
border: 1px solid rgb(77, 187, 41, 1);
width: 100px;
}
.input:focus {
border: 1px solid rgb(77, 187, 41, .5);
outline: none;
}
</style>
<input class="input" bind:value={value} on:change={handleChange} />
Message.svelte
<script>
import Icon from './Icon.svelte';
export let type = 'info', show = false;
const computedMessageClass = type => {
switch (type) {
case 'success':
return 'message message-success';
case 'warning':
return 'message message-warning';
case 'info':
return 'message message-info';
case 'error':
return 'message message-error';
default:
return 'message message-info';
}
}
const computedIconName = type => {
switch (type) {
case 'success':
return 'iconsuccess';
case 'warning':
return 'iconwarning';
case 'info':
return 'iconinfo';
case 'error':
return 'iconerror';
default:
return 'iconinfo';
}
}
</script>
<style>
.message {
position: absolute;
z-index: 100000;
width: 200px;
height: max-content;
left: 0;
right: 0;
margin: auto;
padding: 4px 10px;
animation: show 2s ease-in-out forwards;
display: flex;
justify-content: left;
align-items: center;
border-radius: 4px;
}
.message-content {
margin-left: 10px;
}
@keyframes show {
from {
opacity: 1;
top: 0;
}
to {
opacity: 0;
top: 100px;
}
}
.message-success {
background-color: rgba(77, 187, 41, .2);
color: rgba(77, 187, 41, 1);
}
.message-info {
background-color: rgb(144, 147, 153, .2);
color: rgb(144, 147, 153, 1);
}
.message-warning {
background-color: rgb(230, 162, 60, .2);
color: rgb(230, 162, 60, 1);
}
.message-error {
background-color: rgb(245, 108, 108, .2);
color: rgb(245, 108, 108, 1);
}
</style>
{#if show}
<div class={computedMessageClass(type)}>
<Icon name={computedIconName(type)} />
<p class="message-content">
<slot></slot>
</p>
</div>
{/if}
Tooltip.svelte
<script>
export let content, tooltip;
</script>
<style>
.tooltip {
position: relative;
}
.tooltip .tip-container {
position: absolute;
background: #666;
padding: 0 10px;
border-radius: 4px;
right: -180px;
top: 50%;
margin-top: -24px;
}
.tip-container .tip-triple {
width: 0;
height: 0;
border: 8px solid transparent;
border-right-color: #666;
position: absolute;
left: -16px;
top: 16px;
}
.tip-container .tip-content {
line-height: 24px;
font-size: 12px;
color: white;
}
</style>
<div class="tooltip">
<slot class="tip-component"></slot>
{#if tooltip}
<div class="tip-container">
<div class="tip-triple"></div>
<p class="tip-content">{content}</p>
</div>
{/if}
</div>
Upload.svelte
<script>
export let action, onSuccess, beforeUpload, id;
function ajax(options) {
const xhr = new XMLHttpRequest();
const action = options.action;
let fd = new FormData();
fd.append(options.filename, options.file);
xhr.onerror = function (err) {
console.error(err)
}
xhr.onload = function() {
const text = xhr.responseText || xhr.response;
console.log('text', text)
text && options.success(JSON.parse(text))
}
xhr.open('post', action, true);
xhr.send(fd);
return xhr;
}
function post(rawFile) {
const options = {
id: id,
file: rawFile,
filename: 'img',
action: action,
success: res => onSuccess(res, rawFile, id)
}
const req = ajax(options);
if(req && req.then) {
req.then(options.onSuccess)
}
}
async function handleChange(e) {
const rawFile = e.target.files[0];
if(!beforeUpload) {
return post(rawFile)
}
let flag = await beforeUpload(rawFile, id);
if(flag) post(rawFile)
}
function handleClick() {
const plus = document.getElementById(id);
plus.value = '';
plus.click()
}
</script>
<style>
.upload {
width: 250px;
height: 250px;
border: 1px dashed #ececec;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
}
.upload:hover {
cursor: pointer;
}
.native-input {
display: none;
}
</style>
<div class="upload" on:click={handleClick}>
<div class="upload-slot" >
<slot></slot>
</div>
<input {id} class="native-input" type="file" multiple="false" accept="image/png" on:change={handleChange} />
</div>
utils
通用工具库主要封装了图片及函数式编程需要用到的一些工具函数
function.js
export const curry = (fn, arr = []) => (...args) => (
arg => arg.length === fn.length ?
fn(...arg) :
curry(fn, arg)
)([...arr, ...args]);
export const compose = (...args) => args.reduce((prev, current) => (...values) => prev(current(...values)));
image.js
export const getBase64 = file => {
const reader = new FileReader();
reader.readAsDataURL(file);
return new Promise((resolve) => {
reader.onload = () => {
resolve(reader.result);
};
});
};
export const getPixel = img => {
const image = new Image();
image.src = img;
return new Promise((resolve) => {
image.onload = () => {
const width = image.width;
const height = image.height;
resolve({ width, height });
};
});
}
App.svelte
<script>
import Icon from './components/Icon.svelte';
import Button from './components/Button.svelte';
import Upload from './components/Upload.svelte';
import Input from './components/Input.svelte';
import Message from './components/Message.svelte';
import Tooltip from './components/Tooltip.svelte';
import axios from 'axios';
import { getBase64, getPixel } from './utils';
const bgUrl = './bg.jpeg',
logoUrl = './favicon.png',
actionUrl = '',
compareUrl = '',
downloadUrl = '',
crxUrl = '';
let width = 0, height = 0, flag, compareName, errorMsg, successMsg, show, tooltip;
let uploaders = [
{
id: 'design',
title: '设计图',
filename: '',
url: ''
},
{
id: 'code',
title: '实现图',
filename: '',
url: ''
}
];
const handleCompare = () => {
show = false;
const len = uploaders.filter(f => !!f.filename).length;
if(len == 2) {
axios.post(compareUrl, {
designName: uploaders[0]['filename'],
codeName: uploaders[1]['filename'],
}).then(res => {
console.log('compare', res)
if(res.data.success) {
compareName = res.data.data.compareName;
return true
} else {
flag = 'error';
show = true;
errorMsg = res.data.data
}
}).then(c => {
if(c) {
flag = 'success';
successMsg = '对比成功';
show = true;
handleDownload()
handleDelete()
}
})
} else if(len == 1) {
window.alert('设计图或开发图缺少,请确认已全部上传后再进行比较!')
} else {
window.alert('必须有图片才能进行比较!')
}
};
const handleBeforeUpload = async function(rawFile, id) {
const fileBase64 = await getBase64(rawFile);
const res = await getPixel(fileBase64);
// console.log('res', res)
if(res.width == width && res.height == height) {
switch (id) {
case 'design':
uploaders[0]['url'] = fileBase64
break;
case 'code':
uploaders[1]['url'] = fileBase64
break;
default:
break;
}
return true;
} else {
window.alert('上传图片不符合分比率要求');
return false;
}
}
const handleSuccess = (response, rawFile, id) => {
console.log('response', response, rawFile, id);
if(response.success) {
switch (id) {
case 'design':
uploaders[0]['filename'] = response.data.filename
break;
case 'code':
uploaders[1]['filename'] = response.data.filename
break;
default:
break;
}
}
}
function handleDownload() {
axios({
method: 'POST',
url: downloadUrl,
responseType: 'blob',
data: {
compareName: compareName
}
}).then(res => {
console.log('download', res)
if(res.status == 200) {
var blob = new Blob([res.data]);
// 创建一个URL对象
var url = window.URL.createObjectURL(blob);
console.log('url', url)
// 创建一个a标签
var a = document.createElement("a");
a.href = url;
a.download = compareName;// 这里指定下载文件的文件名
a.click();
// 释放之前创建的URL对象
window.URL.revokeObjectURL(url);
}
})
}
function handleDelete() {
uploaders = [
{
id: 'design',
title: '设计图',
filename: '',
url: ''
},
{
id: 'code',
title: '实现图',
filename: '',
url: ''
}
];
}
</script>
<style>
.pixel-piper {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.main {
width: 600px;
margin: 0 auto;
padding: 20px;
border: 1px solid #eee;
background-color: #fff;
border-radius: 4px;
box-shadow: 0 0 10px #e0e0e0;
}
.main .logo-container {
display: flex;
justify-content: center;
align-items: center;
}
.logo-container .logo:hover {
opacity: 90%;
cursor: pointer;
}
.main .select-container {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
line-height: 40px;
}
.main .upload-container {
display: flex;
padding: 0 0 10px 0;
justify-content: space-between;
text-align: center;
}
.main .button-container {
display: flex;
justify-content: center;
align-items: center;
}
.main .info-container {
text-align: center;
color: red;
font-size: 12px;
margin: 10px 0;
}
</style>
<div class="pixel-piper" style="{`background: url(${bgUrl}) no-repeat; background-size: cover`}">
<section class="main">
<div class="logo-container">
<Tooltip content="点击logo可下载chrome插件" {tooltip}>
<a href={crxUrl}>
<img
class="logo"
src={logoUrl}
alt="logo"
width="100px"
on:mouseenter={() => tooltip=true}
on:mouseleave={() => tooltip=false}
>
</a>
</Tooltip>
</div>
<div class="select-container">
<p class="select-name"><Input bind:value={width} /> x <Input bind:value={height} /> </p>
</div>
<div class="upload-container">
{#each uploaders as uploader}
<div class="uploader">
<Upload
id={uploader.id}
onSuccess={handleSuccess}
beforeUpload={handleBeforeUpload}
action={actionUrl}
>
{#if !uploader.url}
<Icon name="iconplus" />
{:else}
<img class="uploader-image" style="object-fit: contain;" width="250" height="250" src={uploader.url} alt={uploader.id} />
{/if}
</Upload>
<span class="uploader-title">{uploader.title}</span>
</div>
{/each}
</div>
<div class="info-container">
{#if uploaders.filter(f => !!f.filename).length == 2}
<span class="info-tips">注:请在两分钟内进行图片对比!!</span>
{/if}
</div>
<div class="button-container">
<Button icon="iconposition" on:click={handleCompare} type="primary">对比</Button>
{#if uploaders.filter(f => !!f.filename).length == 2}
<div style="margin-left: 10px">
<Button icon="icondelete" on:click={handleDelete} type="default">清除图片</Button>
</div>
{/if}
</div>
</section>
{#if flag == 'success'}
<Message type="success" {show} >{successMsg}</Message>
{:else if flag == 'error'}
<Message type="error" {show} >{errorMsg}</Message>
{/if}
</div>
总结
作为前端工程师,我们是距离用户侧最近的研发同学,不仅仅在于完成业务的代码实现,而且不同于其他研发同学,我们也承担着产品用户体验的重要职责,而这其中页面的还原程度是一项重要的指标,能够让设计同学认可我们完成的工作也是评价大家前端能力的一个维度,毕竟上下游通力合作,才能把产品体验做到最佳,共勉!!!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。