diff --git a/src/App.js b/src/App.js index 7b13e1f..c0a777f 100644 --- a/src/App.js +++ b/src/App.js @@ -33,11 +33,13 @@ class App extends Component { sidebar_stack: [[null, null]], // list of [status, content] mode: 'list', // list, single, search, attention search_text: null, + show_search_intro: false, flow_render_key: +new Date(), token: localStorage['TOKEN'] || null, }; this.show_sidebar_bound = this.show_sidebar.bind(this); this.set_mode_bound = this.set_mode.bind(this); + this.set_search_intro_bound = this.set_search_intro.bind(this); this.on_pressure_bound = this.on_pressure.bind(this); window.BACKEND = @@ -120,10 +122,17 @@ class App extends Component { this.setState({ mode: mode, search_text: search_text, + show_search_intro: false, flow_render_key: +new Date(), }); } + set_search_intro(show_search_intro) { + this.setState({ + show_search_intro: show_search_intro, + }); + } + render() { return ( @@ -168,6 +178,7 @@ class App extends Component { show_sidebar={this.show_sidebar_bound} mode={this.state.mode} search_text={this.state.search_text} + show_search_intro={this.state.show_search_intro} token={token.value} /> ) : ( diff --git a/src/Flows.css b/src/Flows.css index 6961cff..1d76655 100644 --- a/src/Flows.css +++ b/src/Flows.css @@ -15,7 +15,9 @@ .root-dark-mode .box { background-color: var(--box-bgcolor-dark); color: var(--foreground-dark); - box-shadow: 0 0 2px rgba(255, 255, 255, 0.25), 0 0 7px rgba(0, 0, 0, 0.15); + box-shadow: + 0 0 2px rgba(255, 255, 255, 0.25), + 0 0 7px rgba(0, 0, 0, 0.15); } .box-tip { @@ -92,9 +94,12 @@ left: 10px; margin-top: 1.5em; color: white; - text-shadow: /* copied from .black-outline */ -1px -1px 0 rgba(0, 0, 0, 0.6), - 0 -1px 0 rgba(0, 0, 0, 0.6), 1px -1px 0 rgba(0, 0, 0, 0.6), - -1px 1px 0 rgba(0, 0, 0, 0.6), 0 1px 0 rgba(0, 0, 0, 0.6), + text-shadow: /* copied from .black-outline */ + -1px -1px 0 rgba(0, 0, 0, 0.6), + 0 -1px 0 rgba(0, 0, 0, 0.6), + 1px -1px 0 rgba(0, 0, 0, 0.6), + -1px 1px 0 rgba(0, 0, 0, 0.6), + 0 1px 0 rgba(0, 0, 0, 0.6), 1px 1px 0 rgba(0, 0, 0, 0.6); font-family: 'Consolas', 'Courier', monospace; } @@ -206,6 +211,10 @@ overflow-y: hidden; } +.left-container .search-intro .box-content { + max-height: none; +} + .box-id { color: #666666; } @@ -430,7 +439,6 @@ stroke: #777; } - .vote-icon-active { fill: #333; } diff --git a/src/Flows.js b/src/Flows.js index 108d604..7bc79a5 100644 --- a/src/Flows.js +++ b/src/Flows.js @@ -18,6 +18,7 @@ import { API, parse_replies } from './flows_api'; import { cache } from './cache'; import { save_attentions } from './Attention'; import Poll from 'react-polls'; +import { SEARCH_INTRO_MARKDOWN } from './SearchIntro'; /* const IMAGE_BASE = 'https://thimg.yecdn.com/'; @@ -1425,6 +1426,26 @@ function Announcement(props) { ); } +function SearchIntro() { + return ( +
+
+
+
+
+ +  搜索指南 +
+
+ +
+
+
+
+
+ ); +} + export class Flow extends PureComponent { constructor(props) { super(props); @@ -1473,7 +1494,8 @@ export class Flow extends PureComponent { render() { const { submode, announcement } = this.state; - const { mode, show_sidebar, search_text, token } = this.props; + const { mode, show_sidebar, search_text, show_search_intro, token } = + this.props; const submode_names = this.get_submode_names(mode); return ( <> @@ -1503,6 +1525,7 @@ export class Flow extends PureComponent { mode={mode} submode={submode} search_text={search_text} + show_search_intro={show_search_intro} token={token} /> @@ -1809,9 +1832,11 @@ class SubFlow extends PureComponent { render() { const should_deletion_detect = localStorage['DELETION_DETECT'] === 'on'; const { mode, chunks, local_attention_text, search_param } = this.state; - const { submode, show_sidebar } = this.props; + const { submode, show_sidebar, show_search_intro } = this.props; return (
+ {show_search_intro && } + {mode === 'attention' && submode === 1 && local_attention_text === null && ( diff --git a/src/SearchIntro.js b/src/SearchIntro.js new file mode 100644 index 0000000..6ea4b22 --- /dev/null +++ b/src/SearchIntro.js @@ -0,0 +1,37 @@ +export const SEARCH_INTRO_MARKDOWN = ` +树洞提供了较为丰富的搜索功能,可以支持复杂的搜索条件。搜索分为两个大类,线上全局搜索,和在已关注的帖子中过滤。 + +# 线上全局搜索 + +直接点击搜索框,进入全局搜索。有三个子模式,Tag搜索,全文搜索,和头衔。Tag搜索会查询所有匹配的 #hashtag 和折叠警告,包括 #举报 。搜索头衔会查询对应头衔发布的内容。全文搜索会全文查找匹配的搜索词,并支持一些高级语法。 + +## 全文搜索的基本语法 + +全文搜索支持一个或多个关键词,多个关键词用空格分隔。支持 AND OR 语法,空格默认视为 OR 。搜索 **电子 直博** 等效于 **电子 OR 直博** 。支持括号,例如 **(贵系 OR 雷系) AND 好工作** 。支持单双引号,例如 **"and me"** , **'"鹅腿"'** 。 + +此外,可以直接使用 + - 符号标记必须包含和必须排除。例如,可以搜索 **+(贵系 雷系) +好工作** 。 + +## 字段过滤与帖子范围筛选 + +支持按照字段进行更复杂的筛选,包括content comment post_id 三个字段。例如 **content:避雷 AND comment:cy** 。可以使用 IN 语法,例如可以用 **content: IN [微积分 线代]** 替代 **content:微积分 OR content:线代**。 + +可以使用post_id字段限定帖子范围,例如 **+(毕业 出二手 二手) +post_id:[700000 TO \\*]** 。 + +## 搜索结果的排序 + +单个关键词的简单搜索会按照帖子从新到旧排序。复杂的搜索会按匹配程度排序,与出现次数、全文长度、搜索词稀有度等有关。主贴和评论分别记分,且主贴的权重更大。可以在单关键词的结尾加空格来改为匹配程度排序。 + +如需提高某个关键词的权重,可以使用 ^,例如 **微积分^4.0 线代** 会给微积分更高的权重。**+微积分 线代** 则只返回包含微积分的结果,且优先返回同时包含线代的结果。 + +# 在关注列表中搜索 + +展示关注列表的情况下,点击搜索框,进入关注列表搜索。 + +关注列表搜索在本地进行过滤,支持多关键词和正则表达式。请将正则包裹在/ / 中。例如, **/\\d{2,}(万|w)/** 。 + +关注列表搜索基本只考虑主贴,只有评论极少的洞会也考虑评论。 + +# 反馈 + +如果你发现搜索结果不符合预期,例如有应该出现的内容没有出现,请带上 #搜索反馈 发帖反馈。如果你希望涉及你个人信息的内容不要出现在搜索结果中,请使用举报功能。 +`; diff --git a/src/Title.js b/src/Title.js index ebcd2dd..033ddde 100644 --- a/src/Title.js +++ b/src/Title.js @@ -12,15 +12,26 @@ class ControlBar extends PureComponent { super(props); this.state = { search_text: '', + search_focused: false, + search_submitted: false, }; this.set_mode = props.set_mode; this.on_change_bound = this.on_change.bind(this); + this.on_focus_bound = this.on_focus.bind(this); + this.on_blur_bound = this.on_blur.bind(this); this.on_keypress_bound = this.on_keypress.bind(this); this.do_refresh_bound = this.do_refresh.bind(this); this.do_attention_bound = this.do_attention.bind(this); } + update_search_intro(search_text, search_focused, search_submitted) { + const has_search_text = search_text.trim().length > 0; + this.props.set_search_intro( + !search_submitted && (has_search_text || search_focused), + ); + } + componentDidMount() { if (window.location.hash) { let text = decodeURIComponent(window.location.hash).substr(1); @@ -60,13 +71,50 @@ class ControlBar extends PureComponent { } on_change(event) { + const search_text = event.target.value; + const search_submitted = + search_text.length === 0 ? false : this.state.search_submitted; this.setState({ - search_text: event.target.value, + search_text: search_text, + search_submitted: search_submitted, }); + this.update_search_intro( + search_text, + this.state.search_focused, + search_submitted, + ); + } + + on_focus() { + this.setState({ + search_focused: true, + }); + this.update_search_intro( + this.state.search_text, + true, + this.state.search_submitted, + ); + } + + on_blur(event) { + if (event.relatedTarget?.closest('.search-intro')) return; + + this.setState({ + search_focused: false, + }); + this.update_search_intro( + this.state.search_text, + false, + this.state.search_submitted, + ); } on_keypress(event) { if (event.key === 'Enter') { + this.setState({ + search_submitted: true, + }); + this.props.set_search_intro(false); let flag_res = flag_re.exec(this.state.search_text); if (flag_res) { if (flag_res[2]) { @@ -102,7 +150,9 @@ class ControlBar extends PureComponent { window.scrollTo(0, 0); this.setState({ search_text: '', + search_submitted: false, }); + this.props.set_search_intro(false); this.set_mode('list', null); window.location.hash = ''; } @@ -111,7 +161,9 @@ class ControlBar extends PureComponent { window.scrollTo(0, 0); this.setState({ search_text: '', + search_submitted: false, }); + this.props.set_search_intro(false); this.set_mode('attention', null); } @@ -147,6 +199,8 @@ class ControlBar extends PureComponent { : '关键词 / tag / #树洞号' } onChange={this.on_change_bound} + onFocus={this.on_focus_bound} + onBlur={this.on_blur_bound} onKeyPress={this.on_keypress_bound} />