Merge branch 'github-main' into feat/1.2.0/state

# Conflicts:
#	cmd/wire_gen.go
#	internal/service/question_common/question.go
#	internal/service/siteinfo/siteinfo_service.go
This commit is contained in:
LinkinStars 2023-06-13 16:43:06 +08:00
commit c49fa00570
71 changed files with 2603 additions and 1375 deletions

64
.github/Dockerfile vendored
View File

@ -1,64 +0,0 @@
FROM amd64/node:18 AS node-builder
LABEL maintainer="mingcheng<mc@sf.com>"
COPY . /answer
WORKDIR /answer
RUN node -v
RUN make install-ui-packages ui && mv ui/build /tmp
# stage2 build the main binary within static resource
FROM golang:1.19-alpine AS golang-builder
LABEL maintainer="aichy@sf.com"
ARG GOPROXY
# ENV GOPROXY ${GOPROXY:-direct}
ENV GOPROXY=https://goproxy.io,direct
ENV GOPATH /go
ENV GOROOT /usr/local/go
ENV PACKAGE github.com/answerdev/answer
ENV BUILD_DIR ${GOPATH}/src/${PACKAGE}
ENV ANSWER_MODULE ${BUILD_DIR}
ARG TAGS="sqlite sqlite_unlock_notify"
ENV TAGS "bindata timetzdata $TAGS"
ARG CGO_EXTRA_CFLAGS
COPY . ${BUILD_DIR}
WORKDIR ${BUILD_DIR}
COPY --from=node-builder /tmp/build ${BUILD_DIR}/ui/build
RUN apk --no-cache add build-base git bash \
&& make clean build
RUN chmod 755 answer
RUN ["/bin/bash","-c","script/build_plugin.sh"]
RUN cp answer /usr/bin/answer
RUN mkdir -p /data/uploads && chmod 777 /data/uploads \
&& mkdir -p /data/i18n && cp -r i18n/*.yaml /data/i18n
# stage3 copy the binary and resource files into fresh container
FROM alpine
LABEL maintainer="maintainers@sf.com"
ENV TZ "Asia/Shanghai"
RUN apk update \
&& apk --no-cache add \
bash \
ca-certificates \
curl \
dumb-init \
gettext \
openssh \
sqlite \
gnupg \
&& echo "Asia/Shanghai" > /etc/timezone
COPY --from=golang-builder /usr/bin/answer /usr/bin/answer
COPY --from=golang-builder /data /data
COPY /script/entrypoint.sh /entrypoint.sh
RUN chmod 755 /entrypoint.sh
VOLUME /data
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -40,7 +40,7 @@ jobs:
uses: docker/build-push-action@v4
with:
context: .
file: ./.github/Dockerfile
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}

View File

@ -39,20 +39,13 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# - name: Login to GitHub Container Registry
# uses: docker/login-action@v2
# with:
# registry: ghcr.io
# username: ${{ github.actor }}
# password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
file: ./.github/Dockerfile
file: ./Dockerfile
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@ -51,20 +51,13 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# - name: Login to GitHub Container Registry
# uses: docker/login-action@v2
# with:
# registry: ghcr.io
# username: ${{ github.actor }}
# password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
file: ./.github/Dockerfile
file: ./Dockerfile
tags: answerdev/answer:${{ inputs.tag_name }}
labels: ${{ steps.meta.outputs.labels }}

View File

@ -1,14 +1,3 @@
# FROM amd64/node AS node-builder
FROM amd64/node:18 AS node-builder
LABEL maintainer="mingcheng<mc@sf.com>"
COPY . /answer
WORKDIR /answer
RUN node -v
RUN make install-ui-packages ui && mv ui/build /tmp
# stage2 build the main binary within static resource
FROM golang:1.19-alpine AS golang-builder
LABEL maintainer="aichy@sf.com"
@ -28,9 +17,9 @@ ARG CGO_EXTRA_CFLAGS
COPY . ${BUILD_DIR}
WORKDIR ${BUILD_DIR}
COPY --from=node-builder /tmp/build ${BUILD_DIR}/ui/build
RUN apk --no-cache add build-base git bash \
&& make clean build
RUN apk --no-cache add build-base git bash nodejs npm && npm install -g pnpm corepack \
&& make install-ui-packages clean build
RUN chmod 755 answer
RUN ["/bin/bash","-c","script/build_plugin.sh"]
RUN cp answer /usr/bin/answer
@ -38,7 +27,6 @@ RUN cp answer /usr/bin/answer
RUN mkdir -p /data/uploads && chmod 777 /data/uploads \
&& mkdir -p /data/i18n && cp -r i18n/*.yaml /data/i18n
# stage3 copy the binary and resource files into fresh container
FROM alpine
LABEL maintainer="maintainers@sf.com"

View File

@ -169,7 +169,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
answerCommon := answercommon.NewAnswerCommon(answerRepo)
metaRepo := meta.NewMetaRepo(dataData)
metaService := meta2.NewMetaService(metaRepo)
questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaService, configService, activityQueueService)
questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaService, configService, activityQueueService, dataData)
collectionService := service.NewCollectionService(collectionRepo, collectionGroupRepo, questionCommon)
collectionController := controller.NewCollectionController(collectionService)
answerActivityRepo := activity.NewAnswerActivityRepo(dataData, activityRepo, userRankRepo, notificationQueueService)
@ -196,7 +196,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
reasonService := reason2.NewReasonService(reasonRepo)
reasonController := controller.NewReasonController(reasonService)
themeController := controller_admin.NewThemeController()
siteInfoService := siteinfo.NewSiteInfoService(siteInfoRepo, siteInfoCommonService, emailService, tagCommonService, configService)
siteInfoService := siteinfo.NewSiteInfoService(siteInfoRepo, siteInfoCommonService, emailService, tagCommonService, configService, questionCommon)
siteInfoController := controller_admin.NewSiteInfoController(siteInfoService)
controllerSiteInfoController := controller.NewSiteInfoController(siteInfoCommonService)
notificationRepo := notification.NewNotificationRepo(dataData)

View File

@ -11,6 +11,31 @@ backend:
other: Unauthorized.
database_error:
other: Data server error.
forbidden_error:
other: Forbidden.
action:
report:
other: Flag
edit:
other: Edit
delete:
other: Delete
close:
other: Close
reopen:
other: Reopen
forbidden_error:
other: Forbidden.
pin:
other: Pin
hide:
other: Unlist
unpin:
other: Unpin
show:
other: List
invite_someone_to_answer:
other: Edit
role:
name:
user:
@ -26,6 +51,60 @@ backend:
other: Have the full power to access the site.
moderator:
other: Has access to all posts except admin settings.
privilege:
level_1:
description:
other: Level 1 (less reputation required for private team, group)
level_2:
description:
other: Level 2 (low reputation required for startup community)
level_3:
description:
other: Level 3 (high reputation required for mature community)
rank_question_add_label:
other: Ask question
rank_answer_add_label:
other: Write answer
rank_comment_add_label:
other: Write comment
rank_report_add_label:
other: Flag
rank_comment_vote_up_label:
other: Upvote comment
rank_link_url_limit_label:
other: Post more than 2 links at a time
rank_question_vote_up_label:
other: Upvote question
rank_answer_vote_up_label:
other: Upvote answer
rank_question_vote_down_label:
other: Downvote question
rank_answer_vote_down_label:
other: Downvote answer
rank_invite_someone_to_answer_label:
other: Invite someone to answer
rank_tag_add_label:
other: Create new tag
rank_tag_edit_label:
other: Edit tag description (need to review)
rank_question_edit_label:
other: Edit other's question (need to review)
rank_answer_edit_label:
other: Edit other's answer (need to review)
rank_question_edit_without_review_label:
other: Edit other's question without review
rank_answer_edit_without_review_label:
other: Edit other's answer without review
rank_question_audit_label:
other: Review question edits
rank_answer_audit_label:
other: Review answer edits
rank_tag_audit_label:
other: Review tag edits
rank_tag_edit_without_review_label:
other: Edit tag description without review
rank_tag_synonym_label:
other: Manage tag synonyms
email:
other: Email
password:
@ -33,7 +112,14 @@ backend:
email_or_password_wrong_error:
other: Email and password do not match.
error:
password:
space_invalid:
other: Password cannot contain spaces.
admin:
cannot_update_their_password:
other: You cannot modify your password.
cannot_modify_self_status:
other: You cannot modify your status.
email_or_password_wrong:
other: Email and password do not match.
answer:
@ -43,6 +129,8 @@ backend:
other: No permission to delete.
cannot_update:
other: No permission to update.
question_closed_cannot_add:
other: Questions are closed and cannot be added.
comment:
edit_without_permission:
other: Comment are not allowed to edit.
@ -57,6 +145,8 @@ backend:
other: Email should be verified.
verify_url_expired:
other: Email verified URL has expired, please resend the email.
illegal_email_domain_error:
other: Email is not allowed from that email domain. Please use another one.
lang:
not_found:
other: Language file not found.
@ -80,6 +170,8 @@ backend:
new_password_same_as_previous_setting:
other: The new password is the same as the previous one.
question:
already_deleted:
other: This post has been deleted.
not_found:
other: Question not found.
cannot_deleted:
@ -91,12 +183,18 @@ backend:
rank:
fail_to_meet_the_condition:
other: Rank fail to meet the condition.
vote_fail_to_meet_the_condition:
other: Thanks for the feedback. You need at least {{.Rank}} reputation to cast a vote.
no_enough_rank_to_operate:
other: You need at least {{.Rank}} reputation to do this.
report:
handle_failed:
other: Report handle failed.
not_found:
other: Report not found.
tag:
already_exist:
other: Tag already exists.
not_found:
other: Tag not found.
recommend_tag_not_found:
@ -107,6 +205,8 @@ backend:
other: Should not contain synonym tags.
cannot_update:
other: No permission to update.
is_used_cannot_delete:
other: You cannot delete a tag that is in use
cannot_set_synonym_as_itself:
other: You cannot set the synonym of the current tag as itself.
smtp:
@ -121,6 +221,10 @@ backend:
no_permission:
other: No permission to Revision.
user:
external_login_missing_user_id:
other: The third-party platform does not provide a unique UserID, so you cannot login, please contact the website administrator.
external_login_unbinding_forbidden:
other: Please set a login password for your account before you remove this login.
email_or_password_wrong:
other:
other: Email and password do not match.
@ -138,6 +242,10 @@ backend:
other: You cannot modify your role.
not_allowed_registration:
other: Currently the site is not open for registration
access_denied:
other: Access denied
page_access_denied:
other: You do not have access to this page.
config:
read_config_failed:
other: Read config failed
@ -152,37 +260,74 @@ backend:
upload:
unsupported_file_format:
other: Unsupported file format.
report:
site_info:
config_not_found:
other: Site config not found.
reason:
spam:
name:
other: spam
desc:
other: This post is an advertisement, or vandalism. It is not useful or relevant to the current topic.
rude:
rude_or_abusive:
name:
other: rude or abusive
desc:
other: A reasonable person would find this content inappropriate for respectful discourse.
duplicate:
a_duplicate:
name:
other: a duplicate
desc:
other: This question has been asked before and already has an answer.
not_answer:
placeholder:
other: Enter the existing question link
not_a_answer:
name:
other: not an answer
desc:
other: This was posted as an answer, but it does not attempt to answer the question. It should possibly be an edit, a comment, another question, or deleted altogether.
not_need:
no_longer_needed:
name:
other: no longer needed
desc:
other: This comment is outdated, conversational or not relevant to this post.
other:
something:
name:
other: something else
desc:
other: This post requires staff attention for another reason not listed above.
placeholder:
other: Let us know specifically what you are concerned about
community_specific:
name:
other: a community-specific reason
desc:
other: This question doesnt meet a community guideline.
not_clarity:
name:
other: needs details or clarity
desc:
other: This question currently includes multiple questions in one. It should focus on one problem only.
looks_ok:
name:
other: looks ok
desc:
other: This post is good as-is and not low quality.
needs_edit:
name:
other: needs edit, and I did it
desc:
other: Improve and correct problems with this post yourself.
needs_close:
name:
other: needs close
desc:
other: A closed question cant answer, but still can edit, vote and comment.
needs_delete:
name:
other: needs delete
desc:
other: This post will be deleted.
question:
close:
duplicate:
@ -212,6 +357,8 @@ backend:
other: answered
modified:
other: modified
deleted_title:
other: Deleted question
notification:
action:
update_question:
@ -238,6 +385,67 @@ backend:
other: Your answer has been deleted
your_comment_was_deleted:
other: Your comment has been deleted
up_voted_question:
other: upvoted question
down_voted_question:
other: downvoted question
up_voted_answer:
other: upvoted answer
down_voted_answer:
other: downvoted answer
up_voted_comment:
other: upvoted comment
invited_you_to_answer:
other: invited you to answer
email_tpl:
change_email:
title:
other: "[{{.SiteName}}] Confirm your new email address"
body:
other: "Confirm your new email address for {{.SiteName}} by clicking on the following link:<br><br>\n\n<a href='{{.ChangeEmailUrl}}' target='_blank'>{{.ChangeEmailUrl}}</a><br><br>\n\nIf you did not request this change, please ignore this email.\n"
new_answer:
title:
other: "[{{.SiteName}}] {{.DisplayName}} answered your question"
body:
other: "<strong><a href='{{.AnswerUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.AnswerSummary}}</blockquote><br>\n<a href='{{.AnswerUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"
invited_you_to_answer:
title:
other: "[{{.SiteName}}] {{.DisplayName}} invited you to answer"
body:
other: "<strong><a href='{{.InviteUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>I think you may know the answer.</blockquote><br>\n<a href='{{.InviteUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"
new_comment:
title:
other: "[{{.SiteName}}] {{.DisplayName}} commented on your post"
body:
other: "<strong><a href='{{.CommentUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.CommentSummary}}</blockquote><br>\n<a href='{{.CommentUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"
pass_reset:
title:
other: "[{{.SiteName }}] Password reset"
body:
other: "Somebody asked to reset your password on [{{.SiteName}}].<br><br>\n\nIf it was not you, you can safely ignore this email.<br><br>\n\nClick the following link to choose a new password:<br>\n<a href='{{.PassResetUrl}}' target='_blank'>{{.PassResetUrl}}</a>\n"
register:
title:
other: "[{{.SiteName}}] Confirm your new account"
body:
other: "Welcome to {{.SiteName}}<br><br>\n\nClick the following link to confirm and activate your new account:<br>\n<a href='{{.RegisterUrl}}' target='_blank'>{{.RegisterUrl}}</a><br><br>\n\nIf the above link is not clickable, try copying and pasting it into the address bar of your web browser.\n"
test:
title:
other: "[{{.SiteName}}] Test Email"
body:
other: "This is a test email."
action_activity_type:
upvote:
other: upvote
upvoted:
other: upvoted
downvote:
other: downvote
downvoted:
other: downvoted
accept:
other: accept
accepted:
other: accepted
#The following fields are used for interface presentation(Front-end)
ui:
how_to_format:
@ -253,6 +461,7 @@ ui:
tag: Tag
tags: Tags
tag_wiki: tag wiki
create_tag: Create Tag
edit_tag: Edit Tag
ask_a_question: Add Question
edit_question: Edit Question
@ -273,17 +482,28 @@ ui:
upgrade: Answer Upgrade
maintenance: Website Maintenance
users: Users
oauth_callback: Processing
http_404: HTTP Error 404
http_50X: HTTP Error 500
http_403: HTTP Error 403
notifications:
title: Notifications
inbox: Inbox
achievement: Achievements
all_read: Mark all as read
show_more: Show more
someone: Someone
inbox_type:
all: All
posts: Posts
invites: Invites
votes: Votes
suspended:
title: Your Account has been Suspended
until_time: "Your account was suspended until {{ time }}."
forever: This user was suspended forever.
end: You don't meet a community guideline.
contact_us: Contact us
editor:
blockquote:
text: Blockquote
@ -309,7 +529,7 @@ ui:
msg:
empty: Code cannot be empty.
language:
label: Language (optional)
label: Language
placeholder: Automatic detection
btn_cancel: Cancel
btn_confirm: Add
@ -345,7 +565,7 @@ ui:
only_image: Only image files are allowed.
max_size: File size cannot exceed 4MB.
desc:
label: Description (optional)
label: Description
tab_url: Image URL
form_url:
fields:
@ -354,7 +574,7 @@ ui:
msg:
empty: Image URL cannot be empty.
name:
label: Description (optional)
label: Description
btn_cancel: Cancel
btn_confirm: Add
uploading: Uploading
@ -374,7 +594,7 @@ ui:
msg:
empty: URL cannot be empty.
name:
label: Description (optional)
label: Description
btn_cancel: Cancel
btn_confirm: Add
ordered_list:
@ -416,15 +636,16 @@ ui:
range: Display name up to 35 characters.
slug_name:
label: URL Slug
desc: 'Must use the character set "a-z", "0-9", "+ # - ."'
desc: URL slug up to 35 characters.
msg:
empty: URL slug cannot be empty.
range: URL slug up to 35 characters.
character: URL slug contains unallowed character set.
desc:
label: Description (optional)
label: Description
btn_cancel: Cancel
btn_submit: Submit
btn_post: Post new tag
tag_info:
created_at: Created
edited_at: Edited
@ -439,9 +660,11 @@ ui:
synonyms_text: The following tags will be remapped to
delete:
title: Delete this tag
content: >-
<p>We do not allow deleting tag with posts.</p><p>Please remove this tag from the posts first.</p>
content2: Are you sure you wish to delete?
tip_with_posts: >-
<p>We do not allowed <strong>deleting tag with posts</strong>.</p> <p>Please remove this tag from the posts first.</p>
tip_with_synonyms: >-
<p>We do not allowed <strong>deleting tag with synonyms</strong>.</p> <p>Please remove the synonyms from this tag first.</p>
tip: Are you sure you wish to delete?
close: Close
edit_tag:
title: Edit Tag
@ -487,6 +710,7 @@ ui:
Use comments to ask for more information or suggest improvements. Avoid answering questions in comments.
tip_answer: >-
Use comments to reply to other users or notify them of changes. If you are adding new information, edit your post instead of commenting.
tip_vote: It adds something useful to the post
edit_answer:
title: Edit Answer
default_reason: Edit answer
@ -568,6 +792,8 @@ ui:
logout: Log out
admin: Admin
review: Review
bookmark: Bookmarks
moderation: Moderation
search:
placeholder: Search
footer:
@ -592,7 +818,6 @@ ui:
msg:
empty: Cannot be empty.
login:
page_title: Welcome to {{site_name}}
login_to_continue: Log in to continue
info_sign: Don't have an account? <1>Sign up</1>
info_login: Already have an account? <1>Log in</1>
@ -603,6 +828,7 @@ ui:
msg:
empty: Name cannot be empty.
range: Name up to 30 characters.
character: 'Must use the character set "a-z", "0-9", " - . _"'
email:
label: Email
msg:
@ -622,7 +848,6 @@ ui:
msg:
empty: Email cannot be empty.
change_email:
page_title: Welcome to {{site_name}}
btn_cancel: Cancel
btn_update: Update email address
send_success: >-
@ -631,6 +856,20 @@ ui:
label: New Email
msg:
empty: Email cannot be empty.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
email:
label: Email
msg:
empty: Email cannot be empty.
modal_title: Email already existes.
modal_content: This email address already registered. Are you sure you want to connect to the existing account?
modal_cancel: Change email
modal_confirm: Connect to the existing account
password_reset:
page_title: Password Reset
btn_name: Reset my password
@ -649,6 +888,7 @@ ui:
label: Confirm New Password
settings:
page_title: Settings
goto_modify: Go to Modify
nav:
profile: Profile
notification: Notifications
@ -670,20 +910,20 @@ ui:
avatar:
label: Profile Image
gravatar: Gravatar
gravatar_text: You can change image on <1>gravatar.com</1>
gravatar_text: You can change image on
custom: Custom
btn_refresh: Refresh
custom_text: You can upload your image.
default: System
msg: Please upload an avatar
bio:
label: About Me (optional)
label: About Me
website:
label: Website (optional)
label: Website
placeholder: "https://example.com"
msg: Website incorrect format
location:
label: Location (optional)
label: Location
placeholder: "City, Country"
notification:
heading: Notifications
@ -697,8 +937,11 @@ ui:
change_email_info: >-
We've sent an email to that address. Please follow the confirmation instructions.
email:
label: Email
msg: Email cannot be empty.
label: New Email
msg: New Email cannot be empty.
pass:
label: Current Password
msg: Password cannot be empty.
password_title: Password
current_pass:
label: Current Password
@ -715,6 +958,13 @@ ui:
lang:
label: Interface Language
text: User interface language. It will change when you refresh the page.
my_logins:
title: My Logins
label: Log in or sign up on this site using these accounts.
modal_title: Remove Login
modal_content: Are you sure you want to remove this login from your account?
modal_confirm_btn: Remove
remove_success: Removed successfully
toast:
update: update success
update_password: Password changed successfully.
@ -725,7 +975,14 @@ ui:
title: Related Questions
btn: Add question
answers: answers
invite_to_answer:
title: People asked
desc: Invite people who you think might know the answer.
invite: Invite to answer
add: Add people
search: Search people
question_detail:
action: Action
Asked: Asked
asked: asked
update: Modified
@ -733,9 +990,15 @@ ui:
Views: Viewed
Follow: Follow
Following: Following
follow_tip: Follow this question to receive notifications
answered: answered
closed_in: Closed in
show_exist: Show existing question.
useful: Useful
question_useful: It is useful and clear
question_un_useful: It is unclear or not useful
answer_useful: It is useful
answer_un_useful: It is not useful
answers:
title: Answers
score: Score
@ -752,10 +1015,20 @@ ui:
<p>Are you sure you want to add another answer?</p><p>You could use the edit link to refine and improve your existing answer, instead.</p>
empty: Answer cannot be empty.
characters: content must be at least 6 characters in length.
tips:
header_1: Thanks for your answer
li1_1: Please be sure to <strong>answer the question</strong>. Provide details and share your research.
li1_2: Back up any statements you make with references or personal experience.
header_2: But <strong>avoid</strong> ...
li2_1: Asking for help, seeking clarification, or responding to other answers.
reopen:
confirm_btn: Reopen
title: Reopen this post
content: Are you sure you want to reopen?
success: This post has been reopened
pin:
title: Pin this post
content: Are you sure you wish to pinned globally? This post will appear at the top of all post lists.
confirm_btn: Pin
delete:
title: Delete this post
question: >-
@ -763,11 +1036,11 @@ ui:
answer_accepted: >-
<p>We do not recommend <strong>deleting accepted answer</strong> because doing so deprives future readers of this knowledge. </p> Repeated deletion of accepted answers can result in your account being blocked from answering. Are you sure you wish to delete?
other: Are you sure you wish to delete?
tip_question_deleted: This post has been deleted
tip_answer_deleted: This answer has been deleted
btns:
confirm: Confirm
cancel: Cancel
edit: Edit
save: Save
delete: Delete
login: Log in
@ -778,6 +1051,12 @@ ui:
approve: Approve
reject: Reject
skip: Skip
discard_draft: Discard draft
pinned: Pinned
all: All
question: Question
answer: Answer
comment: Comment
search:
title: Search Results
keywords: Keywords
@ -812,7 +1091,6 @@ ui:
modal_confirm:
title: Error...
account_result:
page_title: Welcome to {{site_name}}
success: Your new account is confirmed; you will be redirected to the home page.
link: Continue to homepage
invalid: >-
@ -873,8 +1151,7 @@ ui:
accepted: Accepted
answered: answered
asked: asked
upvote: upvote
downvote: downvote
downvoted: downvoted
mod_short: Mod
mod_long: Moderators
x_reputation: reputation
@ -936,6 +1213,7 @@ ui:
admin_name:
label: Name
msg: Name cannot be empty.
character: 'Must use the character set "a-z", "0-9", " - . _"'
admin_password:
label: Password
text: >-
@ -966,11 +1244,11 @@ ui:
votes: votes
answers: answers
accepted: Accepted
page_404:
desc: "Unfortunately, this page doesn't exist."
back_home: Back to homepage
page_50X:
desc: The server encountered an error and could not complete your request.
page_error:
http_error: HTTP Error {{ code }}
desc_403: You dont have permission to access this page.
desc_404: Unfortunately, this page doesn't exist.
desc_50X: The server encountered an error and could not complete your request.
back_home: Back to homepage
page_maintenance:
desc: "We are under maintenance, we'll be back soon."
@ -993,8 +1271,16 @@ ui:
seo: SEO
customize: Customize
themes: Themes
css-html: CSS/HTML
css_html: CSS/HTML
login: Login
privileges: Privileges
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
admin:
admin_header:
title: Admin
@ -1037,6 +1323,7 @@ ui:
pending: Pending
completed: Completed
flagged: Flagged
flagged_type: Flagged {{ type }}
created: Created
action: Action
review: Review
@ -1064,7 +1351,7 @@ ui:
closed_name: closed
closed_desc: "A closed question can't answer, but still can edit, vote and comment."
deleted_name: deleted
deleted_desc: All reputation gained and lost will be restored.
deleted_desc: This post will be deleted.
btn_cancel: Cancel
btn_submit: Submit
btn_next: Next
@ -1163,11 +1450,11 @@ ui:
validate: Please enter a valid URL.
text: The address of your site.
short_desc:
label: Short Site Description (optional)
label: Short Site Description
msg: Short site description cannot be empty.
text: "Short description, as used in the title tag on homepage."
desc:
label: Site Description (optional)
label: Site Description
msg: Site description cannot be empty.
text: "Describe this site in one sentence, as used in the meta description tag."
contact_email:
@ -1177,14 +1464,6 @@ ui:
text: Email address of key contact responsible for this site.
interface:
page_title: Interface
logo:
label: Logo (optional)
msg: Site logo cannot be empty.
text: You can upload your image or <1>reset</1> it to the site title text.
theme:
label: Theme
msg: Theme cannot be empty.
text: Select an existing theme.
language:
label: Interface Language
msg: Interface language cannot be empty.
@ -1236,18 +1515,18 @@ ui:
branding:
page_title: Branding
logo:
label: Logo (optional)
label: Logo
msg: Logo cannot be empty.
text: The logo image at the top left of your site. Use a wide rectangular image with a height of 56 and an aspect ratio greater than 3:1. If left blank, the site title text will be shown.
mobile_logo:
label: Mobile Logo (optional)
label: Mobile Logo
text: The logo used on mobile version of your site. Use a wide rectangular image with a height of 56. If left blank, the image from the "logo" setting will be used.
square_icon:
label: Square Icon (optional)
label: Square Icon
msg: Square icon cannot be empty.
text: Image used as the base for metadata icons. Should ideally be larger than 512x512.
favicon:
label: Favicon (optional)
label: Favicon
text: A favicon for your site. To work correctly over a CDN it must be a png. Will be resized to 32x32. If left blank, "square icon" will be used.
legal:
page_title: Legal
@ -1302,21 +1581,77 @@ ui:
footer:
label: Footer
text: This will insert before </html>.
sidebar:
label: Sidebar
text: This will insert in sidebar.
login:
page_title: Login
membership:
title: Membership
label: Allow new registrations
text: Turn off to prevent anyone from creating a new account.
email_registration:
title: Email registration
label: Allow email registration
text: Turn off to prevent anyone creating new account through email.
allowed_email_domains:
title: Allowed email domains
text: Email domains that users must register accounts with. One domain per line. Ignored when empty.
private:
title: Private
label: Login required
text: Only logged in users can access this community.
installed_plugins:
title: Installed Plugins
filter:
all: All
active: Active
inactive: Inactive
outdated: Outdated
plugins:
label: Plugins
text: Select an existing plugin.
name: Name
version: Version
status: Status
action: Action
deactivate: Deactivate
activate: Activate
settings: Settings
settings_users:
title: Users
avatar:
label: Default Avatar
text: For users without a custom avatar of their own.
gravatar_base_url:
label: Gravatar Base URL
text: URL of the Gravatar providers API base. Ignored when empty.
profile_editable:
title: Profile Editable
allow_update_display_name:
label: Allow users to change their display name
allow_update_username:
label: Allow users to change their username
allow_update_avatar:
label: Allow users to change their profile image
allow_update_bio:
label: Allow users to change their about me
allow_update_website:
label: Allow users to change their website
allow_update_location:
label: Allow users to change their location
privilege:
title: Privileges
level:
label: Reputation required level
text: Choose the reputation required for the privileges
form:
optional: (optional)
empty: cannot be empty
invalid: is invalid
btn_submit: Save
not_found_props: "Required property {{ key }} not found."
select: Select
page_review:
review: Review
proposed: proposed
@ -1343,6 +1678,10 @@ ui:
closed: closed
reopened: reopened
created: created
pin: pinned
unpin: unpinned
show: listed
hide: unlisted
title: "History for"
tag_title: "Timeline for"
show_votes: "Show votes"
@ -1357,10 +1696,20 @@ ui:
no_data: "We couldn't find anything."
users:
title: Users
users_with_the_most_reputation: Users with the highest reputation scores
users_with_the_most_vote: Users who voted the most
users_with_the_most_reputation: Users with the highest reputation scores this week
users_with_the_most_vote: Users who voted the most this week
staffs: Our community staff
reputation: reputation
votes: votes
prompt:
leave_page: Are you sure you want to leave the page?
changes_not_save: Your changes may not be saved.
draft:
discard_confirm: Are you sure you want to discard your draft?
messages:
post_deleted: This post has been deleted.
post_pin: This post has been pinned.
post_unpin: This post has been unpinned.
post_hide_list: This post has been hidden from list.
post_show_list: This post has been shown to list.
post_reopen: This post has been reopened.

View File

@ -856,6 +856,9 @@ ui:
label: New Email
msg:
empty: Email cannot be empty.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1274,13 +1277,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: Admin

View File

@ -856,6 +856,9 @@ ui:
label: Neue e-mail
msg:
empty: E-Mail darf nicht leer sein.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1274,13 +1277,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: Administrator

View File

@ -882,6 +882,9 @@ ui:
label: New Email
msg:
empty: Email cannot be empty.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1317,13 +1320,11 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:

View File

@ -877,6 +877,9 @@ ui:
label: Nuevo correo electrónico
msg:
empty: El correo electrónico no puede estar vacío.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1299,13 +1302,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: Administrador

View File

@ -856,6 +856,9 @@ ui:
label: Nouvel e-mail
msg:
empty: L'email ne peut pas être vide.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1274,13 +1277,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: Admin

View File

@ -856,6 +856,9 @@ ui:
label: Email Baru
msg:
empty: Email tidak boleh kosong.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1274,13 +1277,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: Admin

View File

@ -856,6 +856,9 @@ ui:
label: New Email
msg:
empty: Email cannot be empty.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1274,13 +1277,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: Admin

View File

@ -856,6 +856,9 @@ ui:
label: New Email
msg:
empty: Email cannot be empty.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1274,13 +1277,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: 管理者

File diff suppressed because it is too large Load Diff

View File

@ -856,6 +856,9 @@ ui:
label: New Email
msg:
empty: Email cannot be empty.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1274,13 +1277,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: Admin

View File

@ -856,6 +856,9 @@ ui:
label: New Email
msg:
empty: Email cannot be empty.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1274,13 +1277,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: Admin

View File

@ -856,6 +856,9 @@ ui:
label: Nový e-mail
msg:
empty: E-mail nemôže byť prázdny.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1274,13 +1277,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: Administrátor

View File

@ -856,6 +856,9 @@ ui:
label: New Email
msg:
empty: Email cannot be empty.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1274,13 +1277,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: Admin

View File

@ -856,6 +856,9 @@ ui:
label: New Email
msg:
empty: Email cannot be empty.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1274,13 +1277,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: Admin

View File

@ -50,21 +50,21 @@ backend:
admin:
other: 拥有管理网站的全部权限。
moderator:
other: 拥有访问后台管理以外的所有权限。
other: 拥有访问后台管理以外的所有权限。
privilege:
level_1:
description:
other: 第 1 级 (私人团队、群组所需的声望)
other: 级别 1少量声望要求适合私有团队、群组
level_2:
description:
other: 级别2(启动社区所需的低声望)
other: 级别 2低声望要求适合初启动的社区
level_3:
description:
other: 等级 3 (成熟社区所需的高声望)
other: 级别 3高声望要求适合成熟的社区
rank_question_add_label:
other: 提问s
other: 提问
rank_answer_add_label:
other: 答案
other: 答案
rank_comment_add_label:
other: 发表评论
rank_report_add_label:
@ -86,23 +86,23 @@ backend:
rank_tag_add_label:
other: 创建新标签
rank_tag_edit_label:
other: 编辑标签描述(需要审核)
other: 编辑标签描述(需要审核)
rank_question_edit_label:
other: 编辑对方的问题 (需要审查)
other: 编辑别人的问题(需要审核)
rank_answer_edit_label:
other: 编辑对方的答案 (需要审查)
other: 编辑别人的答案(需要审核)
rank_question_edit_without_review_label:
other: 不经评论编辑对方的问题
other: 编辑别人的问题无需审核
rank_answer_edit_without_review_label:
other: 编辑对方的答案而不需要审核
other: 编辑别人的答案无需审核
rank_question_audit_label:
other: 查问题
other: 核问题编辑
rank_answer_audit_label:
other: 审核回答
other: 审核回答编辑
rank_tag_audit_label:
other: 审核标签编辑
rank_tag_edit_without_review_label:
other: 编辑标签无需审核
other: 编辑标签无需审核
rank_tag_synonym_label:
other: 管理标签同义词
email:
@ -856,6 +856,9 @@ ui:
label: 新邮箱
msg:
empty: 邮箱不能为空
oauth:
connect: 连接到 {{ auth_name }}
remove: 移除 {{ auth_name }}
oauth_bind_email:
subtitle: 向你的账户添加恢复邮件地址。
btn_update: 更新电子邮件地址
@ -1274,13 +1277,10 @@ ui:
plugins: 插件
installed_plugins: 已安装插件
website_welcome: 欢迎来到 {{site_name}}
plugins:
user_center:
login: 登录
qrcode_login_tip: 请使用 {{ agentName }} 扫描二维码并登录。
login_failed_email_tip: 登录失败,请允许此应用访问您的邮箱信息,然后重试。
oauth:
connect: 连接到 {{ auth_name }}
remove: 移除 {{ auth_name }}
admin:
admin_header:
title: 后台管理

View File

@ -856,6 +856,9 @@ ui:
label: 新電子郵件
msg:
empty: 郵箱不能為空
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
oauth_bind_email:
subtitle: Add a recovery email to your account.
btn_update: Update email address
@ -1274,13 +1277,10 @@ ui:
plugins: Plugins
installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}}
plugins:
user_center:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.
oauth:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}
admin:
admin_header:
title: 後台管理

View File

@ -2,8 +2,9 @@ package cli
import (
"bytes"
"embed"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path"
@ -101,8 +102,10 @@ func BuildNewAnswer(outputPath string, plugins []string, originalAnswerInfo Orig
builder := newAnswerBuilder(outputPath, plugins, originalAnswerInfo)
builder.DoTask(createMainGoFile)
builder.DoTask(downloadGoModFile)
builder.DoTask(copyUIFiles)
builder.DoTask(overwriteIndexTs)
builder.DoTask(buildUI)
builder.DoTask(mergeI18nFiles)
builder.DoTask(replaceNecessaryFile)
builder.DoTask(buildBinary)
builder.DoTask(cleanByproduct)
return builder.BuildError
@ -120,6 +123,7 @@ func formatPlugins(plugins []string) (formatted []*pluginInfo) {
return formatted
}
// createMainGoFile creates main.go file in tmp dir that content is mainGoTpl
func createMainGoFile(b *buildingMaterial) (err error) {
fmt.Printf("[build] tmp dir: %s\n", b.tmpDir)
err = dir.CreateDirIfNotExist(b.tmpDir)
@ -169,6 +173,7 @@ func createMainGoFile(b *buildingMaterial) (err error) {
return
}
// downloadGoModFile run go mod commands to download dependencies
func downloadGoModFile(b *buildingMaterial) (err error) {
// If user specify a module replacement, use it. Otherwise, use the latest version.
if len(b.answerModuleReplacement) > 0 {
@ -191,6 +196,81 @@ func downloadGoModFile(b *buildingMaterial) (err error) {
return
}
// copyUIFiles copy ui files from answer module to tmp dir
func copyUIFiles(b *buildingMaterial) (err error) {
goListCmd := b.newExecCmd("go", "list", "-mod=mod", "-m", "-f", "{{.Dir}}", "github.com/answerdev/answer")
buf := new(bytes.Buffer)
goListCmd.Stdout = buf
if err = goListCmd.Run(); err != nil {
return fmt.Errorf("failed to run go list: %w", err)
}
goModUIDir := filepath.Join(strings.TrimSpace(buf.String()), "ui")
localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui/")
if err = copyDirEntries(os.DirFS(goModUIDir), ".", localUIBuildDir); err != nil {
return fmt.Errorf("failed to copy ui files: %w", err)
}
return nil
}
// overwriteIndexTs overwrites index.ts file in ui/src/plugins/ dir
func overwriteIndexTs(b *buildingMaterial) (err error) {
localUIPluginDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui/src/plugins/")
folders, err := getFolders(localUIPluginDir)
if err != nil {
return fmt.Errorf("failed to get folders: %w", err)
}
content := generateIndexTsContent(folders)
err = os.WriteFile(filepath.Join(localUIPluginDir, "index.ts"), []byte(content), 0644)
if err != nil {
return fmt.Errorf("failed to write index.ts: %w", err)
}
return nil
}
func getFolders(dir string) ([]string, error) {
var folders []string
files, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
for _, file := range files {
if file.IsDir() && file.Name() != "builtin" {
folders = append(folders, file.Name())
}
}
return folders, nil
}
func generateIndexTsContent(folders []string) string {
builder := &strings.Builder{}
builder.WriteString("export default null;\n\n")
for _, folder := range folders {
builder.WriteString(fmt.Sprintf("export { default as %s } from './%s';\n", folder, folder))
}
return builder.String()
}
// buildUI run pnpm install and pnpm build commands to build ui
func buildUI(b *buildingMaterial) (err error) {
localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui")
pnpmInstallCmd := b.newExecCmd("pnpm", "install")
pnpmInstallCmd.Dir = localUIBuildDir
if err = pnpmInstallCmd.Run(); err != nil {
return err
}
pnpmBuildCmd := b.newExecCmd("pnpm", "build")
pnpmBuildCmd.Dir = localUIBuildDir
if err = pnpmBuildCmd.Run(); err != nil {
return err
}
return nil
}
func replaceNecessaryFile(b *buildingMaterial) (err error) {
fmt.Printf("try to replace ui build directory\n")
uiBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/answer/ui")
@ -198,6 +278,7 @@ func replaceNecessaryFile(b *buildingMaterial) (err error) {
return err
}
// mergeI18nFiles merge i18n files
func mergeI18nFiles(b *buildingMaterial) (err error) {
fmt.Printf("try to merge i18n files\n")
@ -285,37 +366,60 @@ func mergeI18nFiles(b *buildingMaterial) (err error) {
return err
}
func copyDirEntries(sourceFs embed.FS, sourceDir string, targetDir string) (err error) {
entries, err := ui.Build.ReadDir(sourceDir)
if err != nil {
return err
}
func copyDirEntries(sourceFs fs.FS, sourceDir string, targetDir string) (err error) {
err = dir.CreateDirIfNotExist(targetDir)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
err = copyDirEntries(sourceFs, filepath.Join(sourceDir, entry.Name()), filepath.Join(targetDir, entry.Name()))
err = fs.WalkDir(sourceFs, sourceDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Convert the path to use forward slashes, important because we use embedded FS which always uses forward slashes
path = filepath.ToSlash(path)
// Construct the absolute path for the source file/directory
srcPath := filepath.Join(sourceDir, path)
// Construct the absolute path for the destination file/directory
dstPath := filepath.Join(targetDir, path)
if d.IsDir() {
// Create the directory in the destination
err := os.MkdirAll(dstPath, os.ModePerm)
if err != nil {
return err
return fmt.Errorf("failed to create directory %s: %w", dstPath, err)
}
} else {
// Open the source file
srcFile, err := sourceFs.Open(srcPath)
if err != nil {
return fmt.Errorf("failed to open source file %s: %w", srcPath, err)
}
defer srcFile.Close()
// Create the destination file
dstFile, err := os.Create(dstPath)
if err != nil {
return fmt.Errorf("failed to create destination file %s: %w", dstPath, err)
}
defer dstFile.Close()
// Copy the file contents
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return fmt.Errorf("failed to copy file contents from %s to %s: %w", srcPath, dstPath, err)
}
continue
}
file, err := sourceFs.ReadFile(filepath.Join(sourceDir, entry.Name()))
if err != nil {
return err
}
filename := filepath.Join(targetDir, entry.Name())
err = os.WriteFile(filename, file, 0666)
if err != nil {
return err
}
}
return nil
return nil
})
return err
}
// buildBinary build binary file
func buildBinary(b *buildingMaterial) (err error) {
versionInfo := b.originalAnswerInfo
cmdPkg := "github.com/answerdev/answer/cmd"
@ -329,6 +433,7 @@ func buildBinary(b *buildingMaterial) (err error) {
return
}
// cleanByproduct delete tmp dir
func cleanByproduct(b *buildingMaterial) (err error) {
return os.RemoveAll(b.tmpDir)
}

View File

@ -6,6 +6,7 @@ import (
"html/template"
"net/http"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/schema"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/log"
@ -25,6 +26,11 @@ func (t *TemplateRenderController) Sitemap(ctx *gin.Context) {
log.Error("get site general failed:", err)
return
}
siteInfo, err := t.siteInfoService.GetSiteSeo(ctx)
if err != nil {
log.Error("get site GetSiteSeo failed:", err)
return
}
sitemapInfo := &schema.SiteMapList{}
infoStr, err := t.data.Cache.GetString(ctx, schema.SitemapCachekey)
@ -32,6 +38,10 @@ func (t *TemplateRenderController) Sitemap(ctx *gin.Context) {
log.Errorf("get Cache failed: %s", err)
return
}
hasTitle := false
if siteInfo.PermaLink == constant.PermaLinkQuestionIDAndTitle || siteInfo.PermaLink == constant.PermaLinkQuestionIDAndTitleByShortID {
hasTitle = true
}
if err = json.Unmarshal([]byte(infoStr), sitemapInfo); err != nil {
log.Errorf("get sitemap info failed: %s", err)
return
@ -45,6 +55,7 @@ func (t *TemplateRenderController) Sitemap(ctx *gin.Context) {
"xmlHeader": template.HTML(`<?xml version="1.0" encoding="UTF-8"?>`),
"list": sitemapInfo.QuestionIDs,
"general": general,
"hastitle": hasTitle,
},
)
} else {
@ -68,6 +79,15 @@ func (t *TemplateRenderController) SitemapPage(ctx *gin.Context, page int) error
log.Error("get site general failed:", err)
return err
}
siteInfo, err := t.siteInfoService.GetSiteSeo(ctx)
if err != nil {
log.Error("get site GetSiteSeo failed:", err)
return err
}
hasTitle := false
if siteInfo.PermaLink == constant.PermaLinkQuestionIDAndTitle || siteInfo.PermaLink == constant.PermaLinkQuestionIDAndTitleByShortID {
hasTitle = true
}
cachekey := fmt.Sprintf(schema.SitemapPageCachekey, page)
infoStr, err := t.data.Cache.GetString(ctx, cachekey)
@ -85,6 +105,7 @@ func (t *TemplateRenderController) SitemapPage(ctx *gin.Context, page int) error
"xmlHeader": template.HTML(`<?xml version="1.0" encoding="UTF-8"?>`),
"list": sitemapInfo.PageData,
"general": general,
"hastitle": hasTitle,
},
)
return nil

View File

@ -15,13 +15,13 @@ import (
func updateCount(x *xorm.Engine) error {
fns := []func(*xorm.Engine) error{
inviteAnswer,
addPrivilegeForInviteSomeoneToAnswer,
addGravatarBaseURL,
updateQuestionCount,
updateTagCount,
updateUserQuestionCount,
updateUserAnswerCount,
inviteAnswer,
inBoxData,
}
for _, fn := range fns {
@ -224,7 +224,7 @@ func updateTagCount(x *xorm.Engine) error {
}
} else {
tag.QuestionCount = 0
if _, err = x.Update(tag, &entity.Tag{ID: tag.ID}); err != nil {
if _, err = x.Cols("question_count").Update(tag, &entity.Tag{ID: tag.ID}); err != nil {
log.Errorf("update %+v tag failed: %s", tag.ID, err)
return fmt.Errorf("update tag failed: %w", err)
}

View File

@ -10,6 +10,7 @@ import (
"github.com/answerdev/answer/internal/entity"
collectioncommon "github.com/answerdev/answer/internal/service/collection_common"
"github.com/answerdev/answer/internal/service/unique"
"github.com/answerdev/answer/pkg/uid"
"github.com/segmentfault/pacman/errors"
"xorm.io/xorm"
)
@ -148,6 +149,9 @@ func (cr *collectionRepo) GetCollectionPage(ctx context.Context, page, pageSize
// SearchObjectCollected check object is collected or not
func (cr *collectionRepo) SearchObjectCollected(ctx context.Context, userID string, objectIds []string) (map[string]bool, error) {
collectedMap := make(map[string]bool)
for k, object_id := range objectIds {
objectIds[k] = uid.DeShortID(object_id)
}
list, err := cr.SearchByObjectIDsAndUser(ctx, userID, objectIds)
if err != nil {
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()

View File

@ -566,6 +566,7 @@ func (as *AnswerService) SearchFormatInfo(ctx context.Context, answers []*entity
}
for _, item := range list {
item.ID = uid.EnShortID(item.ID)
item.MemberActions = permission.GetAnswerPermission(ctx, req.UserID, item.UserID, req.CanEdit, req.CanDelete)
}
return list, nil

View File

@ -3,9 +3,12 @@ package questioncommon
import (
"context"
"encoding/json"
"fmt"
"math"
"time"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/data"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/service/activity_common"
@ -65,6 +68,7 @@ type QuestionCommon struct {
metaService *meta.MetaService
configService *config.ConfigService
activityQueueService activity_queue.ActivityQueueService
data *data.Data
}
func NewQuestionCommon(questionRepo QuestionRepo,
@ -78,6 +82,7 @@ func NewQuestionCommon(questionRepo QuestionRepo,
metaService *meta.MetaService,
configService *config.ConfigService,
activityQueueService activity_queue.ActivityQueueService,
data *data.Data,
) *QuestionCommon {
return &QuestionCommon{
questionRepo: questionRepo,
@ -91,6 +96,7 @@ func NewQuestionCommon(questionRepo QuestionRepo,
metaService: metaService,
configService: configService,
activityQueueService: activityQueueService,
data: data,
}
}
@ -546,6 +552,57 @@ func (as *QuestionCommon) RemoveAnswer(ctx context.Context, id string) (err erro
return as.answerRepo.RemoveAnswer(ctx, id)
}
func (qs *QuestionCommon) SitemapCron(ctx context.Context) {
data := &schema.SiteMapList{}
questionNum, err := qs.questionRepo.GetQuestionCount(ctx)
if err != nil {
log.Error("GetQuestionCount error", err)
return
}
if questionNum <= schema.SitemapMaxSize {
questionIDList, err := qs.questionRepo.GetQuestionIDsPage(ctx, 0, int(questionNum))
if err != nil {
log.Error("GetQuestionIDsPage error", err)
return
}
data.QuestionIDs = questionIDList
} else {
nums := make([]int, 0)
totalpages := int(math.Ceil(float64(questionNum) / float64(schema.SitemapMaxSize)))
for i := 1; i <= totalpages; i++ {
siteMapPagedata := &schema.SiteMapPageList{}
nums = append(nums, i)
questionIDList, err := qs.questionRepo.GetQuestionIDsPage(ctx, i, int(schema.SitemapMaxSize))
if err != nil {
log.Error("GetQuestionIDsPage error", err)
return
}
siteMapPagedata.PageData = questionIDList
if setCacheErr := qs.SetCache(ctx, fmt.Sprintf(schema.SitemapPageCachekey, i), siteMapPagedata); setCacheErr != nil {
log.Errorf("set sitemap cron SetCache failed: %s", setCacheErr)
}
}
data.MaxPageNum = nums
}
if setCacheErr := qs.SetCache(ctx, schema.SitemapCachekey, data); setCacheErr != nil {
log.Errorf("set sitemap cron SetCache failed: %s", setCacheErr)
}
}
func (qs *QuestionCommon) SetCache(ctx context.Context, cachekey string, info interface{}) error {
infoStr, err := json.Marshal(info)
if err != nil {
return errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
}
err = qs.data.Cache.SetString(ctx, cachekey, string(infoStr), schema.DashBoardCacheTime)
if err != nil {
return errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
}
return nil
}
func (qs *QuestionCommon) ShowListFormat(ctx context.Context, data *entity.Question) *schema.QuestionInfo {
return qs.ShowFormat(ctx, data)
}

View File

@ -3,7 +3,6 @@ package service
import (
"encoding/json"
"fmt"
"math"
"strings"
"time"
@ -595,13 +594,16 @@ func (qs *QuestionService) UpdateQuestionInviteUser(ctx context.Context, req *sc
//send notification
oldInviteUserIDsStr := originQuestion.InviteUserID
oldInviteUserIDs := make([]string, 0)
needSendNotificationUserIDs := make([]string, 0)
if oldInviteUserIDsStr != "" {
err = json.Unmarshal([]byte(oldInviteUserIDsStr), &oldInviteUserIDs)
if err == nil {
needSendNotificationUserIDs := converter.ArrayNotInArray(oldInviteUserIDs, inviteUserIDs)
go qs.notificationInviteUser(ctx, needSendNotificationUserIDs, originQuestion.ID, originQuestion.Title, req.UserID)
needSendNotificationUserIDs = converter.ArrayNotInArray(oldInviteUserIDs, inviteUserIDs)
}
} else {
needSendNotificationUserIDs = inviteUserIDs
}
go qs.notificationInviteUser(ctx, needSendNotificationUserIDs, originQuestion.ID, originQuestion.Title, req.UserID)
return nil
}
@ -1353,52 +1355,5 @@ func (qs *QuestionService) changeQuestionToRevision(ctx context.Context, questio
}
func (qs *QuestionService) SitemapCron(ctx context.Context) {
data := &schema.SiteMapList{}
questionNum, err := qs.questionRepo.GetQuestionCount(ctx)
if err != nil {
log.Error("GetQuestionCount error", err)
return
}
if questionNum <= schema.SitemapMaxSize {
questionIDList, err := qs.questionRepo.GetQuestionIDsPage(ctx, 0, int(questionNum))
if err != nil {
log.Error("GetQuestionIDsPage error", err)
return
}
data.QuestionIDs = questionIDList
} else {
nums := make([]int, 0)
totalpages := int(math.Ceil(float64(questionNum) / float64(schema.SitemapMaxSize)))
for i := 1; i <= totalpages; i++ {
siteMapPagedata := &schema.SiteMapPageList{}
nums = append(nums, i)
questionIDList, err := qs.questionRepo.GetQuestionIDsPage(ctx, i, int(schema.SitemapMaxSize))
if err != nil {
log.Error("GetQuestionIDsPage error", err)
return
}
siteMapPagedata.PageData = questionIDList
if setCacheErr := qs.SetCache(ctx, fmt.Sprintf(schema.SitemapPageCachekey, i), siteMapPagedata); setCacheErr != nil {
log.Errorf("set sitemap cron SetCache failed: %s", setCacheErr)
}
}
data.MaxPageNum = nums
}
if setCacheErr := qs.SetCache(ctx, schema.SitemapCachekey, data); setCacheErr != nil {
log.Errorf("set sitemap cron SetCache failed: %s", setCacheErr)
}
}
func (qs *QuestionService) SetCache(ctx context.Context, cachekey string, info interface{}) error {
infoStr, err := json.Marshal(info)
if err != nil {
return errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
}
err = qs.data.Cache.SetString(ctx, cachekey, string(infoStr), schema.DashBoardCacheTime)
if err != nil {
return errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
}
return nil
qs.questioncommon.SitemapCron(ctx)
}

View File

@ -13,6 +13,7 @@ import (
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service/config"
"github.com/answerdev/answer/internal/service/export"
questioncommon "github.com/answerdev/answer/internal/service/question_common"
"github.com/answerdev/answer/internal/service/siteinfo_common"
tagcommon "github.com/answerdev/answer/internal/service/tag_common"
"github.com/answerdev/answer/plugin"
@ -27,6 +28,7 @@ type SiteInfoService struct {
emailService *export.EmailService
tagCommonService *tagcommon.TagCommonService
configService *config.ConfigService
questioncommon *questioncommon.QuestionCommon
}
func NewSiteInfoService(
@ -35,6 +37,8 @@ func NewSiteInfoService(
emailService *export.EmailService,
tagCommonService *tagcommon.TagCommonService,
configService *config.ConfigService,
questioncommon *questioncommon.QuestionCommon,
) *SiteInfoService {
plugin.RegisterGetSiteURLFunc(func() string {
generalSiteInfo, err := siteInfoCommonService.GetSiteGeneral(context.Background())
@ -51,6 +55,7 @@ func NewSiteInfoService(
emailService: emailService,
tagCommonService: tagCommonService,
configService: configService,
questioncommon: questioncommon,
}
}

View File

@ -19,7 +19,7 @@ echo "cmd is "$cmd
$cmd
if [ ! -f "./new_answer" ]; then
echo "new_answer is not exist build failed"
exit 0
exit 1
fi
rm answer
mv new_answer answer

5
ui/.gitignore vendored
View File

@ -31,3 +31,8 @@ yarn.lock
package-lock.json
.eslintcache
/.vscode/
/* !/src/plugins
/src/plugins/*
!/src/plugins/builtin
!/src/plugins/Demo

View File

@ -8,7 +8,6 @@
"build": "react-app-rewired build",
"lint": "eslint . --cache --fix --ext .ts,.tsx",
"prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
"prepare": "cd .. && husky install",
"preinstall": "node ./scripts/preinstall.js",
"pre-commit": "lint-staged"
},

View File

@ -560,13 +560,10 @@ export interface OauthBindEmailReq {
must: boolean;
}
export interface OauthConnectorItem {
export interface UserOauthConnectorItem {
icon: string;
name: string;
link: string;
}
export interface UserOauthConnectorItem extends OauthConnectorItem {
binding: boolean;
external_id: string;
}

View File

@ -0,0 +1,72 @@
import { FC, ReactNode, memo } from 'react';
import builtin from '@/plugins/builtin';
import * as plugins from '@/plugins';
import { Plugin, PluginType } from '@/utils/pluginKit';
/**
* NotePlease set at least either of the `slug_name` and `type` attributes, otherwise no plugins will be rendered.
*
* @field slug_name: The `slug_name` of the plugin needs to be rendered.
* If this property is set, `PluginRender` will use it first (regardless of whether `type` is set)
* to find the corresponding plugin and render it.
* @field type: Used to formulate the rendering of all plugins of this type.
* (if the `slug_name` attribute is set, it will be ignored)
* @field prop: Any attribute you want to configure, e.g. `className`
*/
interface Props {
slug_name?: string;
type?: PluginType;
children?: ReactNode;
[prop: string]: any;
}
const findPlugin: (s, k: 'slug_name' | 'type', v) => Plugin[] = (
source,
k,
v,
) => {
const ret: Plugin[] = [];
if (source) {
Object.keys(source).forEach((i) => {
const p = source[i];
if (p && p.component && p.info && p.info[k] === v) {
ret.push(p);
}
});
}
return ret;
};
const Index: FC<Props> = ({ slug_name, type, children, ...props }) => {
const fk = slug_name ? 'slug_name' : 'type';
const fv = fk === 'slug_name' ? slug_name : type;
const bp = findPlugin(builtin, fk, fv);
const vp = findPlugin(plugins, fk, fv);
const pluginSlice = [...bp, ...vp];
if (!pluginSlice.length) {
return null;
}
/**
* TODO: Rendering control for non-builtin plug-ins
* ps: Logic such as version compatibility determination can be placed here
*/
return (
<>
{pluginSlice.map((ps) => {
const PluginFC = ps.component;
return (
// @ts-ignore
<PluginFC key={ps.info.slug_name} {...props}>
{children}
</PluginFC>
);
})}
</>
);
};
export default memo(Index);

View File

@ -1,6 +1,3 @@
---
sidebar_position: 0
---
# Schema Form
## Introduction
@ -12,7 +9,7 @@ A React component capable of building HTML forms out of a [JSON schema](https://
```tsx
import React, { useState } from 'react';
import { SchemaForm, initFormData, JSONSchema, UISchema } from '@/components';
import { SchemaForm, initFormData, JSONSchema, UISchema, FormKit } from '@/components';
const schema: JSONSchema = {
title: 'General',
@ -51,6 +48,14 @@ const uiSchema: UISchema = {
const Form = () => {
const [formData, setFormData] = useState(initFormData(schema));
const formRef = useRef<{
validator: () => Promise<boolean>;
}>(null);
const refreshConfig: FormKit['refreshConfig'] = async () => {
// refreshFormConfig();
};
const handleChange = (data) => {
setFormData(data);
@ -58,27 +63,56 @@ const Form = () => {
return (
<SchemaForm
ref={formRef}
schema={schema}
uiSchema={uiSchema}
formData={formData}
onChange={handleChange}
refreshConfig={refreshConfig}
/>
);
};
export default Form;
```
## Props
---
| Property | Description | Type | Default |
| -------- | ---------------------------------------- | ------------------------------------- | ------- |
| schema | Describe the form structure with schema | [JSONSchema](#json-schema) | - |
| uiSchema | Describe the properties of the field | [UISchema](#uischema) | - |
| formData | Describe form data | [FormData](#formdata) | - |
| onChange | Callback function when form data changes | (data: [FormData](#formdata)) => void | - |
| onSubmit | Callback function when form is submitted | (data: React.FormEvent) => void | - |
## Form Props
```ts
interface FormProps {
// Describe the form structure with schema
schema: JSONSchema | null;
// Describe the properties of the field
uiSchema?: UISchema;
// Describe form data
formData: Type.FormDataType | null;
// Callback function when form data changes
onChange?: (data: Type.FormDataType) => void;
// Handler for when a form fires a `submit` event
onSubmit?: (e: React.FormEvent) => void;
/**
* Callback method for updating form configuration
* information (schema/uiSchema) in UIAction
*/
refreshConfig?: FormKit['refreshConfig'];
}
```
## Form Ref
```ts
export interface FormRef {
validator: () => Promise<boolean>;
}
```
When you need to validate a form and get the result outside the form, you can create a `FormRef` with `useRef` and pass it to the form using the `ref` property.
This allows you to validate the form and get the result outside the form using `formRef.current.validator()`.
---
## Types Definition
### JSONSchema
@ -102,24 +136,70 @@ export interface JSONSchema {
}
```
### UIOptions
### UISchema
```ts
export interface UIOptions {
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
'ui:options'?: UIOptions;
};
}
```
### UIWidget
```ts
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch'
| 'legend'
| 'button';
```
---
### UIOptions
```ts
export type UIOptions =
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions
| ButtonOptions;
```
#### BaseUIOptions
```ts
export interface BaseUIOptions {
empty?: string;
className?: string | string[];
// Will be appended to the className of the form component itself
className?: classnames.Argument;
// The className that will be attached to a form field container
fieldClassName?: classnames.Argument;
// Make a form component render into simplified mode
readOnly?: boolean;
simplify?: boolean;
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
}
```
### InputOptions
#### InputOptions
```ts
export interface InputOptions extends UIOptions {
export interface InputOptions extends BaseUIOptions {
placeholder?: string;
type?:
inputType?:
| 'color'
| 'date'
| 'datetime-local'
@ -136,82 +216,88 @@ export interface InputOptions extends UIOptions {
| 'week';
}
```
### SelectOptions
#### SelectOptions
```ts
export interface SelectOptions extends UIOptions {}
```
### UploadOptions
#### UploadOptions
```ts
export interface UploadOptions extends UIOptions {
export interface UploadOptions extends BaseUIOptions {
acceptType?: string;
imageType?: 'post' | 'avatar' | 'branding';
imageType?: Type.UploadType;
}
```
### SwitchOptions
#### SwitchOptions
```ts
export interface SwitchOptions extends UIOptions {}
export interface SwitchOptions extends BaseUIOptions {
label?: string;
}
```
### TimezoneOptions
#### TimezoneOptions
```ts
export interface TimezoneOptions extends UIOptions {
placeholder?: string;
}
```
### CheckboxOptions
#### CheckboxOptions
```ts
export interface CheckboxOptions extends UIOptions {}
```
### RadioOptions
#### RadioOptions
```ts
export interface RadioOptions extends UIOptions {}
```
### TextareaOptions
#### TextareaOptions
```ts
export interface TextareaOptions extends UIOptions {
placeholder?: string;
rows?: number;
}
```
### UIWidget
#### ButtonOptions
```ts
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch';
export interface ButtonOptions extends BaseUIOptions {
text: string;
icon?: string;
action?: UIAction;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
}
```
### UISchema
#### UIAction
```ts
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
'ui:options'?:
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions;
export interface UIAction {
url: string;
method?: 'get' | 'post' | 'put' | 'delete';
loading?: {
text: string;
state?: 'none' | 'pending' | 'completed';
};
on_complete?: {
toast_return_message?: boolean;
refresh_form_config?: boolean;
};
}
```
#### FormKit
```ts
export interface FormKit {
refreshConfig(): void;
}
```
---
### FormData
```ts
export interface FormValue<T = any> {
@ -226,6 +312,44 @@ export interface FormDataType {
}
```
---
## Backend API
For backend generating modal form you can return json like this.
### Response
```json
{
"name": "string",
"slug_name": "string",
"description": "string",
"version": "string",
"config_fields": [
{
"name": "string",
"type": "textarea",
"title": "string",
"description": "string",
"required": true,
"value": "string",
"ui_options": {
"placeholder": "placeholder",
"rows": 4
},
"options": [
{
"value": "string",
"label": "string"
}
]
}
]
}
```
## reference
- [json schema](https://json-schema.org/understanding-json-schema/index.html)

View File

@ -12,7 +12,13 @@ import classnames from 'classnames';
import type * as Type from '@/common/interface';
import type { JSONSchema, UISchema, BaseUIOptions, FormKit } from './types';
import type {
JSONSchema,
FormProps,
FormRef,
BaseUIOptions,
FormKit,
} from './types';
import {
Legend,
Select,
@ -27,20 +33,6 @@ import {
export * from './types';
interface IProps {
schema: JSONSchema | null;
formData: Type.FormDataType | null;
uiSchema?: UISchema;
refreshConfig?: FormKit['refreshConfig'];
hiddenSubmit?: boolean;
onChange?: (data: Type.FormDataType) => void;
onSubmit?: (e: React.FormEvent) => void;
}
interface IRef {
validator: () => Promise<boolean>;
}
/**
* TODO:
* - [!] Standardised `Admin/Plugins/Config/index.tsx` method for generating dynamic form configurations.
@ -60,7 +52,7 @@ interface IRef {
* @param onChange change event
* @param onSubmit submit event
*/
const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
const SchemaForm: ForwardRefRenderFunction<FormRef, FormProps> = (
{
schema,
uiSchema = {},

View File

@ -1,9 +1,24 @@
import { ButtonProps } from 'react-bootstrap';
import React from 'react';
import classnames from 'classnames';
import * as Type from '@/common/interface';
export interface FormProps {
schema: JSONSchema | null;
uiSchema?: UISchema;
formData: Type.FormDataType | null;
refreshConfig?: FormKit['refreshConfig'];
hiddenSubmit?: boolean;
onChange?: (data: Type.FormDataType) => void;
onSubmit?: (e: React.FormEvent) => void;
}
export interface FormRef {
validator: () => Promise<boolean>;
}
export interface JSONSchema {
title: string;
description?: string;

View File

@ -41,6 +41,7 @@ import HttpErrorContent from './HttpErrorContent';
import CustomSidebar from './CustomSidebar';
import ImgViewer from './ImgViewer';
import SideNav from './SideNav';
import PluginRender from './PluginRender';
export {
Avatar,
@ -88,5 +89,6 @@ export {
CustomSidebar,
ImgViewer,
SideNav,
PluginRender,
};
export type { EditorRef, JSONSchema, UISchema };

View File

@ -4,22 +4,42 @@ import i18next from 'i18next';
import en_US from '@i18n/en_US.yaml';
import zh_CN from '@i18n/zh_CN.yaml';
import { DEFAULT_LANG } from '@/common/constants';
import { DEFAULT_LANG, LANG_RESOURCE_STORAGE_KEY } from '@/common/constants';
import Storage from '@/utils/storage';
/**
* Prevent i18n from re-initialising when the page is refreshed and switching to `fallbackLng`.
*/
const initLng = i18next.resolvedLanguage || DEFAULT_LANG;
const initResources = {
en_US: {
translation: en_US.ui,
},
zh_CN: {
translation: zh_CN.ui,
},
};
const storageLang = Storage.get(LANG_RESOURCE_STORAGE_KEY);
if (
storageLang &&
storageLang.resources &&
storageLang.lng &&
storageLang.lng !== 'en_US' &&
storageLang.lng !== 'zh_CN'
) {
initResources[storageLang.lng] = {
translation: storageLang.resources,
};
}
i18next
// pass the i18n instance to react-i18next.
.use(initReactI18next)
.init({
resources: {
en_US: {
translation: en_US.ui,
},
zh_CN: {
translation: zh_CN.ui,
},
},
// debug: process.env.NODE_ENV === 'development',
fallbackLng: process.env.REACT_APP_LANG || DEFAULT_LANG,
resources: initResources,
lng: initLng,
fallbackLng: DEFAULT_LANG,
interpolation: {
escapeValue: false,
},

View File

@ -13,7 +13,7 @@ import { getLoginConf, checkLoginResult } from './service';
let checkTimer: NodeJS.Timeout;
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins' });
const { t } = useTranslation('translation', { keyPrefix: 'user_center' });
const navigate = useNavigate();
const ucAgent = userCenterStore().agent;
const agentName = ucAgent?.agent_info?.name || '';

View File

@ -28,7 +28,7 @@ const data = [
];
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins' });
const { t } = useTranslation('translation', { keyPrefix: 'user_center' });
const ucAgent = userCenterStore().agent;
return (
<Col lg={4} className="mx-auto mt-3 py-5">

View File

@ -9,8 +9,7 @@ import type {
ImgCodeRes,
FormDataType,
} from '@/common/interface';
import { Unactivate, WelcomeTitle } from '@/components';
import { PluginOauth, PluginUcLogin } from '@/plugins';
import { Unactivate, WelcomeTitle, PluginRender } from '@/components';
import {
loggedUserInfoStore,
loginSettingStore,
@ -172,15 +171,16 @@ const Index: React.FC = () => {
usePageTags({
title: t('login', { keyPrefix: 'page_title' }),
});
return (
<Container style={{ paddingTop: '4rem', paddingBottom: '5rem' }}>
<WelcomeTitle />
{step === 1 ? (
<Col className="mx-auto" md={6} lg={4} xl={3}>
{ucAgentInfo ? (
<PluginUcLogin className="mb-5" />
<PluginRender slug_name="uc_login" className="mb-5" />
) : (
<PluginOauth className="mb-5" />
<PluginRender type="Connector" className="mb-5" />
)}
{canOriginalLogin ? (
<>

View File

@ -3,8 +3,7 @@ import { Container, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { Unactivate, WelcomeTitle } from '@/components';
import { PluginOauth } from '@/plugins';
import { Unactivate, WelcomeTitle, PluginRender } from '@/components';
import { guard } from '@/utils';
import SignUpForm from './components/SignUpForm';
@ -27,7 +26,7 @@ const Index: React.FC = () => {
{showForm ? (
<Col className="mx-auto" md={6} lg={4} xl={3}>
<PluginOauth className="mb-5" />
<PluginRender type="Connector" className="mb-5" />
<SignUpForm callback={onStep} />
</Col>
) : (

View File

@ -18,7 +18,7 @@ const Index = () => {
});
const { t: t2 } = useTranslation('translation', {
keyPrefix: 'plugins.oauth',
keyPrefix: 'oauth',
});
const deleteLogins = (e, item) => {

View File

@ -0,0 +1,20 @@
package demo
import "github.com/answerdev/answer/plugin"
type DemoPlugin struct {
}
func init() {
plugin.Register(&DemoPlugin{})
}
func (d DemoPlugin) Info() plugin.Info {
return plugin.Info{
Name: plugin.MakeTranslator("i18n.demo.name"),
SlugName: "demo_plugin",
Description: plugin.MakeTranslator("i18n.demo.description"),
Author: "answerdev",
Version: "0.0.1",
}
}

View File

@ -0,0 +1,4 @@
plugin:
ui_plugin_demo:
ui:
msg: UI Plugin Demo

View File

@ -0,0 +1,9 @@
import pluginKit from '@/utils/pluginKit';
import en_US from './en_US.yaml';
import zh_CN from './zh_CN.yaml';
pluginKit.initI18nResource({
en_US,
zh_CN,
});

View File

@ -0,0 +1,4 @@
plugin:
ui_plugin_demo:
ui:
msg: UI 插件示例

View File

@ -0,0 +1,24 @@
import { memo, FC } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert } from 'react-bootstrap';
import pluginKit, { PluginInfo } from '@/utils/pluginKit';
import './i18n';
import info from './info.yaml';
const pluginInfo: PluginInfo = {
slug_name: info.slug_name,
};
const Index: FC = () => {
const { t } = useTranslation(pluginKit.getTransNs(), {
keyPrefix: pluginKit.getTransKeyPrefix(pluginInfo),
});
return <Alert variant="info">{t('msg')}</Alert>;
};
export default {
info: pluginInfo,
component: memo(Index),
};

View File

@ -0,0 +1,4 @@
slug_name: ui_plugin_demo
version: 0.0.1
author: Answer.dev

View File

@ -0,0 +1,5 @@
plugin:
connector:
ui:
connect: Connect with {{ auth_name }}
remove: Remove {{ auth_name }}

View File

@ -0,0 +1,9 @@
import pluginKit from '@/utils/pluginKit';
import en_US from './en_US.yaml';
import zh_CN from './zh_CN.yaml';
pluginKit.initI18nResource({
en_US,
zh_CN,
});

View File

@ -0,0 +1,6 @@
plugin:
connector:
ui:
connect: 连接到 {{ auth_name }}
remove: 解绑 {{ auth_name }}

View File

@ -4,14 +4,25 @@ import { useTranslation } from 'react-i18next';
import classnames from 'classnames';
import { useGetStartUseOauthConnector } from '@/services';
import pluginKit, { PluginInfo } from '@/utils/pluginKit';
import { SvgIcon } from '@/components';
import info from './info.yaml';
import { useGetStartUseOauthConnector } from './services';
import './i18n';
const pluginInfo: PluginInfo = {
slug_name: info.slug_name,
type: info.type,
};
interface Props {
className?: string;
}
const Index: FC<Props> = ({ className }) => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins.oauth' });
const { t } = useTranslation(pluginKit.getTransNs(), {
keyPrefix: pluginKit.getTransKeyPrefix(pluginInfo),
});
const { data } = useGetStartUseOauthConnector();
if (!data?.length) return null;
@ -29,4 +40,7 @@ const Index: FC<Props> = ({ className }) => {
);
};
export default memo(Index);
export default {
info: pluginInfo,
component: memo(Index),
};

View File

@ -0,0 +1,6 @@
slug_name: connector
type: Connector
version: 0.0.1
link: https://github.com/answerdev/plugins/tree/main/connector/
author: Answer.dev

View File

@ -0,0 +1,21 @@
import useSWR from 'swr';
import request from '@/utils/request';
export interface OauthConnectorItem {
icon: string;
name: string;
link: string;
}
export const useGetStartUseOauthConnector = () => {
const { data, error } = useSWR<OauthConnectorItem[]>(
'/answer/api/v1/connector/info',
request.instance.get,
);
return {
data,
error,
};
};

View File

@ -0,0 +1,6 @@
plugin:
uc_login:
ui:
login: Login
qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in.
login_failed_email_tip: Login failed, please allow this app to access your email information before try again.

View File

@ -0,0 +1,9 @@
import pluginKit from '@/utils/pluginKit';
import en_US from './en_US.yaml';
import zh_CN from './zh_CN.yaml';
pluginKit.initI18nResource({
en_US,
zh_CN,
});

View File

@ -0,0 +1,6 @@
plugin:
uc_login:
ui:
login: 登录
qrcode_login_tip: 请使用 {{ agentName }} 扫描二维码登录
login_failed_email_tip: 登录失败, 请允许该应用程序访问您的电子邮件信息,然后再试一次。

View File

@ -4,14 +4,25 @@ import { useTranslation } from 'react-i18next';
import classnames from 'classnames';
import pluginKit, { PluginInfo } from '@/utils/pluginKit';
import { SvgIcon } from '@/components';
import { userCenterStore } from '@/stores';
import './i18n';
import info from './info.yaml';
interface Props {
className?: classnames.Argument;
}
const pluginInfo: PluginInfo = {
slug_name: info.slug_name,
};
const Index: FC<Props> = ({ className }) => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins.oauth' });
const { t } = useTranslation(pluginKit.getTransNs(), {
keyPrefix: pluginKit.getTransKeyPrefix(pluginInfo),
});
const ucAgent = userCenterStore().agent;
const ucLoginRedirect =
ucAgent?.enabled && ucAgent?.agent_info?.login_redirect_url;
@ -31,5 +42,7 @@ const Index: FC<Props> = ({ className }) => {
}
return null;
};
export default memo(Index);
export default {
info: pluginInfo,
component: memo(Index),
};

View File

@ -0,0 +1,4 @@
slug_name: uc_login
version: 0.0.1
author: Answer.dev

View File

@ -0,0 +1,7 @@
import Connector from './Connector';
import UcLogin from './UcLogin';
export default {
Connector,
UcLogin,
};

View File

@ -1,4 +1,3 @@
import PluginOauth from './PluginOauth';
import PluginUcLogin from './PluginUcLogin';
export default null;
export { PluginOauth, PluginUcLogin };
// export { default as Demo } from './Demo';

View File

@ -1,23 +0,0 @@
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface PluginOption {
label: string;
value: string;
}
export interface PluginItem {
name: string;
type: UIWidget;
title: string;
description: string;
ui_options?: UIOptions;
options?: PluginOption[];
value?: string;
required?: boolean;
}
export interface PluginConfig {
name: string;
slug_name: string;
config_fields: PluginItem[];
}

View File

@ -2,7 +2,29 @@ import qs from 'qs';
import useSWR from 'swr';
import request from '@/utils/request';
import type { PluginConfig } from '@/plugins/types';
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface PluginOption {
label: string;
value: string;
}
export interface PluginItem {
name: string;
type: UIWidget;
title: string;
description: string;
ui_options?: UIOptions;
options?: PluginOption[];
value?: string;
required?: boolean;
}
export interface PluginConfig {
name: string;
slug_name: string;
config_fields: PluginItem[];
}
export const useQueryPlugins = (params) => {
const apiUrl = `/answer/admin/api/plugins?${qs.stringify(params)}`;

View File

@ -23,14 +23,3 @@ export const useOauthConnectorInfoByUser = () => {
export const userOauthUnbind = (data: { external_id: string }) => {
return request.delete('/answer/api/v1/connector/user/unbinding', data);
};
export const useGetStartUseOauthConnector = () => {
const { data, error } = useSWR<Type.OauthConnectorItem[]>(
'/answer/api/v1/connector/info',
request.instance.get,
);
return {
data,
error,
};
};

View File

@ -17,6 +17,9 @@ import {
import Storage from './storage';
/**
* localize kit for i18n
*/
export const loadLanguageOptions = async (forAdmin = false) => {
const languageOptions = forAdmin
? await getAdminLanguageOptions()
@ -68,13 +71,6 @@ const addI18nResource = async (langName) => {
}
};
dayjs.extend(utc);
dayjs.extend(timezone);
const localeDayjs = (langName) => {
langName = langName.replace('_', '-').toLowerCase();
dayjs.locale(langName);
};
export const getCurrentLang = () => {
const loggedUser = loggedUserInfoStore.getState().user;
const adminInterface = interfaceStore.getState().interface;
@ -88,6 +84,16 @@ export const getCurrentLang = () => {
return currentLang;
};
/**
* localize for Day.js
*/
dayjs.extend(utc);
dayjs.extend(timezone);
const localeDayjs = (langName) => {
langName = langName.replace('_', '-').toLowerCase();
dayjs.locale(langName);
};
export const setupAppLanguage = async () => {
const lang = getCurrentLang();
if (!i18next.getDataByLanguage(lang)) {

78
ui/src/utils/pluginKit.ts Normal file
View File

@ -0,0 +1,78 @@
import { NamedExoticComponent, FC } from 'react';
import i18next from 'i18next';
/**
* This information is to be defined for all components.
* It may be used for feature upgrades or version compatibility processing.
*
* @field slug_name: Unique identity string for the plugin, usually configured in `info.yaml`
* @field type: The type of plugin is defined and a single type of plugin can have multiple implementations.
* For example, a plugin of type `Connector` can have a `google` implementation and a `github` implementation.
* `PluginRender` automatically renders the plug-in types already included in `PluginType`.
* @field name: Plugin name, optionally configurable. Usually read from the `i18n` file
* @field description: Plugin description, optionally configurable. Usually read from the `i18n` file
*/
const I18N_NS = 'plugin';
export type PluginType = 'Connector';
export interface PluginInfo {
slug_name: string;
type?: PluginType;
name?: string;
description?: string;
}
export interface Plugin {
info: PluginInfo;
component: NamedExoticComponent | FC;
}
interface I18nResource {
[lng: string]: {
plugin: {
[slug_name: string]: {
ui: any;
};
};
};
}
const addResourceBundle = (resource: I18nResource) => {
if (resource) {
Object.keys(resource).forEach((lng) => {
const r = resource[lng];
i18next.addResourceBundle(lng, I18N_NS, r.plugin, true, true);
});
}
};
const initI18nResource = (resource: I18nResource) => {
addResourceBundle(resource);
/**
* Note: In development mode,
* when the base i18n file is changed, `i18next` will reinitialise the updated resource file,
* which will cause the resource package added in the plugin to be lost
* and will need to be automatically re-added by listening for events
*/
i18next.on('initialized', () => {
addResourceBundle(resource);
});
};
const getTransNs = () => {
return I18N_NS;
};
const getTransKeyPrefix = (info: PluginInfo) => {
const kp = `${info.slug_name}.ui`;
return kp;
};
export default {
initI18nResource,
getTransNs,
getTransKeyPrefix,
};

View File

@ -2,7 +2,11 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{{ range .list }}
<url>
{{if $.hastitle}}
<loc>{{$.general.SiteUrl}}/questions/{{.ID}}/{{.Title}}</loc>
{{else}}
<loc>{{$.general.SiteUrl}}/questions/{{.ID}}</loc>
{{end}}
<lastmod>{{.UpdateTime}}</lastmod>
</url>
{{ end }}