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(),
@ -125,9 +125,10 @@ func SaveSite(c *gin.Context) {
}
var json struct {
Name string `json:"name" binding:"required"`
Content string `json:"content" binding:"required"`
Overwrite bool `json:"overwrite"`
Name string `json:"name" binding:"required"`
Content string `json:"content" binding:"required"`
SiteCategoryID uint64 `json:"site_category_id"`
Overwrite bool `json:"overwrite"`
}
if !api.BindAndValid(c, &json) {
@ -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,76 +128,80 @@ 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
class="std-selector"
@click="show"
v-if="!hideInputContainer"
class="std-selector-container"
>
<div class="chips-container">
<ATag
v-for="(chipText, index) in M_values"
:key="index"
class="mr-1"
color="orange"
:bordered="false"
@click="show"
>
{{ chipText?.[recordValueIndex] }}
</ATag>
</div>
<AModal
:mask="false"
:open="visible"
:cancel-text="$gettext('Cancel')"
:ok-text="$gettext('Ok')"
:title="$gettext('Selector')"
:width="800"
destroy-on-close
@cancel="visible = false"
@ok="ok"
<div
class="std-selector"
@click="show"
>
{{ description }}
<StdTable
v-model:selected-row-keys="computedSelectedKeys"
v-model:selected-rows="records"
:api="api"
:columns="columns"
:disable-search="disableSearch"
pithy
:row-key="itemKey"
:get-params="getParams"
:selection-type="selectionType"
disable-query-params
/>
</AModal>
<div class="chips-container">
<div v-if="props.recordValueIndex">
<ATag
v-for="(chipText, index) in ComputedMValue"
:key="index"
class="mr-1"
color="orange"
:bordered="false"
@click="show"
>
{{ chipText?.[recordValueIndex] }}
</ATag>
</div>
<div
v-else
class="text-gray-400"
>
{{ placeholder }}
</div>
</div>
</div>
</div>
<AModal
:mask="false"
:open="visible"
:cancel-text="$gettext('Cancel')"
:ok-text="$gettext('Ok')"
:title="$gettext('Selector')"
:width="800"
destroy-on-close
@cancel="visible = false"
@ok="ok"
>
{{ description }}
<StdTable
v-model:selected-row-keys="computedSelectedKeys"
v-model:selected-rows="selectedBuffer"
:api
:columns
:disable-search
:row-key="itemKey"
:get-params
:selection-type
:get-checkbox-props
pithy
disable-query-params
/>
</AModal>
</div>
</template>
@ -180,7 +209,7 @@ watchEffect(() => {
.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,18 +76,29 @@ function on_change_enabled(checked: CheckedType) {
key="1"
:header="$gettext('Basic')"
>
<AFormItem :label="$gettext('Enabled')">
<ASwitch
:checked="enabled"
@change="on_change_enabled"
/>
</AFormItem>
<AFormItem :label="$gettext('Name')">
<AInput v-model:value="filename" />
</AFormItem>
<AFormItem :label="$gettext('Updated at')">
{{ formatDateTime(data.modified_at) }}
</AFormItem>
<AForm layout="vertical">
<AFormItem :label="$gettext('Enabled')">
<ASwitch
:checked="enabled"
@change="on_change_enabled"
/>
</AFormItem>
<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

@ -2,6 +2,8 @@ package model
type Site struct {
Model
Path string `json:"path"`
Advanced bool `json:"advanced"`
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()
@ -43,13 +49,15 @@ func newSite(db *gorm.DB, opts ...gen.DOOption) site {
type site struct {
siteDo
ALL field.Asterisk
ID field.Uint64
CreatedAt field.Time
UpdatedAt field.Time
DeletedAt field.Field
Path field.String
Advanced field.Bool
ALL field.Asterisk
ID field.Uint64
CreatedAt field.Time
UpdatedAt field.Time
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")