Commit 5565aa5b by Ivan

feat: 通过时选择标签

parent a152fff4
......@@ -13,7 +13,6 @@ class Admin::ImagesController < ApplicationController
tag: @tag.as_json(only: [ :id, :name, :catalog ], methods: [ :images_count ]),
images: @images.as_json(include: [ :user, :tags ], methods: [ :file_url, :thumbnail_url, :medium_url ]),
filters: params[:q] || {},
tags: Tag.all.to_json,
pagination: {
current_page: @images.current_page,
total_pages: @images.total_pages,
......@@ -33,7 +32,6 @@ class Admin::ImagesController < ApplicationController
total_pages: @images.total_pages,
total_count: @images.total_count
},
tags: Tag.all.to_json,
pending_count: Image.pending.count,
approved_count: Image.approved.count,
rejected_count: Image.rejected.count
......@@ -43,28 +41,23 @@ class Admin::ImagesController < ApplicationController
def show
render inertia: "admin/images/Show", props: {
image: @image.as_json(include: [ :user, :tags ], methods: [ :file_url, :thumbnail_url, :medium_url ]),
tags: Tag.all.as_json
image: @image.as_json(include: [ :user, :tags ], methods: [ :file_url, :thumbnail_url, :medium_url ])
}
end
def edit
render inertia: "admin/images/Edit", props: {
image: @image.as_json(include: [ :user, :tags ], methods: [ :file_url, :thumbnail_url, :medium_url ]),
tags: Tag.all.as_json
image: @image.as_json(include: [ :user, :tags ], methods: [ :file_url, :thumbnail_url, :medium_url ])
}
end
def new
if params[:tag_id].present?
render inertia: "admin/tags/images/New", props: {
tag: @tag.as_json(only: [ :id, :name, :catalog ], methods: [ :images_count ]),
tags: Tag.all.as_json
tag: @tag.as_json(only: [ :id, :name, :catalog ], methods: [ :images_count ])
}
else
render inertia: "admin/images/New", props: {
tags: Tag.all.as_json
}
render inertia: "admin/images/New", props: {}
end
end
......@@ -112,7 +105,6 @@ class Admin::ImagesController < ApplicationController
else
render inertia: "admin/images/Edit", props: {
image: @image.as_json(include: [ :user, :tags ], methods: [ :file_url, :thumbnail_url, :medium_url, :errors ]),
tags: Tag.all.as_json,
errors: @image.errors
}, status: :unprocessable_entity
end
......@@ -123,50 +115,12 @@ class Admin::ImagesController < ApplicationController
redirect_to admin_images_path, notice: "Image was successfully deleted."
end
def pending
@q = Image.pending.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(20)
render inertia: "admin/images/Pending", props: {
images: @images.as_json(include: [ :user, :tags ], methods: [ :file_url, :thumbnail_url, :medium_url ]),
filters: params[:q] || {},
total_count: Image.count,
pending_count: Image.pending.count,
approved_count: Image.approved.count,
rejected_count: Image.rejected.count
}
end
def approved
@q = Image.approved.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(20)
render inertia: "admin/images/Approved", props: {
images: @images.as_json(include: [ :user, :tags ], methods: [ :file_url, :thumbnail_url, :medium_url ]),
filters: params[:q] || {},
total_count: Image.count,
pending_count: Image.pending.count,
approved_count: Image.approved.count,
rejected_count: Image.rejected.count
}
end
def rejected
@q = Image.rejected.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(20)
render inertia: "admin/images/Rejected", props: {
images: @images.as_json(include: [ :user, :tags ], methods: [ :file_url, :thumbnail_url, :medium_url ]),
filters: params[:q] || {},
total_count: Image.count,
pending_count: Image.pending.count,
approved_count: Image.approved.count,
rejected_count: Image.rejected.count
}
end
def approve
Image.transaction do
@image.approved!
@image.set_tags_by_ids(params[:tag_ids]) if params[:tag_ids].present?
end
redirect_to admin_image_path(@image), notice: "Image was successfully approved."
end
......
......@@ -15,7 +15,6 @@ class ImagesController < ApplicationController
tag: @tag.as_json(only: [ :id, :name, :catalog ], methods: [ :images_count ]),
images: @images.as_json(include: [ :user, :tags ], methods: [ :file_url, :thumbnail_url, :medium_url ]),
filters: params[:q] || {},
tags: Tag.all.to_json,
pagination: {
current_page: @images.current_page,
total_pages: @images.total_pages,
......@@ -30,7 +29,6 @@ class ImagesController < ApplicationController
render inertia: "images/Index", props: {
images: @images.as_json(include: [ :user, :tags ], methods: [ :file_url, :thumbnail_url, :medium_url ]),
filters: params[:q] || {},
tags: Tag.all.to_json,
pagination: {
current_page: @images.current_page,
total_pages: @images.total_pages,
......@@ -51,9 +49,7 @@ class ImagesController < ApplicationController
def new
@image = Image.new
render inertia: "images/New", props: {
tags: Tag.all.as_json
}
render inertia: "images/New", props: {}
end
def create
......@@ -75,13 +71,13 @@ class ImagesController < ApplicationController
def edit
authorize_user
unless @image.status == "pending" || Current.user&.admin?
return redirect_to image_path(@image), notice: "没有权限修改"
end
render inertia: "images/Edit", props: {
image: @image.as_json(include: [ :tags ], methods: [ :file_url ]),
tags: Tag.all.as_json
image: @image.as_json(include: [ :tags ], methods: [ :file_url ])
}
end
......@@ -101,23 +97,32 @@ class ImagesController < ApplicationController
else
render inertia: "images/Edit", props: {
image: @image.as_json(include: [ :tags ], methods: [ :file_url, :errors ]),
tags: Tag.all.as_json,
errors: @image.errors
}, status: :unprocessable_entity
end
end
def destroy
@image.destroy
authorize_admin
@image.destroy!
redirect_to images_path, notice: "Image was successfully deleted."
end
def approve
authorize_admin
Image.transaction do
@image.approved!
@image.set_tags_by_ids(params[:tag_ids]) if params[:tag_ids].present?
end
redirect_to image_path(@image), notice: "Image was successfully approved."
end
def reject
authorize_admin
@image.rejected!
redirect_to image_path(@image), notice: "Image was rejected."
end
......
class TagsController < ApplicationController
allow_unauthenticated_access only: [ :index ]
# allow_unauthenticated_access only: [ :index ]
def index
@tags = Tag.includes(:images).all
......@@ -17,59 +17,67 @@ class TagsController < ApplicationController
@grouped_tags[catalog] = grouped_tags[catalog]
end
render inertia: "tags/Index", props: {
tags: @grouped_tags.transform_values do |tags|
data = @grouped_tags.transform_values do |tags|
tags.map do |tag|
tag.as_json(only: [ :id, :name, :catalog ], methods: [ :images_count ])
end
end
}
end
def new
render inertia: "tags/New"
end
def create
@tag = Tag.new(tag_params)
if @tag.save
redirect_to tags_path, notice: "Tag was successfully created."
else
render inertia: "tags/New", props: {
tag: @tag.as_json,
errors: @tag.errors
}, status: :unprocessable_entity
end
end
def edit
@tag = Tag.find(params[:id])
render inertia: "tags/Edit", props: {
tag: @tag.as_json(only: [ :id, :name, :catalog ])
respond_to do |format|
format.html do
render inertia: "tags/Index", props: {
tags: data
}
end
def update
@tag = Tag.find(params[:id])
if @tag.update(tag_params)
redirect_to tags_path, notice: "Tag was successfully updated."
else
render inertia: "tags/Edit", props: {
tag: @tag.as_json,
errors: @tag.errors
}, status: :unprocessable_entity
format.json { render json: { tags: data } }
end
end
def destroy
@tag = Tag.find(params[:id])
@tag.destroy
redirect_to tags_path, notice: "Tag was successfully deleted."
end
# def new
# render inertia: "tags/New"
# end
# def create
# @tag = Tag.new(tag_params)
# if @tag.save
# redirect_to tags_path, notice: "Tag was successfully created."
# else
# render inertia: "tags/New", props: {
# tag: @tag.as_json,
# errors: @tag.errors
# }, status: :unprocessable_entity
# end
# end
# def edit
# @tag = Tag.find(params[:id])
# render inertia: "tags/Edit", props: {
# tag: @tag.as_json(only: [ :id, :name, :catalog ])
# }
# end
# def update
# @tag = Tag.find(params[:id])
# if @tag.update(tag_params)
# redirect_to tags_path, notice: "Tag was successfully updated."
# else
# render inertia: "tags/Edit", props: {
# tag: @tag.as_json,
# errors: @tag.errors
# }, status: :unprocessable_entity
# end
# end
# def destroy
# @tag = Tag.find(params[:id])
# @tag.destroy
# redirect_to tags_path, notice: "Tag was successfully deleted."
# end
private
......
import { useState, useEffect } from 'react'
import * as Popover from '@radix-ui/react-popover'
import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { isEqual } from 'lodash-es'
import axios from 'axios'
export default function TagSearchInput({ value, onChange, allTags = {}, placeholder = "输入标签名称搜索" }) {
export default function TagSearchInput({ value, onChange, placeholder = "输入标签名称搜索" }) {
// value 现在是逗号分隔的标签 ID 列表
const [searchQuery, setSearchQuery] = useState('')
const [selectedTags, setSelectedTags] = useState([])
......@@ -12,49 +12,34 @@ export default function TagSearchInput({ value, onChange, allTags = {}, placehol
const [filteredTags, setFilteredTags] = useState({})
const [internalValue, setInternalValue] = useState(value || [])
// 处理标签数据和选中标签
// 获取标签数据
useEffect(() => {
// 处理标签数据,支持数组或已分组的对象格式
let processedTags = {}
let tagsData = allTags
// 如果是 JSON 字符串,先解析
if (typeof allTags === 'string') {
const fetchTags = async () => {
try {
tagsData = JSON.parse(allTags)
} catch (e) {
console.error('Failed to parse tags JSON:', e)
tagsData = []
}
}
const response = await axios.get('/tags.json')
// 如果传入的是数组,需要按catalog属性分组
if (Array.isArray(tagsData)) {
// 按catalog属性分组标签
const groupedByCategory = {}
tagsData.forEach(tag => {
const catalog = tag.catalog || ''
if (!groupedByCategory[catalog]) {
groupedByCategory[catalog] = []
// 处理获取到的标签数据
if (response.data && response.data.tags) {
setAvailableTags(response.data.tags)
}
groupedByCategory[catalog].push(tag)
})
processedTags = groupedByCategory
} else {
// 如果已经是分组好的对象,直接使用
processedTags = tagsData || {}
setSelectedTags(Object.values(response.data.tags).flat().filter(tag => internalValue.includes(tag.id)))
} catch (error) {
console.error('Error fetching tags:', error)
setAvailableTags({})
}
}
// 更新分组后的标签
setAvailableTags(processedTags)
fetchTags()
}, [])
// 处理选中的标签
// 使用 JSON.stringify 比较数组,因于 isEqual 可能不总是可靠的用于空数组
useEffect(() => {
// 使用 JSON.stringify 比较数组
const valueChanged = JSON.stringify(value) !== JSON.stringify(internalValue);
// 如果值发生变化或强制更新,则更新内部值
// 如果值发生变化,则更新内部值
if (valueChanged) {
const newValue = value || [];
setInternalValue(newValue);
......@@ -65,7 +50,7 @@ export default function TagSearchInput({ value, onChange, allTags = {}, placehol
// 从所有可用标签中找出已选标签
const selected = [];
Object.values(processedTags).forEach(tagGroup => {
Object.values(availableTags).forEach(tagGroup => {
tagGroup.forEach(tag => {
if (tagIds.includes(tag.id)) {
selected.push(tag);
......@@ -79,7 +64,7 @@ export default function TagSearchInput({ value, onChange, allTags = {}, placehol
setSelectedTags([]);
}
}
}, [allTags, value, internalValue])
}, [value, internalValue, availableTags])
// 根据搜索词过滤标签
useEffect(() => {
......@@ -174,7 +159,7 @@ export default function TagSearchInput({ value, onChange, allTags = {}, placehol
</div>
<Popover.Portal>
<Popover.Content
className="bg-white rounded-md shadow-lg p-4 w-72 max-w-[calc(100vw-2rem)] z-50"
className="bg-white rounded-md shadow-lg p-4 w-72 max-w-[calc(100vw-2rem)] z-2050"
sideOffset={5}
>
<div className="space-y-4">
......
import { useState } from 'react'
import { Link } from '@inertiajs/react'
import { useState, useEffect } from 'react'
import { Link, router } from '@inertiajs/react'
import * as Dialog from '@radix-ui/react-dialog'
import * as Form from '@radix-ui/react-form'
import ComImageStatusTag from './ComImageStatusTag'
import TagSearchInput from '../TagSearchInput'
export default function ComImageShow({ path, image, can_edit, can_approve, isAdmin = false }) {
const [isModalOpen, setIsModalOpen] = useState(false)
const [isApproveDialogOpen, setIsApproveDialogOpen] = useState(false)
const [selectedTagIds, setSelectedTagIds] = useState(image.tags?.map(tag => tag.id) || [])
const [isSubmitting, setIsSubmitting] = useState(false)
// Update selectedTagIds when image changes
useEffect(() => {
if (image && image.tags) {
setSelectedTagIds(image.tags.map(tag => tag.id));
}
}, [image])
const handleApprove = () => {
setIsSubmitting(true)
router.patch(`${path}/approve`, {
tag_ids: selectedTagIds
}, {
onSuccess: () => {
setIsApproveDialogOpen(false)
setIsSubmitting(false)
},
onError: () => {
setIsSubmitting(false)
}
})
}
return (
<div className="w-full bg-white shadow overflow-hidden sm:rounded-lg">
......@@ -27,14 +54,13 @@ export default function ComImageShow({ path, image, can_edit, can_approve, isAdm
)}
{can_approve && image.status === 'pending' && (
<>
<Link
href={`${path}/approve`}
method="patch"
as="button"
<button
type="button"
onClick={() => setIsApproveDialogOpen(true)}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
通过
</Link>
</button>
<Link
href={`${path}/reject`}
method="patch"
......@@ -122,8 +148,8 @@ export default function ComImageShow({ path, image, can_edit, can_approve, isAdm
{/* 全屏图片模态框 */}
<Dialog.Root open={isModalOpen} onOpenChange={setIsModalOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed inset-0 flex items-center justify-center z-50">
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-100" />
<Dialog.Content className="fixed inset-0 flex items-center justify-center z-150">
<Dialog.Title className="hidden"/>
<div className="relative w-full h-full max-w-screen-xl max-h-screen p-4">
<img
......@@ -156,6 +182,57 @@ export default function ComImageShow({ path, image, can_edit, can_approve, isAdm
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{/* 审核通过确认对话框 */}
<Dialog.Root open={isApproveDialogOpen} onOpenChange={setIsApproveDialogOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-100" />
<Dialog.Content className="fixed z-150 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-lg shadow-xl p-6 z-50">
<Dialog.Title className="text-lg font-medium text-gray-900 mb-4">
确认通过审核
</Dialog.Title>
<div className="mb-4">
<p className="text-sm text-gray-500 mb-4">
请为此图片选择合适的标签,然后点击确认通过审核。
</p>
<Form.Root>
<Form.Field name="tags" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
标签
</Form.Label>
<Form.Control asChild>
<TagSearchInput
value={selectedTagIds}
onChange={(value) => setSelectedTagIds(value)}
placeholder="点击搜索图标添加标签"
/>
</Form.Control>
</Form.Field>
</Form.Root>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Dialog.Close asChild>
<button
type="button"
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
取消
</button>
</Dialog.Close>
<button
type="button"
disabled={isSubmitting}
onClick={handleApprove}
className="inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
>
{isSubmitting ? '处理中...' : '确认通过'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</div>
)
}
import { Head, useForm } from '@inertiajs/react'
import { Head, useForm, router } from '@inertiajs/react'
import Layout from '../Layout'
import ImageUploadForm from '../../components/ImageUploadForm'
......@@ -8,14 +8,14 @@ export default function Edit({ image, tags, auth, errors = {} }) {
? image.tags.map(tag => tag.id)
: []
const { data, setData, patch, processing } = useForm({
const { data, setData, processing } = useForm({
title: image.title || '',
tag_ids: initialTagIds, // 使用tag_ids而不是tags
})
const handleSubmit = (e) => {
e.preventDefault()
patch(`/images/${image.id}`)
router.patch(`/admin/images/${image.id}`, { image: data })
}
return (
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment