提交 228d752f 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 b539ac1d
......@@ -617,7 +617,7 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.hidden = function(e) {
var $input;
this.resetRows();
this.removeArrayKeyEvent();
this.removeArrowKeyEvent();
$input = this.dropdown.find('.dropdown-input-field');
if (this.options.filterable) {
$input.blur();
......@@ -900,7 +900,7 @@ GitLabDropdown = (function() {
);
};
GitLabDropdown.prototype.removeArrayKeyEvent = function() {
GitLabDropdown.prototype.removeArrowKeyEvent = function() {
return $('body').off('keydown');
};
......
<script>
import { GlLink } from '@gitlab/ui';
export default {
components: {
GlLink,
},
props: {
currentPath: {
type: String,
required: false,
default: null,
},
links: {
type: Array,
required: true,
},
},
computed: {
normalizedLinks() {
return this.links.map(link => ({
text: link.text,
path: `${link.path}?path=${this.currentPath}`,
}));
},
},
};
</script>
<template>
<section class="border-top pt-1 mt-1">
<h5 class="m-0 dropdown-bold-header">{{ __('Download this directory') }}</h5>
<div class="dropdown-menu-content">
<div class="btn-group ml-0 w-100">
<gl-link
v-for="(link, index) in normalizedLinks"
:key="index"
:href="link.path"
:class="{ 'btn-primary': index === 0 }"
class="btn btn-xs"
>
{{ link.text }}
</gl-link>
</div>
</div>
</section>
</template>
<script>
import { GlLink } from '@gitlab/ui';
export default {
components: {
GlLink,
},
props: {
path: {
type: String,
required: true,
},
text: {
type: String,
required: true,
},
cssClass: {
type: String,
required: false,
default: null,
},
},
};
</script>
<template>
<gl-link :href="path" :class="cssClass" class="btn">{{ text }}</gl-link>
</template>
......@@ -3,9 +3,13 @@ import createRouter from './router';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import LastCommit from './components/last_commit.vue';
import TreeActionLink from './components/tree_action_link.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
import apolloProvider from './graphql';
import { setTitle } from './utils/title';
import { parseBoolean } from '../lib/utils/common_utils';
import { webIDEUrl } from '../lib/utils/url_utility';
import { __ } from '../locale';
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
......@@ -91,6 +95,66 @@ export default function setupVueRepositoryList() {
},
});
const treeHistoryLinkEl = document.getElementById('js-tree-history-link');
const { historyLink } = treeHistoryLinkEl.dataset;
// eslint-disable-next-line no-new
new Vue({
el: treeHistoryLinkEl,
router,
render(h) {
return h(TreeActionLink, {
props: {
path: historyLink + (this.$route.params.pathMatch || '/'),
text: __('History'),
},
});
},
});
const webIdeLinkEl = document.getElementById('js-tree-web-ide-link');
if (webIdeLinkEl) {
// eslint-disable-next-line no-new
new Vue({
el: webIdeLinkEl,
router,
render(h) {
return h(TreeActionLink, {
props: {
path: webIDEUrl(`/${projectPath}/edit/${ref}/-${this.$route.params.pathMatch || '/'}`),
text: __('Web IDE'),
cssClass: 'qa-web-ide-button',
},
});
},
});
}
const directoryDownloadLinks = document.getElementById('js-directory-downloads');
if (directoryDownloadLinks) {
// eslint-disable-next-line no-new
new Vue({
el: directoryDownloadLinks,
router,
render(h) {
const currentPath = this.$route.params.pathMatch || '/';
if (currentPath !== '/') {
return h(DirectoryDownloadLinks, {
props: {
currentPath: currentPath.replace(/^\//, ''),
links: JSON.parse(directoryDownloadLinks.dataset.links),
},
});
}
return null;
},
});
}
// eslint-disable-next-line no-new
new Vue({
el,
......
/* eslint-disable no-return-assign, one-var, no-var, consistent-return, class-methods-use-this, no-lonely-if, vars-on-top */
/* eslint-disable no-return-assign, one-var, no-var, consistent-return, class-methods-use-this, vars-on-top */
import $ from 'jquery';
import { escape, throttle } from 'underscore';
......@@ -95,7 +95,6 @@ export class SearchAutocomplete {
this.createAutocomplete();
}
this.saveTextLength();
this.bindEvents();
this.dropdownToggle.dropdown();
this.searchInput.addClass('js-autocomplete-disabled');
......@@ -107,7 +106,7 @@ export class SearchAutocomplete {
this.onClearInputClick = this.onClearInputClick.bind(this);
this.onSearchInputFocus = this.onSearchInputFocus.bind(this);
this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this);
this.onSearchInputKeyDown = this.onSearchInputKeyDown.bind(this);
this.onSearchInputChange = this.onSearchInputChange.bind(this);
this.setScrollFade = this.setScrollFade.bind(this);
}
getElement(selector) {
......@@ -118,10 +117,6 @@ export class SearchAutocomplete {
return (this.originalState = this.serializeState());
}
saveTextLength() {
return (this.lastTextLength = this.searchInput.val().length);
}
createAutocomplete() {
return this.searchInput.glDropdown({
filterInputBlur: false,
......@@ -318,12 +313,16 @@ export class SearchAutocomplete {
}
bindEvents() {
this.searchInput.on('keydown', this.onSearchInputKeyDown);
this.searchInput.on('input', this.onSearchInputChange);
this.searchInput.on('keyup', this.onSearchInputKeyUp);
this.searchInput.on('focus', this.onSearchInputFocus);
this.searchInput.on('blur', this.onSearchInputBlur);
this.clearInput.on('click', this.onClearInputClick);
this.dropdownContent.on('scroll', throttle(this.setScrollFade, 250));
this.searchInput.on('click', e => {
e.stopPropagation();
});
}
enableAutocomplete() {
......@@ -342,43 +341,19 @@ export class SearchAutocomplete {
}
}
// Saves last length of the entered text
onSearchInputKeyDown() {
return this.saveTextLength();
onSearchInputChange() {
this.enableAutocomplete();
}
onSearchInputKeyUp(e) {
switch (e.keyCode) {
case KEYCODE.BACKSPACE:
// When removing the last character and no badge is present
if (this.lastTextLength === 1) {
this.disableAutocomplete();
}
// When removing any character from existin value
if (this.lastTextLength > 1) {
this.enableAutocomplete();
}
break;
case KEYCODE.ESCAPE:
this.restoreOriginalState();
break;
case KEYCODE.ENTER:
this.disableAutocomplete();
break;
case KEYCODE.UP:
case KEYCODE.DOWN:
return;
default:
// Handle the case when deleting the input value other than backspace
// e.g. Pressing ctrl + backspace or ctrl + x
if (this.searchInput.val() === '') {
this.disableAutocomplete();
} else {
// We should display the menu only when input is not empty
if (e.keyCode !== KEYCODE.ENTER) {
this.enableAutocomplete();
}
}
}
this.wrap.toggleClass('has-value', Boolean(e.target.value));
}
......@@ -434,7 +409,7 @@ export class SearchAutocomplete {
disableAutocomplete() {
if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) {
this.searchInput.addClass('js-autocomplete-disabled');
this.dropdown.removeClass('show').trigger('hidden.bs.dropdown');
this.dropdown.dropdown('toggle');
this.restoreMenu();
}
}
......
......@@ -2,8 +2,10 @@
module Types
class TodoTargetEnum < BaseEnum
value 'Issue'
value 'MergeRequest'
value 'Epic'
value 'COMMIT', value: 'Commit', description: 'A Commit'
value 'ISSUE', value: 'Issue', description: 'An Issue'
value 'MERGEREQUEST', value: 'MergeRequest', description: 'A MergeRequest'
end
end
Types::TodoTargetEnum.prepend_if_ee('::EE::Types::TodoTargetEnum')
......@@ -40,7 +40,8 @@ module Types
field :body, GraphQL::STRING_TYPE,
description: 'Body of the todo',
null: false
null: false,
calls_gitaly: true # TODO This is only true when `target_type` is `Commit`. See https://gitlab.com/gitlab-org/gitlab/issues/34757#note_234752665
field :state, Types::TodoStateEnum,
description: 'State of the todo',
......
......@@ -195,6 +195,17 @@ module TreeHelper
full_name: project.name_with_namespace
}
end
def directory_download_links(project, ref, archive_prefix)
formats = ['zip', 'tar.gz', 'tar.bz2', 'tar']
formats.map do |fmt|
{
text: fmt,
path: project_archive_path(project, id: tree_join(ref, archive_prefix), format: fmt)
}
end
end
end
TreeHelper.prepend_if_ee('::EE::TreeHelper')
......@@ -23,7 +23,7 @@ module Clusters
end
def validate_params(cluster)
if params[:management_project_id]
if params[:management_project_id].present?
management_project = management_project_scope(cluster).find_by_id(params[:management_project_id])
unless management_project
......
......@@ -12,11 +12,14 @@
%h5.m-0.dropdown-bold-header= _('Download source code')
.dropdown-menu-content
= render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil
- if directory? && Feature.enabled?(:git_archive_path, default_enabled: true)
%section.border-top.pt-1.mt-1
%h5.m-0.dropdown-bold-header= _('Download this directory')
.dropdown-menu-content
= render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path
- if Feature.enabled?(:git_archive_path, default_enabled: true)
- if vue_file_list_enabled?
#js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } }
- elsif directory?
%section.border-top.pt-1.mt-1
%h5.m-0.dropdown-bold-header= _('Download this directory')
.dropdown-menu-content
= render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path
- if pipeline && pipeline.latest_builds_with_artifacts.any?
%section.border-top.pt-1.mt-1
%h5.m-0.dropdown-bold-header= _('Download artifacts')
......
......@@ -77,15 +77,21 @@
.tree-controls
= render_if_exists 'projects/tree/lock_link'
= link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
- if vue_file_list_enabled?
#js-tree-history-link.d-inline-block{ data: { history_link: project_commits_path(@project, @ref) } }
- else
= link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
= render 'projects/find_file_link'
- if can_create_mr_from_fork
= succeed " " do
- if can_collaborate || current_user&.already_forked?(@project)
= link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do
= _('Web IDE')
- if vue_file_list_enabled?
#js-tree-web-ide-link.d-inline-block
- else
= link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do
= _('Web IDE')
- else
= link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do
= _('Web IDE')
......
---
title: 'Puma only: database connection pool now always >= number of worker threads'
merge_request: 19286
author:
type: performance
---
title: Fix keyboard shortcuts in header search autocomplete
merge_request: 18685
author:
type: fixed
---
title: Update expired trial status copy
merge_request: 18962
author:
type: changed
---
title: Fix errors in GraphQL Todos API due to missing TargetTypeEnum values
merge_request: 19052
author:
type: fixed
---
title: Add endpoint for a group's vulnerable projects
merge_request: 15317
author:
type: added
---
title: Remove empty Github service templates from database
merge_request: 18868
author:
type: fixed
---
title: Enforce default, global project and snippet visibilities
merge_request: 19188
author:
type: fixed
# frozen_string_literal: true
# when running on puma, scale connection pool size with the number
# of threads per worker process
if defined?(::Puma)
db_config = Gitlab::Database.config ||
Rails.application.config.database_configuration[Rails.env]
puma_options = Puma.cli_config.options
# We use either the maximum number of threads per worker process, or
# the user specified value, whichever is larger.
desired_pool_size = [db_config['pool'].to_i, puma_options[:max_threads]].max
db_config['pool'] = desired_pool_size
# recreate the connection pool from the new config
ActiveRecord::Base.establish_connection(db_config)
end
# frozen_string_literal: true
class SetApplicationSettingsDefaultProjectAndSnippetVisibility < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
change_column_null :application_settings, :default_project_visibility, false, 0
change_column_default :application_settings, :default_project_visibility, from: nil, to: 0
change_column_null :application_settings, :default_snippet_visibility, false, 0
change_column_default :application_settings, :default_snippet_visibility, from: nil, to: 0
end
end
# frozen_string_literal: true
## It's expected to delete one record on GitLab.com
#
class RemoveEmptyGithubServiceTemplates < ActiveRecord::Migration[5.2]
DOWNTIME = false
class Service < ActiveRecord::Base
self.table_name = 'services'
self.inheritance_column = :_type_disabled
serialize :properties, JSON
end
def up
relationship.where(properties: {}).delete_all
end
def down
relationship.find_or_create_by!(properties: {})
end
private
def relationship
RemoveEmptyGithubServiceTemplates::Service.where(template: true, type: 'GithubService')
end
end
......@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_10_26_041447) do
ActiveRecord::Schema.define(version: 2019_10_26_124116) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
......@@ -158,8 +158,8 @@ ActiveRecord::Schema.define(version: 2019_10_26_041447) do
t.text "restricted_visibility_levels"
t.boolean "version_check_enabled", default: true
t.integer "max_attachment_size", default: 10, null: false
t.integer "default_project_visibility"
t.integer "default_snippet_visibility"
t.integer "default_project_visibility", default: 0, null: false
t.integer "default_snippet_visibility", default: 0, null: false
t.text "domain_whitelist"
t.boolean "user_oauth_applications", default: true
t.string "after_sign_out_path"
......
......@@ -5,7 +5,7 @@ require 'rails/generators'
module Rails
class PostDeploymentMigrationGenerator < Rails::Generators::NamedBase
def create_migration_file
timestamp = Time.now.strftime('%Y%m%d%H%M%S')
timestamp = Time.now.utc.strftime('%Y%m%d%H%M%S')
template "migration.rb", "db/post_migrate/#{timestamp}_#{file_name}.rb"
end
......
......@@ -2421,9 +2421,6 @@ msgstr ""
msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}, or start a free 30-day trial of GitLab.com Gold."
msgstr ""
msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}."
msgstr ""
msgid "BillingPlans|Learn more about each plan by visiting our %{pricing_page_link}."
msgstr ""
......@@ -2442,18 +2439,15 @@ msgstr ""
msgid "BillingPlans|To manage the plan for this group, visit the billing section of %{parent_billing_page_link}."
msgstr ""
msgid "BillingPlans|Your GitLab.com trial expired on %{expiration_date}. %{learn_more_text}"
msgid "BillingPlans|Your GitLab.com Gold trial expired on %{expiration_date}. You can restore access to the Gold features at any time by upgrading below."
msgstr ""
msgid "BillingPlans|Your GitLab.com trial will <strong>expire after %{expiration_date}</strong>. You can learn more about GitLab.com Gold by reading about our %{features_link}."
msgid "BillingPlans|Your GitLab.com Gold trial will <strong>expire after %{expiration_date}</strong>. You can retain access to the Gold features by upgrading below."
msgstr ""
msgid "BillingPlans|billed annually at %{price_per_year}"
msgstr ""
msgid "BillingPlans|features"
msgstr ""
msgid "BillingPlans|frequently asked questions"
msgstr ""
......
......@@ -26,6 +26,16 @@ describe 'User uses header search field', :js do
end
end
context 'when using the keyboard shortcut' do
before do
find('body').native.send_keys('s')
end
it 'shows the category search dropdown' do
expect(page).to have_selector('.dropdown-header', text: /#{scope_name}/i)
end
end
context 'when clicking the search field' do
before do
page.find('#search.js-autocomplete-disabled').click
......@@ -77,15 +87,21 @@ describe 'User uses header search field', :js do
end
context 'when entering text into the search field' do
before do
it 'does not display the category search dropdown' do
page.within('.search-input-wrap') do
fill_in('search', with: scope_name.first(4))
end
end
it 'does not display the category search dropdown' do
expect(page).not_to have_selector('.dropdown-header', text: /#{scope_name}/i)
end
it 'hides the dropdown when there are no results' do
page.within('.search-input-wrap') do
fill_in('search', with: 'a_search_term_with_no_results')
end
expect(page).not_to have_selector('.dropdown-menu')
end
end
end
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Repository directory download links component renders downloads links for path app 1`] = `
<section
class="border-top pt-1 mt-1"
>
<h5
class="m-0 dropdown-bold-header"
>
Download this directory
</h5>
<div
class="dropdown-menu-content"
>
<div
class="btn-group ml-0 w-100"
>
<gllink-stub
class="btn btn-xs btn-primary"
href="http://test.com/?path=app"
>
zip
</gllink-stub>
<gllink-stub
class="btn btn-xs"
href="http://test.com/?path=app"
>
tar
</gllink-stub>
</div>
</div>
</section>
`;
exports[`Repository directory download links component renders downloads links for path app/assets 1`] = `
<section
class="border-top pt-1 mt-1"
>
<h5
class="m-0 dropdown-bold-header"
>
Download this directory
</h5>
<div
class="dropdown-menu-content"
>
<div
class="btn-group ml-0 w-100"
>
<gllink-stub
class="btn btn-xs btn-primary"
href="http://test.com/?path=app/assets"
>
zip
</gllink-stub>
<gllink-stub
class="btn btn-xs"
href="http://test.com/?path=app/assets"
>
tar
</gllink-stub>
</div>
</div>
</section>
`;
import { shallowMount } from '@vue/test-utils';
import DirectoryDownloadLinks from '~/repository/components/directory_download_links.vue';
let vm;
function factory(currentPath) {
vm = shallowMount(DirectoryDownloadLinks, {
propsData: {
currentPath,
links: [{ text: 'zip', path: 'http://test.com/' }, { text: 'tar', path: 'http://test.com/' }],
},
});
}
describe('Repository directory download links component', () => {
afterEach(() => {
vm.destroy();
});
it.each`
path
${'app'}
${'app/assets'}
`('renders downloads links for path $path', ({ path }) => {
factory(path);
expect(vm.element).toMatchSnapshot();
});
});
import Vue from 'vue';
import { formatDate } from '~/lib/utils/datetime_utility';
import { mount, createLocalVue } from '@vue/test-utils';
import { PathIdSeparator } from 'ee/related_issues/constants';
import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
import {
defaultAssignees,
......@@ -12,7 +13,7 @@ describe('RelatedIssuableItem', () => {
const props = {
idKey: 1,
displayReference: 'gitlab-org/gitlab-test#1',
pathIdSeparator: '#',
pathIdSeparator: PathIdSeparator.Issue,
path: `${gl.TEST_HOST}/path`,
title: 'title',
confidential: true,
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Database config initializer' do
subject do
load Rails.root.join('config/initializers/database_config.rb')
end
before do
allow(ActiveRecord::Base).to receive(:establish_connection)
end
context "when using Puma" do
let(:puma) { double('puma') }
let(:puma_options) { { max_threads: 8 } }
before do
stub_const("Puma", puma)
allow(puma).to receive_message_chain(:cli_config, :options).and_return(puma_options)
end
context "and no existing pool size is set" do
before do
stub_database_config(pool_size: nil)
end
it "sets it to the max number of worker threads" do
expect { subject }.to change { Gitlab::Database.config['pool'] }.from(nil).to(8)
end
end
context "and the existing pool size is smaller than the max number of worker threads" do
before do
stub_database_config(pool_size: 7)
end
it "sets it to the max number of worker threads" do
expect { subject }.to change { Gitlab::Database.config['pool'] }.from(7).to(8)
end
end
context "and the existing pool size is larger than the max number of worker threads" do
before do
stub_database_config(pool_size: 9)
end
it "keeps the configured pool size" do
expect { subject }.not_to change { Gitlab::Database.config['pool'] }
end
end
end
context "when not using Puma" do
before do
stub_database_config(pool_size: 7)
end
it "does nothing" do
expect { subject }.not_to change { Gitlab::Database.config['pool'] }
end
end
def stub_database_config(pool_size:)
config = {
'adapter' => 'postgresql',
'host' => 'db.host.com',
'pool' => pool_size
}.compact
allow(Gitlab::Database).to receive(:config).and_return(config)
end
end
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20191021101942_remove_empty_github_service_templates.rb')
describe RemoveEmptyGithubServiceTemplates, :migration do
subject(:migration) { described_class.new }
let(:services) do
table(:services).tap do |klass|
klass.class_eval do
serialize :properties, JSON
end
end
end
before do
services.delete_all
create_service(properties: nil)
create_service(properties: {})
create_service(properties: { some: :value })
create_service(properties: {}, template: false)
create_service(properties: {}, type: 'SomeType')
end
def all_service_properties
services.where(template: true, type: 'GithubService').pluck(:properties)
end
it 'correctly migrates up and down service templates' do
reversible_migration do |migration|
migration.before -> do
expect(services.count).to eq(5)
expect(all_service_properties)
.to match(a_collection_containing_exactly(nil, {}, { 'some' => 'value' }))
end
migration.after -> do
expect(services.count).to eq(4)
expect(all_service_properties)
.to match(a_collection_containing_exactly(nil, { 'some' => 'value' }))
end
end
end
def create_service(params)
data = { template: true, type: 'GithubService' }
data.merge!(params)
services.create!(data)
end
end
......@@ -13,7 +13,7 @@ describe 'Query current user todos' do
let(:fields) do
<<~QUERY
nodes {
id
#{all_graphql_fields_for('todos'.classify)}
}
QUERY
end
......@@ -28,6 +28,8 @@ describe 'Query current user todos' do
post_graphql(query, current_user: current_user)
end
it_behaves_like 'a working graphql query'
it 'contains the expected ids' do
is_expected.to include(
a_hash_including('id' => commit_todo.to_global_id.to_s),
......@@ -35,4 +37,12 @@ describe 'Query current user todos' do
a_hash_including('id' => merge_request_todo.to_global_id.to_s)
)
end
it 'returns Todos for all target types' do
is_expected.to include(
a_hash_including('targetType' => 'COMMIT'),
a_hash_including('targetType' => 'ISSUE'),
a_hash_including('targetType' => 'MERGEREQUEST')
)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'getting project information' do
include GraphqlHelpers
let(:query) do
graphql_query_for('currentUser', {}, 'name')
end
subject { graphql_data['currentUser'] }
before do
post_graphql(query, current_user: current_user)
end
context 'when there is a current_user' do
set(:current_user) { create(:user) }
it_behaves_like 'a working graphql query'
it { is_expected.to include('name' => current_user.name) }
end
context 'when there is no current_user' do
let(:current_user) { nil }
it_behaves_like 'a working graphql query'
it { is_expected.to be_nil }
end
end
......@@ -138,6 +138,23 @@ describe Clusters::UpdateService do
expect(cluster.management_project_id).to be_nil
end
end
context 'cluster already has a management project set' do
before do
cluster.update!(management_project: create(:project))
end
let(:params) do
{ management_project_id: '' }
end
it 'unsets management_project_id' do
is_expected.to eq(true)
cluster.reload
expect(cluster.management_project_id).to be_nil
end
end
end
context 'project cluster' do
......
文件模式从 100644 更改为 100755
文件模式从 100644 更改为 100755
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册