feat: add category option for site

This commit is contained in:
Jacky 2024-10-25 10:00:50 +08:00
parent 7ad5cac3b8
commit 207f80f858
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
16 changed files with 1452 additions and 508 deletions

View file

@ -69,7 +69,7 @@ func GetSite(c *gin.Context) {
c.JSON(http.StatusOK, Site{
ModifiedAt: file.ModTime(),
Advanced: site.Advanced,
Site: site,
Enabled: enabled,
Name: name,
Config: string(origContent),
@ -102,7 +102,7 @@ func GetSite(c *gin.Context) {
c.JSON(http.StatusOK, Site{
ModifiedAt: file.ModTime(),
Advanced: site.Advanced,
Site: site,
Enabled: enabled,
Name: name,
Config: nginxConfig.FmtCode(),
@ -127,6 +127,7 @@ func SaveSite(c *gin.Context) {
var json struct {
Name string `json:"name" binding:"required"`
Content string `json:"content" binding:"required"`
SiteCategoryID uint64 `json:"site_category_id"`
Overwrite bool `json:"overwrite"`
}
@ -149,11 +150,18 @@ func SaveSite(c *gin.Context) {
return
}
enabledConfigFilePath := nginx.GetConfPath("sites-enabled", name)
s := query.Site
_, err = s.Where(s.Path.Eq(path)).Update(s.SiteCategoryID, json.SiteCategoryID)
if err != nil {
api.ErrHandler(c, err)
return
}
// rename the config file if needed
if name != json.Name {
newPath := nginx.GetConfPath("sites-available", json.Name)
s := query.Site
_, err = s.Where(s.Path.Eq(path)).Update(s.Path, newPath)
_, _ = s.Where(s.Path.Eq(path)).Update(s.Path, newPath)
// check if dst file exists, do not rename
if helper.FileExists(newPath) {

View file

@ -3,15 +3,16 @@ package sites
import (
"github.com/0xJacky/Nginx-UI/internal/cert"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/0xJacky/Nginx-UI/model"
"github.com/sashabaranov/go-openai"
"time"
)
type Site struct {
ModifiedAt time.Time `json:"modified_at"`
Advanced bool `json:"advanced"`
Enabled bool `json:"enabled"`
*model.Site
Name string `json:"name"`
ModifiedAt time.Time `json:"modified_at"`
Enabled bool `json:"enabled"`
Config string `json:"config"`
AutoCert bool `json:"auto_cert"`
ChatGPTMessages []openai.ChatCompletionMessage `json:"chatgpt_messages,omitempty"`

View file

@ -39,6 +39,7 @@
"reconnecting-websocket": "^4.4.0",
"sortablejs": "^1.15.3",
"universal-cookie": "^7.2.1",
"unocss": "^0.63.6",
"vite-plugin-build-id": "0.4.2",
"vue": "^3.5.12",
"vue-dompurify-html": "^5.1.0",
@ -51,6 +52,12 @@
},
"devDependencies": {
"@antfu/eslint-config": "^3.8.0",
"@iconify-json/fa": "1.1.5",
"@iconify-json/tabler": "1.1.95",
"@iconify/tools": "3.0.5",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^2.1.33",
"@iconify/vue": "4.1.1",
"@simplewebauthn/types": "^11.0.0",
"@types/lodash": "^4.17.12",
"@types/nprogress": "^0.2.3",

1485
app/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -1,6 +1,7 @@
import type { CertificateInfo } from '@/api/cert'
import type { NgxConfig } from '@/api/ngx'
import type { ChatComplicationMessage } from '@/api/openai'
import type { SiteCategory } from '@/api/site_category'
import type { PrivateKeyType } from '@/constants'
import Curd from '@/api/curd'
import http from '@/lib/http'
@ -16,6 +17,8 @@ export interface Site {
chatgpt_messages: ChatComplicationMessage[]
tokenized?: NgxConfig
cert_info?: Record<number, CertificateInfo[]>
site_category_id: number
site_category?: SiteCategory
}
export interface AutoCertRequest {

View file

@ -1,13 +1,13 @@
<script setup lang="ts">
import type Curd from '@/api/curd'
import type { Column } from '@/components/StdDesign/types'
import type { Ref } from 'vue'
import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
import { watchOnce } from '@vueuse/core'
import _ from 'lodash'
const props = defineProps<{
placeholder?: string
label?: string
selectedKey: number | number[] | undefined | null
selectionType: 'radio' | 'checkbox'
recordValueIndex: string // to index the value of the record
// eslint-disable-next-line ts/no-explicit-any
@ -24,9 +24,19 @@ const props = defineProps<{
disabled?: boolean
// eslint-disable-next-line ts/no-explicit-any
valueApi?: Curd<any>
// eslint-disable-next-line ts/no-explicit-any
getCheckboxProps?: (record: any) => any
hideInputContainer?: boolean
}>()
const emit = defineEmits(['update:selectedKey'])
const selectedKey = defineModel<number | number[] | undefined | null | string | string[]>('selectedKey')
onMounted(() => {
if (!selectedKey.value)
watchOnce(selectedKey, _init)
else
_init()
})
const getParams = computed(() => {
return props.getParams
@ -34,19 +44,23 @@ const getParams = computed(() => {
const visible = ref(false)
// eslint-disable-next-line ts/no-explicit-any
const M_values = ref([]) as any
const M_values = ref([]) as Ref<any[]>
const init = _.debounce(_init, 500, {
leading: true,
trailing: false,
})
onMounted(() => {
init()
const ComputedMValue = computed(() => {
return M_values.value.filter(v => v && Object.keys(v).length > 0)
})
// eslint-disable-next-line ts/no-explicit-any
const records = ref([]) as Ref<any[]>
const records = defineModel<any[]>('selectedRecords', {
default: () => [],
})
watch(() => props.value, () => {
if (props.selectionType === 'radio')
M_values.value = [props.value]
else if (typeof selectedKey.value === 'object')
M_values.value = props.value || []
})
async function _init() {
// valueApi is used to fetch items that are using itemKey as index value
@ -55,22 +69,22 @@ async function _init() {
M_values.value = []
if (props.selectionType === 'radio') {
// M_values.value = [props.value] // not init value, we need to fetch them from api
if (!props.value && props.selectedKey) {
api.get(props.selectedKey, props.getParams).then(r => {
// M_values.value = [props.value]
// not init value, we need to fetch them from api
if (!props.value && selectedKey.value && selectedKey.value !== '0') {
api.get(selectedKey.value, props.getParams).then(r => {
M_values.value = [r]
records.value = [r]
})
}
}
else if (typeof props.selectedKey === 'object') {
M_values.value = props.value || []
else if (typeof selectedKey.value === 'object') {
// M_values.value = props.value || []
// not init value, we need to fetch them from api
if (!props.value && (props.selectedKey?.length || 0) > 0) {
if (!props.value && (selectedKey.value?.length || 0) > 0) {
api.get_list({
...props.getParams,
id: props.selectedKey,
id: selectedKey.value,
}).then(r => {
M_values.value = r.data
records.value = r.data
@ -85,11 +99,22 @@ function show() {
}
const selectedKeyBuffer = ref()
// eslint-disable-next-line ts/no-explicit-any
const selectedBuffer: Ref<any[]> = ref([])
if (props.selectionType === 'radio')
selectedKeyBuffer.value = [props.selectedKey]
else
selectedKeyBuffer.value = props.selectedKey
watch(selectedKey, () => {
selectedKeyBuffer.value = _.clone(selectedKey.value)
})
watch(records, v => {
selectedBuffer.value = [...v]
M_values.value = [...v]
})
onMounted(() => {
selectedKeyBuffer.value = _.clone(selectedKey.value)
selectedBuffer.value = _.clone(records.value)
})
const computedSelectedKeys = computed({
get() {
@ -103,41 +128,36 @@ const computedSelectedKeys = computed({
},
})
onMounted(() => {
if (props.selectedKey === undefined || props.selectedKey === null) {
if (props.selectionType === 'radio')
emit('update:selectedKey', '')
else
emit('update:selectedKey', [])
}
})
async function ok() {
visible.value = false
emit('update:selectedKey', selectedKeyBuffer.value)
selectedKey.value = selectedKeyBuffer.value
records.value = selectedBuffer.value
await nextTick()
M_values.value = _.clone(records.value)
}
watchEffect(() => {
init()
})
// function clear() {
// M_values.value = []
// emit('update:selectedKey', '')
// }
defineExpose({ show })
</script>
<template>
<div class="std-selector-container">
<div>
<div
v-if="!hideInputContainer"
class="std-selector-container"
>
<div
class="std-selector"
@click="show"
>
<div class="chips-container">
<div v-if="props.recordValueIndex">
<ATag
v-for="(chipText, index) in M_values"
v-for="(chipText, index) in ComputedMValue"
:key="index"
class="mr-1"
color="orange"
@ -147,6 +167,15 @@ watchEffect(() => {
{{ chipText?.[recordValueIndex] }}
</ATag>
</div>
<div
v-else
class="text-gray-400"
>
{{ placeholder }}
</div>
</div>
</div>
</div>
<AModal
:mask="false"
:open="visible"
@ -161,26 +190,26 @@ watchEffect(() => {
{{ description }}
<StdTable
v-model:selected-row-keys="computedSelectedKeys"
v-model:selected-rows="records"
:api="api"
:columns="columns"
:disable-search="disableSearch"
pithy
v-model:selected-rows="selectedBuffer"
:api
:columns
:disable-search
:row-key="itemKey"
:get-params="getParams"
:selection-type="selectionType"
:get-params
:selection-type
:get-checkbox-props
pithy
disable-query-params
/>
</AModal>
</div>
</div>
</template>
<style lang="less" scoped>
.std-selector-container {
min-height: 39.9px;
display: flex;
align-items: flex-start;
align-items: self-start;
.std-selector {
overflow-y: auto;
@ -195,7 +224,7 @@ watchEffect(() => {
line-height: 1.5;
background-image: none;
border: 1px solid #d9d9d9;
border-radius: 4px;
border-radius: 6px;
transition: all 0.3s;
//margin: 0 10px 0 0;
cursor: pointer;
@ -203,9 +232,10 @@ watchEffect(() => {
}
}
.chips-container {
span {
margin: 2px;
.dark {
.std-selector {
border: 1px solid #424242;
background-color: #141414;
}
}
</style>

View file

@ -7,7 +7,7 @@ import VueDOMPurifyHTML from 'vue-dompurify-html'
import App from './App.vue'
import gettext from './gettext'
import router from './routes'
import './style.css'
import 'virtual:uno.css'
const pinia = createPinia()

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -40,7 +40,7 @@ const saving = ref(false)
const filename = ref('')
const parse_error_status = ref(false)
const parse_error_message = ref('')
const data = ref({})
const data = ref({}) as Ref<Site>
init()
@ -134,6 +134,7 @@ async function save() {
name: filename.value || name.value,
content: configText.value,
overwrite: true,
site_category_id: data.value.site_category_id,
}).then(r => {
handle_response(r)
router.push({

View file

@ -4,10 +4,13 @@ import type { ChatComplicationMessage } from '@/api/openai'
import type { CheckedType } from '@/types'
import type { Ref } from 'vue'
import domain from '@/api/domain'
import site_category from '@/api/site_category'
import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
import StdSelector from '@/components/StdDesign/StdDataEntry/components/StdSelector.vue'
import { formatDateTime } from '@/lib/helper'
import { useSettingsStore } from '@/pinia'
import Deploy from '@/views/site/components/Deploy.vue'
import siteCategoryColumns from '@/views/site/site_category/columns'
import { message, Modal } from 'ant-design-vue'
const settings = useSettingsStore()
@ -73,6 +76,7 @@ function on_change_enabled(checked: CheckedType) {
key="1"
:header="$gettext('Basic')"
>
<AForm layout="vertical">
<AFormItem :label="$gettext('Enabled')">
<ASwitch
:checked="enabled"
@ -82,9 +86,19 @@ function on_change_enabled(checked: CheckedType) {
<AFormItem :label="$gettext('Name')">
<AInput v-model:value="filename" />
</AFormItem>
<AFormItem :label="$gettext('Category')">
<StdSelector
v-model:selected-key="data.site_category_id"
:api="site_category"
:columns="siteCategoryColumns"
record-value-index="name"
selection-type="radio"
/>
</AFormItem>
<AFormItem :label="$gettext('Updated at')">
{{ formatDateTime(data.modified_at) }}
</AFormItem>
</AForm>
</ACollapsePanel>
<ACollapsePanel
v-if="!settings.is_remote"

View file

@ -1,14 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
],
theme: {
extend: {},
},
plugins: [],
corePlugins: {
preflight: false,
},
}

66
app/uno.config.ts Normal file
View file

@ -0,0 +1,66 @@
// uno.config.ts
import {
defineConfig,
presetAttributify,
presetIcons,
presetTypography,
presetUno,
presetWebFonts,
transformerDirectives,
transformerVariantGroup,
} from 'unocss'
export default defineConfig({
shortcuts: [],
rules: [],
variants: [
// 使用工具函数
matcher => {
if (!matcher.endsWith('!'))
return matcher
return {
matcher: matcher.slice(0, -1),
selector: s => `${s}!important`,
}
},
],
theme: {
colors: {
// ...
},
},
presets: [
presetUno(),
presetAttributify(),
presetIcons({
collections: {
tabler: () => import('@iconify-json/tabler/icons.json').then(i => i.default),
},
extraProperties: {
'display': 'inline-block',
'height': '1.2em',
'width': '1.2em',
'vertical-align': 'text-bottom',
},
}),
presetTypography(),
presetWebFonts(),
],
transformers: [
transformerDirectives(),
transformerVariantGroup(),
],
content: {
pipeline: {
include: [
// default
/\.(vue|[jt]sx|ts)($|\?)/,
// 参考https://unocss.dev/guide/extracting#extracting-from-build-tools-pipeline
],
// exclude files
// exclude: []
},
},
})

View file

@ -1,10 +1,10 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import UnoCSS from 'unocss/vite'
import AutoImport from 'unplugin-auto-import/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import DefineOptions from 'unplugin-vue-define-options/vite'
import { defineConfig, loadEnv } from 'vite'
import vitePluginBuildId from 'vite-plugin-build-id'
@ -34,9 +34,9 @@ export default defineConfig(({ mode }) => {
plugins: [
vue(),
vueJsx(),
vitePluginBuildId(),
svgLoader(),
UnoCSS(),
Components({
resolvers: [AntDesignVueResolver({ importStyle: false })],
directoryAsNamespace: true,

View file

@ -4,4 +4,6 @@ type Site struct {
Model
Path string `json:"path"`
Advanced bool `json:"advanced"`
SiteCategoryID uint64 `json:"site_category_id"`
SiteCategory *SiteCategory `json:"site_category,omitempty"`
}

View file

@ -34,6 +34,12 @@ func newSite(db *gorm.DB, opts ...gen.DOOption) site {
_site.DeletedAt = field.NewField(tableName, "deleted_at")
_site.Path = field.NewString(tableName, "path")
_site.Advanced = field.NewBool(tableName, "advanced")
_site.SiteCategoryID = field.NewUint64(tableName, "site_category_id")
_site.SiteCategory = siteBelongsToSiteCategory{
db: db.Session(&gorm.Session{}),
RelationField: field.NewRelation("SiteCategory", "model.SiteCategory"),
}
_site.fillFieldMap()
@ -50,6 +56,8 @@ type site struct {
DeletedAt field.Field
Path field.String
Advanced field.Bool
SiteCategoryID field.Uint64
SiteCategory siteBelongsToSiteCategory
fieldMap map[string]field.Expr
}
@ -72,6 +80,7 @@ func (s *site) updateTableName(table string) *site {
s.DeletedAt = field.NewField(table, "deleted_at")
s.Path = field.NewString(table, "path")
s.Advanced = field.NewBool(table, "advanced")
s.SiteCategoryID = field.NewUint64(table, "site_category_id")
s.fillFieldMap()
@ -88,13 +97,15 @@ func (s *site) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
}
func (s *site) fillFieldMap() {
s.fieldMap = make(map[string]field.Expr, 6)
s.fieldMap = make(map[string]field.Expr, 8)
s.fieldMap["id"] = s.ID
s.fieldMap["created_at"] = s.CreatedAt
s.fieldMap["updated_at"] = s.UpdatedAt
s.fieldMap["deleted_at"] = s.DeletedAt
s.fieldMap["path"] = s.Path
s.fieldMap["advanced"] = s.Advanced
s.fieldMap["site_category_id"] = s.SiteCategoryID
}
func (s site) clone(db *gorm.DB) site {
@ -107,6 +118,77 @@ func (s site) replaceDB(db *gorm.DB) site {
return s
}
type siteBelongsToSiteCategory struct {
db *gorm.DB
field.RelationField
}
func (a siteBelongsToSiteCategory) Where(conds ...field.Expr) *siteBelongsToSiteCategory {
if len(conds) == 0 {
return &a
}
exprs := make([]clause.Expression, 0, len(conds))
for _, cond := range conds {
exprs = append(exprs, cond.BeCond().(clause.Expression))
}
a.db = a.db.Clauses(clause.Where{Exprs: exprs})
return &a
}
func (a siteBelongsToSiteCategory) WithContext(ctx context.Context) *siteBelongsToSiteCategory {
a.db = a.db.WithContext(ctx)
return &a
}
func (a siteBelongsToSiteCategory) Session(session *gorm.Session) *siteBelongsToSiteCategory {
a.db = a.db.Session(session)
return &a
}
func (a siteBelongsToSiteCategory) Model(m *model.Site) *siteBelongsToSiteCategoryTx {
return &siteBelongsToSiteCategoryTx{a.db.Model(m).Association(a.Name())}
}
type siteBelongsToSiteCategoryTx struct{ tx *gorm.Association }
func (a siteBelongsToSiteCategoryTx) Find() (result *model.SiteCategory, err error) {
return result, a.tx.Find(&result)
}
func (a siteBelongsToSiteCategoryTx) Append(values ...*model.SiteCategory) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Append(targetValues...)
}
func (a siteBelongsToSiteCategoryTx) Replace(values ...*model.SiteCategory) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Replace(targetValues...)
}
func (a siteBelongsToSiteCategoryTx) Delete(values ...*model.SiteCategory) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Delete(targetValues...)
}
func (a siteBelongsToSiteCategoryTx) Clear() error {
return a.tx.Clear()
}
func (a siteBelongsToSiteCategoryTx) Count() int64 {
return a.tx.Count()
}
type siteDo struct{ gen.DO }
// FirstByID Where("id=@id")